Skip to content

sysroot/sidecar path-translation gaps #233

Description

@henrybear327

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions