Summary
Two syscall paths under --sysroot forget to convert between the name the guest uses and the name stored on disk.
On a case-insensitive host volume (the default APFS) the case-fold sidecar stores guest-created names as opaque
.ef_<hex> tokens, so any path that skips the conversion breaks in one of two directions:
- Bug 1 (disk -> guest): after
chdir into a guest-created directory, getcwd() and readlink("/proc/self/cwd") report the on-disk .ef_<hex> token path instead of the guest path, in both the calling process and forked children.
- Bug 2 (guest -> disk): a relative
access(2) on a guest-created file returns ENOENT, even though open() and stat() on the same name in the same directory succeed.
Both are 100% reproducible on a case-insensitive sysroot. It was caught during OCI image PR CI runs.
Bug 1: guest cwd leaks a .ef_ sidecar token across fork/exec
Reproducer
/* poc-cwd.c */
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#define WORK_DIR "/exec-cwd-work"
#define NESTED_DIR WORK_DIR "/Nested"
static int check_cwd(const char *ctx)
{
int bad = 0;
char cwd[512], proc_cwd[512];
if (!getcwd(cwd, sizeof cwd)) { perror("getcwd"); return 1; }
printf("[%s] getcwd() = %s\n", ctx, cwd);
if (strcmp(cwd, NESTED_DIR) != 0) { printf(" >> WRONG: expected %s\n", NESTED_DIR); bad = 1; }
if (strstr(cwd, ".ef_")) { printf(" >> LEAK: sidecar token in cwd\n"); bad = 1; }
ssize_t n = readlink("/proc/self/cwd", proc_cwd, sizeof proc_cwd - 1);
if (n < 0) { perror("readlink /proc/self/cwd"); return 1; }
proc_cwd[n] = '\0';
printf("[%s] /proc/self/cwd = %s\n", ctx, proc_cwd);
if (strcmp(proc_cwd, NESTED_DIR) != 0) { printf(" >> WRONG: expected %s\n", NESTED_DIR); bad = 1; }
if (strstr(proc_cwd, ".ef_")) { printf(" >> LEAK: sidecar token in /proc/self/cwd\n"); bad = 1; }
return bad;
}
int main(int argc, char **argv)
{
if (argc > 1 && !strcmp(argv[1], "--check")) /* forked-child re-check */
return check_cwd("child");
if (mkdir(WORK_DIR, 0755) < 0 && access(WORK_DIR, F_OK) != 0) { perror("mkdir work"); return 1; }
if (mkdir(NESTED_DIR, 0755) < 0 && access(NESTED_DIR, F_OK) != 0) { perror("mkdir nested"); return 1; }
if (chdir(NESTED_DIR) < 0) { perror("chdir"); return 1; }
int bad = check_cwd("parent");
char self[512];
ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
if (n < 0) { perror("readlink /proc/self/exe"); return 1; }
self[n] = '\0';
pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) {
char *cargv[] = { self, "--check", NULL };
execve(self, cargv, NULL);
perror("execve"); _exit(127);
}
int status = 0;
waitpid(pid, &status, 0);
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
printf(">> forked child observed the token leak (child exit=%d)\n",
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
bad = 1;
}
return bad;
}
The guest must create the directories itself: only guest-created names get sidecar tokens, and the token is what leaks. Two nested levels exercise the multi-component case. The fork + execve re-check matters because fork relaunches elfuse, whose lazily recomputed cwd is where the token leaks most visibly.
Build and run
aarch64-linux-gnu-gcc -static -O0 poc-cwd.c -o poc-cwd
d=$(mktemp -d); mkdir -p "$d/bin"; cp poc-cwd "$d/bin/poc-cwd"
./build/elfuse --sysroot "$d" "$d/bin/poc-cwd"
ls -a "$d" # guest dir is on disk as .ef_<hex>, not "exec-cwd-work"
rm -rf "$d"
Observed (main @ 6cee917)
[parent] getcwd() = /.ef_587aff5e13cf3813/.ef_f2ea99c58566b230
>> WRONG: expected /exec-cwd-work/Nested
>> LEAK: sidecar token in cwd
[parent] /proc/self/cwd = /.ef_587aff5e13cf3813/.ef_f2ea99c58566b230
>> WRONG: expected /exec-cwd-work/Nested
>> LEAK: sidecar token in /proc/self/cwd
[child] getcwd() = /.ef_587aff5e13cf3813/.ef_f2ea99c58566b230
>> LEAK ...
[child] /proc/self/cwd = /.ef_587aff5e13cf3813/.ef_f2ea99c58566b230
>> LEAK ...
>> forked child observed the token leak (child exit=1)
ls -a "$d" confirms the guest directory really is stored as
.ef_587aff5e13cf3813 on disk.
Expected
getcwd() = /exec-cwd-work/Nested
readlink("/proc/self/cwd") = /exec-cwd-work/Nested
Why it matters (real workload)
The JVM computes user.dir from getcwd() at startup; javac then resolves source files against it. With user.dir set to a .ef_<hex> path that does not exist as a guest path, javac fails. Shell builtins mask the bug (sh's pwd answers from $PWD), so only a fresh process that asks the kernel (/bin/pwd, the JVM, or the forked child above) exposes it.
Bug 2: relative access() returns spurious ENOENT (the javac case)
Reproducer
/* poc-access.c */
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define WORK_DIR "/access-work"
int main(void)
{
if (mkdir(WORK_DIR, 0755) < 0 && access(WORK_DIR, F_OK) != 0) { perror("mkdir"); return 1; }
if (chdir(WORK_DIR) < 0) { perror("chdir"); return 1; }
int fd = open("T.java", O_CREAT | O_WRONLY, 0644); /* guest creates it -> tokenized */
if (fd < 0) { perror("open(create)"); return 1; }
close(fd);
struct stat st;
printf("open/stat see T.java: stat=%d (size ok=%d)\n",
stat("T.java", &st), st.st_mode != 0);
if (access("T.java", F_OK) != 0) {
perror("access(\"T.java\", F_OK)");
printf(">> BUG: access() reports ENOENT for a file open()/stat() just saw\n");
printf(">> (this is exactly javac's \"error: file not found: T.java\")\n");
return 1;
}
printf("access(\"T.java\", F_OK) = 0 (ok)\n");
return 0;
}
Build and run
aarch64-linux-gnu-gcc -static -O0 poc-access.c -o poc-access
d=$(mktemp -d); mkdir -p "$d/bin"; cp poc-access "$d/bin/poc-access"
./build/elfuse --sysroot "$d" "$d/bin/poc-access"
rm -rf "$d"
Observed (main @ 6cee917)
open/stat see T.java: stat=0 (size ok=1)
>> BUG: access() reports ENOENT for a file open()/stat() just saw
>> (this is exactly javac's "error: file not found: T.java")
Expected
access("T.java", F_OK) should return 0, matching open/stat on the same
name.
Common root cause and status
Both bugs share one shape: the sidecar makes "the name the guest uses" and "the name on disk" different, and any path that forgets to convert between them, in either direction, breaks. Bug 1 forgot disk -> guest (reading cwd back); bug 2 forgot guest -> disk (checking access), or rather threw away a conversion it had already computed.
Summary
Two syscall paths under
--sysrootforget to convert between the name the guest uses and the name stored on disk.On a case-insensitive host volume (the default APFS) the case-fold sidecar stores guest-created names as opaque
.ef_<hex>tokens, so any path that skips the conversion breaks in one of two directions:chdirinto a guest-created directory,getcwd()andreadlink("/proc/self/cwd")report the on-disk.ef_<hex>token path instead of the guest path, in both the calling process and forked children.access(2)on a guest-created file returnsENOENT, even thoughopen()andstat()on the same name in the same directory succeed.Both are 100% reproducible on a case-insensitive sysroot. It was caught during OCI image PR CI runs.
Bug 1: guest cwd leaks a
.ef_sidecar token across fork/execReproducer
The guest must create the directories itself: only guest-created names get sidecar tokens, and the token is what leaks. Two nested levels exercise the multi-component case. The
fork+execvere-check matters becauseforkrelaunches elfuse, whose lazily recomputed cwd is where the token leaks most visibly.Build and run
Observed (main @ 6cee917)
ls -a "$d"confirms the guest directory really is stored as.ef_587aff5e13cf3813on disk.Expected
Why it matters (real workload)
The JVM computes
user.dirfromgetcwd()at startup;javacthen resolves source files against it. Withuser.dirset to a.ef_<hex>path that does not exist as a guest path, javac fails. Shell builtins mask the bug (sh'spwdanswers from$PWD), so only a fresh process that asks the kernel (/bin/pwd, the JVM, or the forked child above) exposes it.Bug 2: relative access() returns spurious ENOENT (the javac case)
Reproducer
Build and run
Observed (main @ 6cee917)
Expected
access("T.java", F_OK)should return0, matchingopen/staton the samename.
Common root cause and status
Both bugs share one shape: the sidecar makes "the name the guest uses" and "the name on disk" different, and any path that forgets to convert between them, in either direction, breaks. Bug 1 forgot disk -> guest (reading cwd back); bug 2 forgot guest -> disk (checking access), or rather threw away a conversion it had already computed.