From 16f7fde55a53db36847599358df98cc980d10f3d Mon Sep 17 00:00:00 2001 From: Chandarith Chea <45872539+chandarith-chea@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:09:57 +0700 Subject: [PATCH 1/2] Root changes directories and opens files whatever their mode (CAP_DAC_OVERRIDE) tar, running as root in a container, could not restore a Go module cache: Go makes module directories 0555, tar as root creates each directory with its final mode and then its contents, and the nested mkdir answered EACCES. The container's files are the Mac user's, so macOS enforces a mode's owner bits against the guest's root as against anyone; Linux lets uid 0 through. As root on 0.6.10, in a directory after chmod 555: mkdir, symlink, mkfifo, rmdir, and rename or unlink of a file on disk all failed; so did appending to a 0444 file, reading a 000 one, and listing or entering a 000 directory. New regular files seemed to work, because they are made in the in-memory upper layer (upm.h). They did not: the write-back into the 0555 directory got EACCES every time, the file stayed in memory and was lost when the container stopped (mkdir a; echo x > a/f; chmod 555 a; then docker cp showed an empty a/). A file call the host refused with EACCES is now done once more when the caller is root holding CAP_DAC_OVERRIDE (FS_DAC_REDO around fs_call and chdir, so every path call passes through it and the first attempt costs nothing more). That pass takes no fast path: the walk opens each directory on the way, and each one of the container's that lacks owner rwx is given it for the rest of the call, by descriptor, or, where the mode refuses the open itself, by its name in its already open parent with no symlink followed, so nothing outside the root changes. open, truncate, chdir and rename do the same for the leaf. The modes are put back by descriptor when the call ends (the entry may have been renamed or removed), unless something else changed them meanwhile; the call's own chmod is what stays. The image layer is never changed (no container writes it), nor a read-only bind mount. The write-back uses the same pass, whoever made the file. Another user still gets EACCES, and so does root after a capset that drops the capability. The in-memory layer let both make and rename new files in a 0555 directory (its mirror directories are 0777); it now asks the host directory's mode, and treats root without the capability as bound by it. Limits: while a redone call runs (microseconds) the container's other processes can see the wider mode, and an engine killed just then leaves it. dockivad rejects --cap-drop, so the capability can only be dropped from inside. Image files and directories without owner read or search permission stay shut to root. exec, chown, inotify and unix-socket paths are not redone. tests/native/rootdac_guest.c (scripts/verify_overlay.py, every runtime build, with and without the memory layer) does each operation as root in a directory made in the container, a bind mount and a 0555 directory of the image, at 0555 and 000, on files on disk and still in memory; then as another user and as root without the capability, expecting EACCES; the verifier checks the files and modes left on the host, that the image and a read-only mount are unchanged, and busybox tar extracting and rm -rf removing a tree of 0555 directories as root. It fails on main at the first mkdir. --- native/fs.h | 107 ++++++++++++++++++++-- native/memfs.h | 15 ++- native/overlay.h | 23 +++-- native/upm.h | 16 +++- scripts/verify_overlay.py | 77 +++++++++++++++- tests/native/rootdac_guest.c | 172 +++++++++++++++++++++++++++++++++++ 6 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 tests/native/rootdac_guest.c diff --git a/native/fs.h b/native/fs.h index bbc3cd7..90c395e 100644 --- a/native/fs.h +++ b/native/fs.h @@ -129,6 +129,12 @@ static _Thread_local ProcShared *proc_now=&proc_first; #define prctl_pdeathsig (proc_now->priv.pdeathsig) #define prctl_name (proc_now->priv.name) #define cap_held (proc_now->priv.caps) +/* Root's CAP_DAC_OVERRIDE (fs_dac_begin). denied: the host refused a call for a file's + or directory's mode (fs_error); on: the call is being done again for root; ro: its + walk is in a read-only bind mount, where no mode changes; held: what that pass + changed, to put back (fs_dac_end). */ +#define FS_DAC_HELD 64 +static _Thread_local struct { int on,denied,ro,count;struct { int fd;mode_t mode,set; } held[FS_DAC_HELD]; } fs_dac; /* merged: the listing of a directory both image layers hold (overlay.h). */ struct OvList;static void ov_list_free(struct OvList *list); typedef struct GuestDirectory { DIR *stream;unsigned refs,count;char path[1025];struct OvList *merged;int lower; } GuestDirectory; /* lower: opened there only */ @@ -221,6 +227,7 @@ static uint64_t fs_error(void) { case ENOTEMPTY:return linux_error(39);case EROFS:return linux_error(30); case EFBIG:return linux_error(27);case ENOSPC:return linux_error(28); case EXDEV:return linux_error(18); /* openat2 RESOLVE_BENEATH, rename across mounts */ + case EACCES:fs_dac.denied=1;return linux_error(13); /* root does the call again (FS_DAC_REDO) */ default:return net_error(); } } @@ -504,6 +511,76 @@ static int fs_walk_fast(int base,const char *cur_start,const char *rest,int foll #define FS_ANY_AT 0 #define fs_renameat renameat #endif +/* Root's CAP_DAC_OVERRIDE. The container's files are the Mac user's, so macOS refuses + a directory or file whose mode lacks the owner's bits, to the guest's root as to + anyone, where Linux lets root through: tar as root makes a directory with its final + mode (0555 in Go's module cache) and then its contents, rm -rf removes them, and a + root shell lists a 0700-less directory. A file call the host refused with EACCES is + done once more for root holding the capability (FS_DAC_REDO), with fs_dac.on: that + pass takes no fast path, so the walk opens every directory on the way itself, and + each one of the container's (upper layer, writable bind mounts and volumes; never + the image's, which no container changes, nor a read-only mount's: fs_dac.ro) + lacking owner rwx is given them for the rest of + the call - by descriptor, or, where the mode refuses the open itself, by its name in + its already open parent with no symlink followed, so nothing outside the root is + changed. Callers that open the leaf do the same for it (fs_dac_leaf). fs_dac_end puts + each mode back by descriptor (the entry may be renamed or removed by then) unless it + changed meanwhile (a chmod by another thread; the call's own: fs_dac_chmod). + ponytail: for those microseconds the container's other processes see the wider + mode, and an engine killed then leaves it; CAP_DAC_READ_SEARCH alone is not honoured; + exec, chown, inotify and unix-socket paths are not redone (a root user rarely meets + an unsearchable directory there). */ +static int fs_dac_can(void) { return !guest_uid && (cap_held[0]&2u); } /* capability 1 */ +static int fs_dac_begin(void) { + int denied=fs_dac.denied;fs_dac.denied=0; + if(!denied || fs_dac.on || !fs_dac_can())return 0; + fs_dac.on=1;return 1; +} +static void fs_dac_end(void) { + int saved=errno;struct stat st; + while(fs_dac.count) { + int i=--fs_dac.count,fd=fs_dac.held[i].fd; + if(!fstat(fd,&st) && (st.st_mode&07777)==fs_dac.held[i].set)(void)fchmod(fd,fs_dac.held[i].mode); + close(fd); + } + fs_dac.on=0;errno=saved; +} +#define FS_DAC_REDO(result,call) do { if((result)==linux_error(13) && fs_dac_begin()) { (result)=(call);fs_dac_end(); } } while(0) +static mode_t fs_dac_need(const struct stat *st) { + mode_t need=S_ISDIR(st->st_mode)?0700:S_ISREG(st->st_mode)?0600:0; + return (st->st_mode&need)==need?0:need; +} +static void fs_dac_hold(int fd,mode_t mode,mode_t need) { + fs_dac.held[fs_dac.count].fd=fd;fs_dac.held[fs_dac.count].mode=mode;fs_dac.held[fs_dac.count++].set=mode|need; +} +/* A directory of the container's the pass opened (fd), or will change entries in. */ +static void fs_dac_fd(int fd) { + struct stat st;mode_t need=0; + if(!fs_dac.on || fs_dac.ro || fd<0 || fs_dac.count==FS_DAC_HELD || fstat(fd,&st) || !(need=fs_dac_need(&st)))return; + int keep=fcntl(fd,F_DUPFD_CLOEXEC,0); + if(keep<0 || fchmod(keep,(st.st_mode&07777)|need)) { if(keep>=0)close(keep);return; } + fs_dac_hold(keep,st.st_mode&07777,need); +} +/* The container's leaf in parent, before the pass opens it or changes its entries. */ +static void fs_dac_leaf(int parent,const char *leaf) { + struct stat st;mode_t need=0; + if(!fs_dac.on || fs_dac.ro || fs_dac.count==FS_DAC_HELD || fstatat(parent,leaf,&st,FS_NOFOLLOW_AT) || !(need=fs_dac_need(&st)))return; + mode_t mode=st.st_mode&07777; + if(fchmodat(parent,leaf,mode|need,FS_NOFOLLOW_AT))return; + int fd=openat(parent,leaf,O_RDONLY|FS_NOFOLLOW_OPEN|O_CLOEXEC|O_NONBLOCK); + if(fd<0) { (void)fchmodat(parent,leaf,mode,FS_NOFOLLOW_AT);return; } + fs_dac_hold(fd,mode,need); +} +/* The pass's own chmod of st's file to mode: that is what stays. */ +static void fs_dac_chmod(const struct stat *st,mode_t mode) { + struct stat held; + for(int i=0;ist_ino && held.st_dev==st->st_dev)fs_dac.held[i].mode=fs_dac.held[i].set=mode; +} +static int fs_subdir(int dir,const char *name) { + if(fs_dac.on)fs_dac_leaf(dir,name); + return openat(dir,name,O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); +} static int fs_walk(int base,const char *base_path,const char *input,int follow,char leaf[1025],char canon[1025]); static int ov_walk(int base,const char *base_path,const char *input,int follow,char leaf[1025],char canon[1025]); /* overlay.h */ /* A guest directory by canonical path, as a descriptor (a mount's own, if it is one). */ @@ -539,7 +616,7 @@ static int fs_walk(int base,const char *base_path,const char *input,int follow,c strcpy(cur,rest[0]=='/'?(fs_scope.root>=0?fs_scope.path:"/"):base_path); if(mem_mount_count && *cur && mem_mount_inside(cur))return mem_redirect_set(cur,rest); #ifdef O_NOFOLLOW_ANY - { /* The fast path stays in base's host directory tree: not across a mount. */ + if(!fs_dac.on) { /* The fast path stays in base's host directory tree: not across a mount. */ char full[1025];int fast=fs_walk_fast(base,cur,rest,follow,direct,leaf,full); if(fast!=-2) { if(fast>=0) { fs_where=fs_mount_of(full);if(canon)strcpy(canon,full); } @@ -548,6 +625,7 @@ static int fs_walk(int base,const char *base_path,const char *input,int follow,c } #endif int fd=dup(base);if(fd<0)return -1; + if(fs_dac.on) { int m=fs_mount_count?fs_mount_of(cur):0;fs_dac.ro=m>0 && fs_mounts[m-1].readonly;fs_dac_fd(fd); } for(;;) { while(rest[pos]=='/')pos++; if(!rest[pos]) { strcpy(leaf,".");if(canon)strcpy(canon,cur);fs_where=fs_mount_of(cur);return fd; } @@ -563,13 +641,14 @@ static int fs_walk(int base,const char *base_path,const char *input,int follow,c if(!*cur)continue; /* position unknown: stay, as at "/" */ fs_canon_pop(cur); int parent=fs_open_dir(cur);int saved=errno;close(fd);fd=parent;errno=saved;if(fd<0)return -1; + fs_dac_fd(fd); continue; } if(fs_scope.resolve&8 && fs_is_root(fd)) { close(fd);errno=EXDEV;return -1; } if(!fs_is_root(fd)) { int next=openat(fd,"..",O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); int saved=errno;close(fd);fd=next;errno=saved;if(fd<0)return -1; - fs_canon_pop(cur); + fs_dac_fd(fd);fs_canon_pop(cur); } continue; } @@ -584,7 +663,7 @@ static int fs_walk(int base,const char *base_path,const char *input,int follow,c if(m>=0) { if(!fs_mounts[m].directory && !last) { close(fd);errno=ENOTDIR;return -1; } close(fd);fd=dup(fs_mounts[m].fd);if(fd<0)return -1; - strcpy(cur,at); + fs_dac.ro=fs_mounts[m].readonly;fs_dac_fd(fd);strcpy(cur,at); if(last) { strcpy(leaf,fs_mounts[m].directory?".":fs_mounts[m].name); if(canon)strcpy(canon,at); @@ -607,6 +686,7 @@ static int fs_walk(int base,const char *base_path,const char *input,int follow,c if(fs_scope.resolve&8) { close(fd);errno=EXDEV;return -1; } close(fd);fd=dup(FS_WALK_ROOT);if(fd<0)return -1; strcpy(cur,fs_scope.root>=0?fs_scope.path:"/"); + if(fs_dac.on) { int m=fs_mount_count?fs_mount_of(cur):0;fs_dac.ro=m>0 && fs_mounts[m-1].readonly;fs_dac_fd(fd); } } continue; } @@ -615,12 +695,14 @@ static int fs_walk(int base,const char *base_path,const char *input,int follow,c if(canon) { strcpy(canon,cur);if(fs_canon_push(canon,part)<0) { close(fd);return -1; } } fs_where=fs_mount_of(cur);return fd; } - int next=openat(fd,part,O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); + int next=fs_subdir(fd,part); int saved=errno;close(fd);fd=next;errno=saved;if(fd<0)return -1; if(fs_canon_push(cur,part)<0) { close(fd);return -1; } } } #include "overlay.h" +/* Whether the last walk's leaf is the container's own (fs_dac): not an image file. */ +static int ov_leaf_upper(void) { return !(fs_lower>=0 && ov_leaf.canon[0] && ov_leaf.layer==1); } /* Minimal /proc: /proc/self/exe and /proc//exe name each process's executable (canonical guest paths, indexed by pid; PROCESS_LIMIT+1 processes). Path lookups resolve them to that file; readlink returns the path. */ @@ -700,15 +782,17 @@ static uint64_t fs_fchdir(void) { /* Working directories remain descriptor anchored and record the canonical (link-free) path. A renamed cwd is usable for relative I/O, but getcwd fails instead of returning an unverified stale pathname. */ -static uint64_t fs_chdir(void) { +static uint64_t fs_chdir_once(void) { char leaf[1025],canon[1025]; int parent=fs_lookup((uint64_t)-100,cpu->gpr[0],leaf,1,canon);if(parent<0)return fs_error(); + if(fs_dac.on && ov_leaf_upper())fs_dac_leaf(parent,leaf); int fd=openat(parent,leaf,O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); int saved=errno;close(parent);errno=saved;if(fd<0)return fs_error(); GuestCwd *next=calloc(1,sizeof(*next));if(!next) { close(fd);return linux_error(12); } next->fd=fd;next->refs=1;strcpy(next->path,canon); fs_cwd_release();fs_cwd=next;return 0; } +static uint64_t fs_chdir(void) { uint64_t result=fs_chdir_once();FS_DAC_REDO(result,fs_chdir_once());return result; } /* Initial working directory from --cwd (an image WorkingDir), resolved inside the root. */ static int mem_start_cwd(const char *path); static int fs_start_cwd(const char *path) { @@ -1547,6 +1631,7 @@ static uint64_t fs_open(void) { refuses one before the leaf). */ int link=opath && exists && S_ISLNK(before.st_mode); if(link)options=O_RDONLY|O_SYMLINK|FS_NOFOLLOW_OPEN|O_CLOEXEC|O_NONBLOCK; + if(fs_dac.on && exists && !(fs_lower>=0 && layers.canon[0] && layers.layer==1))fs_dac_leaf(parent,leaf); /* root opens whatever its mode */ int fd=openat(parent,leaf,options|(fresh?O_EXCL:0),mode); if(fd<0 && fresh && errno==EEXIST && !(flags&128)) { fresh=0;fd=openat(parent,leaf,options,mode); } if(fd<0) { uint64_t e=fs_error();close(parent);return e; } @@ -1756,7 +1841,9 @@ static uint64_t fs_flock(int fd) { return (operation&4) || !schedule_active?linux_error(11):IO_BLOCK; return fs_lock_set(st.st_dev,st.st_ino,owner,0,INT64_MAX,type); } -static uint64_t fs_call(uint64_t call) { +static uint64_t fs_call_once(uint64_t call); +static uint64_t fs_call(uint64_t call) { uint64_t result=fs_call_once(call);FS_DAC_REDO(result,fs_call_once(call));return result; } +static uint64_t fs_call_once(uint64_t call) { if(call==166) { unsigned previous=fs_umask;fs_umask=(unsigned)cpu->gpr[0]&0777;return previous; } if(call==52 || call==53 || call==452) { uint64_t mode=cpu->gpr[call==52?1:2]&UINT64_C(07777); /* Linux ignores other bits (libuv passes st_mode); setuid/setgid/sticky allowed */ @@ -1778,6 +1865,7 @@ static uint64_t fs_call(uint64_t call) { struct stat st;int result=fs_leaf_stat(parent,leaf,&st); if(!result && !S_ISREG(st.st_mode) && !S_ISDIR(st.st_mode) && !S_ISSOCK(st.st_mode)) { errno=EACCES;result=-1; } if(!result)result=fchmodat(parent,leaf,(mode_t)mode,FS_NOFOLLOW_AT); + if(!result && fs_dac.on)fs_dac_chmod(&st,(mode_t)mode); int saved=errno;close(parent);errno=saved;return result<0?fs_error():0; } else if(fd<0)return linux_error(9); struct stat st;int result=fstat(fd,&st); @@ -1873,6 +1961,7 @@ static uint64_t fs_call(uint64_t call) { char leaf[1025];int parent=fs_lookup((uint64_t)-100,cpu->gpr[0],leaf,1|FS_DIRECT,NULL);if(parent<0)return fs_error(); if(!fs_path_writable()) { close(parent);return linux_error(30); } { OvLeaf w=ov_leaf;if(ov_prepare(&w,&parent,leaf,OV_MODIFY)) { uint64_t e=fs_error();close(parent);return e; } } + fs_dac_leaf(parent,leaf); int fd=openat(parent,leaf,O_WRONLY|FS_NOFOLLOW_OPEN|O_NONBLOCK|O_CLOEXEC);int saved=errno;close(parent);errno=saved; if(fd<0)return fs_error(); struct stat st;if(fstat(fd,&st)<0 || !S_ISREG(st.st_mode)) { saved=S_ISDIR(st.st_mode)?EISDIR:EINVAL;close(fd);errno=saved;return fs_error(); } @@ -2000,6 +2089,7 @@ static uint64_t fs_call(uint64_t call) { if(!fs_path_writable()) { close(other);close(parent);return linux_error(30); } if(!strcmp(target,".")) { close(other);close(parent);return linux_error(fs_where>0?16:13); } OvLeaf to=ov_leaf;struct stat moving; + if(fs_dac.on && !(layers && w.layer==1))fs_dac_leaf(parent,leaf); /* a directory moving to another one is written (its "..") */ int directory=upm_fs && layers && to.canon[0] && !fstatat(parent,leaf,&moving,FS_NOFOLLOW_AT) && S_ISDIR(moving.st_mode); if(directory && upm_has_files(to.canon)) { result=-1;errno=ENOTEMPTY; } /* its pending files (upm.h) are entries */ else if(layers || (fs_lower>=0 && to.canon[0]))result=layers && to.canon[0]?ov_rename(&w,parent,leaf,&to,other,target):(errno=EXDEV,-1); @@ -2042,7 +2132,10 @@ static uint64_t fs_call(uint64_t call) { int layers=fs_lower>=0 && dir->path[0] && !(fs_mount_count && fs_mount_of(dir->path)); if(!dir->stream && !dir->merged) { struct stat below; /* only a directory both layers hold merges: a lower-only one (most of an image's) streams as it is */ - if(layers && !dir->lower && ((!fstatat(fs_lower,dir->path[1]?dir->path+1:".",&below,FS_NOFOLLOW_AT) && S_ISDIR(below.st_mode)) || upm_has_files(dir->path)))dir->merged=ov_list(dir->path); + if(layers && !dir->lower && ((!fstatat(fs_lower,dir->path[1]?dir->path+1:".",&below,FS_NOFOLLOW_AT) && S_ISDIR(below.st_mode)) || upm_has_files(dir->path))) { + errno=0;dir->merged=ov_list(dir->path); + if(!dir->merged && errno==EACCES && fs_dac_can())return fs_error(); /* its mode refuses the walk there: root's redo lists it whole */ + } if(!dir->merged) { int copy=dup(fd);if(copy<0)return fs_error(); dir->stream=fdopendir(copy);if(!dir->stream) { int saved=errno;close(copy);errno=saved;return fs_error(); } diff --git a/native/memfs.h b/native/memfs.h index 24818f1..ec823a1 100644 --- a/native/memfs.h +++ b/native/memfs.h @@ -64,10 +64,19 @@ static int mem_is(const MemNode *n,uint32_t type) { return (n->mode&0170000)==ty #define MEM_DIR 0040000u #define MEM_REG 0100000u #define MEM_LNK 0120000u -/* rwx (4, 2, 1) for the caller: root passes, except execute on a file with no x bit. */ +/* rwx (4, 2, 1) for the caller: root passes, except execute on a file with no x bit. + An upper directory's mirror (upm.h) is 0777 whatever the directory's mode: changing + its entries is the host directory's to allow, as for the files on disk beside them + (a user made files in a 0555 directory, and renamed ones not yet written back). */ +static void mem_dir_path(const MemNode *n,char out[1025]); static int mem_may(const MemNode *n,unsigned want) { - if(!guest_uid)return !(want&1) || mem_is(n,MEM_DIR) || (n->mode&0111); - unsigned bits=guest_uid==n->uid || n->fs==upm_fs?(n->mode>>6)&7:guest_gid==n->gid?(n->mode>>3)&7:n->mode&7; /* upm.h: owned as host files are */ + int layer=n->fs==upm_fs; + if(!guest_uid && !(layer && !fs_dac_can()))return !(want&1) || mem_is(n,MEM_DIR) || (n->mode&0111); /* (the layer's files: root holding CAP_DAC_OVERRIDE, fs.h) */ + if(layer && (want&2) && mem_is(n,MEM_DIR)) { + char path[1025];struct stat st;mem_dir_path(n,path); + if(path[0] && !fstatat(fs_root,path[1]?path+1:".",&st,FS_NOFOLLOW_AT) && (st.st_mode&0300)!=0300)return 0; + } + unsigned bits=guest_uid==n->uid || layer?(n->mode>>6)&7:guest_gid==n->gid?(n->mode>>3)&7:n->mode&7; /* upm.h: owned as host files are */ return (bits&want)==want; } /* Removing or renaming node out of dir: the sticky bit keeps others' files. */ diff --git a/native/overlay.h b/native/overlay.h index 94256ac..71ad5aa 100644 --- a/native/overlay.h +++ b/native/overlay.h @@ -169,6 +169,7 @@ static int ov_mark(int dir,const char *name) { /* make ".wh.N static void ov_unmark(int dir,const char *name) { char wh[300];if(snprintf(wh,sizeof wh,OV_WH "%s",name)<=255)unlinkat(dir,wh,0); } static void ov_close2(int a,int b) { int saved=errno;if(a>=0)close(a);if(b>=0)close(b);errno=saved; } static int ov_subdir(int dir,const char *name) { return openat(dir,name,O_RDONLY|O_DIRECTORY|O_NOFOLLOW|O_CLOEXEC); } +#define ov_updir fs_subdir /* an upper or mounted directory: root's redo opens it whatever its mode (fs_dac, fs.h) */ /* A directory stream from its start (a dup shares the descriptor's position). */ static DIR *ov_opendir(int dir) { int copy=dup(dir);DIR *s=copy>=0 && lseek(copy,0,SEEK_SET)==0?fdopendir(copy):NULL; @@ -190,19 +191,22 @@ static int ov_pair(const char *canon,int *upper,int *lower,int *mount) { if(m>0) { if(!fs_mounts[m-1].directory) { errno=ENOTDIR;return -1; } *mount=m-1;p+=strlen(fs_mounts[m-1].path);*upper=dup(fs_mounts[m-1].fd);if(*upper<0)return -1; + fs_dac.ro=fs_mounts[m-1].readonly;fs_dac_fd(*upper); } else { + fs_dac.ro=0; /* Most directories are in one layer only: a path the lower layer lacks is the upper one's, found in one call (and the reverse needs the walk). */ struct stat st; - if(canon[1] && fstatat(fs_lower,canon+1,&st,FS_NOFOLLOW_AT) && (errno==ENOENT || errno==ENOTDIR)) { + if(!fs_dac.on && canon[1] && fstatat(fs_lower,canon+1,&st,FS_NOFOLLOW_AT) && (errno==ENOENT || errno==ENOTDIR)) { *upper=openat(fs_root,canon+1,O_RDONLY|O_DIRECTORY|FS_NOFOLLOW_OPEN|O_CLOEXEC); if(*upper>=0 || errno!=ELOOP)return *upper<0?-1:0; } *upper=dup(fs_root);*lower=dup(fs_lower); if(*upper<0 || *lower<0) { ov_close2(*upper,*lower);return -1; } + fs_dac_fd(*upper); } while(ov_part(&p,part)) { - int u=*upper>=0?ov_subdir(*upper,part):-1; + int u=*upper>=0?ov_updir(*upper,part):-1; if(u<0 && *upper>=0 && errno!=ENOENT) { ov_close2(*upper,*lower);return -1; } /* a file or link hides what is below */ int l=-1; if(*lower>=0 && !(u>=0?ov_marker(u,NULL):*upper>=0 && ov_marker(*upper,part)))l=ov_subdir(*lower,part); @@ -233,11 +237,13 @@ static int ov_chmod(int dir,const char *name,mode_t mode) { /* A canonical guest directory of the root's layers in the upper layer: it and those above it are made from the lower ones where missing. Returns its descriptor. */ static int ov_upper(const char *canon) { - if(canon[1]) { int d=openat(fs_root,canon+1,O_RDONLY|O_DIRECTORY|FS_NOFOLLOW_OPEN|O_CLOEXEC);if(d>=0)return d; } /* there already */ + fs_dac.ro=0; + if(canon[1]) { int d=openat(fs_root,canon+1,O_RDONLY|O_DIRECTORY|FS_NOFOLLOW_OPEN|O_CLOEXEC);if(d>=0) { fs_dac_fd(d);return d; } } /* there already */ int u=dup(fs_root),l=dup(fs_lower);const char *p=canon;char part[256]; if(u<0 || l<0) { ov_close2(u,l);return -1; } + fs_dac_fd(u); while(ov_part(&p,part)) { - int nu=ov_subdir(u,part),nl=l>=0?ov_subdir(l,part):-1; + int nu=ov_updir(u,part),nl=l>=0?ov_subdir(l,part):-1; if(nu<0 && errno==ENOENT) { struct stat st; if(nl<0 || fstat(nl,&st) || ov_marker(u,part)) { ov_close2(u,l);ov_close2(nl,-1);errno=ENOENT;return -1; } @@ -248,7 +254,7 @@ static int ov_upper(const char *canon) { (void)ov_chmod(u,part,st.st_mode&07777);(void)utimensat(u,part,times,AT_SYMLINK_NOFOLLOW); ov_dirty=1; /* a lower directory now shows from above (fchmod of its descriptor): ov_changed */ } - nu=ov_subdir(u,part); + nu=ov_updir(u,part); } if(nu<0) { ov_close2(u,l);ov_close2(nl,-1);return -1; } if(nl>=0 && ov_marker(nu,NULL)) { close(nl);nl=-1; } /* an opaque directory: nothing lower is seen */ @@ -615,7 +621,7 @@ static int ov_walk(int base,const char *base_path,const char *input,int follow,c memcpy(rest,input,length+1); if(mem_mount_count && mem_mount_inside(cur))return mem_redirect_set(cur,rest); OvLinkWalk passed={0};char from[1025]; - if(fs_scope.root<0) { + if(fs_scope.root<0 && !fs_dac.on) { char to[1025];int fast; if(ov_link_get(cur,rest,follow,to) && (fast=ov_walk_fast("/",to,follow,direct,leaf,canon))!=-2)return fast; if((fast=ov_walk_fast(cur,rest,follow,direct,leaf,canon))!=-2)return fast; @@ -635,6 +641,7 @@ static int ov_walk(int base,const char *base_path,const char *input,int follow,c if(fd>=0) { strcpy(leaf,".");if(canon)strcpy(canon,cur); fs_where=0;ov_leaf.layer=layer;strcpy(ov_leaf.canon,cur); + if(!layer) { fs_dac.ro=0;fs_dac_fd(fd); } return fd; } } @@ -669,7 +676,7 @@ static int ov_walk(int base,const char *base_path,const char *input,int follow,c if(i>=0) { if(!fs_mounts[i].directory && !last) { ov_close2(u,l);errno=ENOTDIR;return -1; } ov_close2(u,l);u=dup(fs_mounts[i].fd);l=-1;mount=i;if(u<0)return -1; - strcpy(cur,at); + fs_dac.ro=fs_mounts[i].readonly;fs_dac_fd(u);strcpy(cur,at); if(last) { strcpy(leaf,fs_mounts[i].directory?".":fs_mounts[i].name);if(canon)strcpy(canon,at);fs_where=i+1;return u; } continue; } @@ -714,7 +721,7 @@ static int ov_walk(int base,const char *base_path,const char *input,int follow,c if(!S_ISDIR(st.st_mode)) { ov_close2(u,l);errno=ENOTDIR;return -1; } int nu=-1,nl=-1; if(layer==0) { - nu=ov_subdir(u,part); + nu=ov_updir(u,part); if(nu<0) { ov_close2(u,l);return -1; } if(mount<0 && l>=0 && !ov_marker(nu,NULL))nl=ov_subdir(l,part); } else if((nl=ov_subdir(l,part))<0) { ov_close2(u,l);return -1; } diff --git a/native/upm.h b/native/upm.h index 11c0af0..9105bb3 100644 --- a/native/upm.h +++ b/native/upm.h @@ -209,7 +209,18 @@ static int upm_write_back(MemNode *n,const char *canon,int force) { /* All from the directory, opened refusing a symlink anywhere on the way (another run, an exec, may have swapped one in): nothing lands outside the root. */ int parent=dir[1]?openat(fs_root,dir+1,O_RDONLY|O_DIRECTORY|FS_NOFOLLOW_OPEN|O_CLOEXEC):dup(fs_root); - int fd=parent>=0?openat(parent,temp,O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC|O_NOFOLLOW,0600):-1,ok=fd>=0; + int fd=parent>=0?openat(parent,temp,O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC|O_NOFOLLOW,0600):-1,forced=0; + if(fd<0 && errno==EACCES) { + /* The directory, or one above it, lost the owner's bits after the file was made + (Go fills a module's directory, then makes it 0555): the file is the guest's + already, so it is written whoever made it, as root's calls are (fs_dac, fs.h). + It stayed in memory and went with the container's end. */ + forced=!fs_dac.on;fs_dac.on=1; + if(parent>=0)close(parent); + parent=ov_upper(dir); + fd=parent>=0?openat(parent,temp,O_WRONLY|O_CREAT|O_EXCL|O_CLOEXEC|O_NOFOLLOW,0600):-1; + } + int ok=fd>=0; for(uint64_t at=0;ok && at=0)(void)unlinkat(parent,temp,0); if(parent>=0)close(parent); + if(forced)fs_dac_end(); mem_node_put(n); /* the pin (frees it once forgotten) */ return done; } @@ -262,6 +274,7 @@ static int64_t upm_move(MemNode *n,MemEntry *e,const char *from,const char *to,c } MemNode *into=upm_dir(dir,1);MemEntry *clash=into?mem_find(into,name):NULL; if(!into || (clash && clash->node))return 1; + if(!mem_may(into,3))return -13; upm_count(to,1); /* before it exists: walks look it up */ MemEntry *now=mem_link(into,name,n);int64_t r=now?0:-12; if(!r && disk) { @@ -285,6 +298,7 @@ static int64_t upm_cross(uint64_t call,MemPath *w,const int *res) { if(res[0]==1) { /* from a pending file */ MemNode *n=w[0].node;char from[1025];snprintf(from,sizeof from,"%s",w[0].canon); if(!n || !from[0]) { pthread_mutex_unlock(&mem_lock);return -2; } + if(call!=37 && w[0].dir && !mem_may(w[0].dir,3)) { pthread_mutex_unlock(&mem_lock);return -13; } /* its directory's mode, as mem_rename */ if(call!=37 && !(flags&2)) { /* a rename: in memory where it can be (upm_move) */ n->refs++;pthread_mutex_unlock(&mem_lock); char leaf[1025],to[1025];int parent=fs_lookup(x[2],x[3],leaf,FS_DIRECT,to); diff --git a/scripts/verify_overlay.py b/scripts/verify_overlay.py index 8fb191f..a4c16c4 100644 --- a/scripts/verify_overlay.py +++ b/scripts/verify_overlay.py @@ -12,7 +12,11 @@ mode, time and inode unchanged, and no copy-up leftovers may remain. Last, names that differ only by case (tests/native/case_guest.c) with both layers on a case-sensitive volume, as dockivad keeps them, with and without the memory layer, and statfs there -(tests/native/statfs_guest.c): the size of the volume --statfs-from names.""" +(tests/native/statfs_guest.c): the size of the volume --statfs-from names. And root's +CAP_DAC_OVERRIDE (tests/native/rootdac_guest.c): root changing 0555 and 000 directories +of the upper layer, a bind mount and the image, with and without the memory layer, +other users refused, the modes and the image left as they were, and a tar archive of +0555 directories and 0444 files extracted by busybox tar as root.""" import os from pathlib import Path import subprocess @@ -115,6 +119,68 @@ def upm_runs(t): return failed +def dac_runs(t): + """rootdac_guest.c on every built runtime, with and without the memory layer, a + bind mount at /vol; then what it left: every file on disk, each mode as the guest set it.""" + failed = 0 + lower = t/'dac-lower' + for name in ('low/ro/x', 'low/ro/mv', 'low/ro/keep', 'low/ro2/x'): + (lower/name).parent.mkdir(parents=True, exist_ok=True) + (lower/name).write_text(name.rsplit('/', 1)[1]) + (lower/'low/ro/sub').mkdir() + (lower/'low/ro').chmod(0o555) + (lower/'low/ro2').chmod(0o555) + build(ROOT/'tests/native/rootdac_guest.c', t/'rootdac') + before = snapshot(lower) + + def left(base, hard=True): + """What is wrong under base (the host's /w or /vol) after the run (hard: it made a hard link there).""" + a = base/'a' + wrong = [] if a.stat().st_mode & 0o7777 == 0o555 else [f'a is {oct(a.stat().st_mode)}'] + a.chmod(0o755) # to look inside + want = {'d1b': 'd', 'd3': 'd+', 'd4': 'd', 'd5': 'd', 'y1b': 'y', 'y3': 'y', 'keep': 'kept', 'z': 'z', 'cwd': 'c'} + if (a/'d3').stat().st_mode & 0o7777 != 0: + wrong.append('d3 mode') + (a/'d3').chmod(0o600) + wrong += [k for k, v in want.items() if not (a/k).is_file() or (a/k).read_text() != v] + wrong += [k for k in ('d/in', 'pre2', 'last') if not (a/k).is_dir()] + [k for k in ('pre', 'd1', 'd2', 'y1', 'y2', 'f', 'g', 'mvd') if os.path.lexists(a/k)] + if not (a/'l').is_symlink() or not (a/'p').is_fifo() or (base/'out').stat().st_mode & 0o7777 != 0o555: + wrong.append('link, fifo or moved directory') + if hard and (not (a/'h').is_file() or (a/'h').stat().st_ino != (a/'d4').stat().st_ino): + wrong.append('hard link') + return wrong + [str(p) for p in base.rglob('.wh.*')] + + for binary, runtime, extra in runtimes(): + for memory in (False, True): + tag = f'{binary}-{int(memory)}' + upper, state, vol, ro = t/f'dac-up-{tag}', t/f'dac-state-{tag}', t/f'dac-vol-{tag}', t/f'dac-ro-{tag}' + for d in (upper, state, vol, ro/'locked'): + d.mkdir(parents=True) + (ro/'locked/f').write_text('f') + (ro/'locked').chmod(0) + more = ['--upper-memory', str(state/'upper-sync'), '--layers-gen', str(state/'gen')] if memory else [] + r = subprocess.run([str(runtime), 'run', str(t/'rootdac'), '--root', str(upper), '--lower', str(lower), '--writable', '--bind', str(vol), '/vol', '--bind-ro', str(ro), '/ro', + '--storage-limit-mib', '0', '--max-steps', '2000000000', '--process-tree', '--scheduled-network', '--dynamic', *more, *extra], + capture_output=True, timeout=300) + ok = r.returncode == 0 and r.stdout == b'rootdac PASS\n' and b'ERROR: AddressSanitizer' not in r.stderr + wrong = [] + if ok: + low = upper/'low/ro' + wrong = left(upper/'w') + left(vol, False) + ([] if low.stat().st_mode & 0o7777 == 0o555 else ['low/ro mode']) + low.chmod(0o755) + wrong += [k for k in ('.wh.x', '.wh.sub', '.wh.mv', 'f', 'mv2') if not (low/k).is_file()] + ([] if (low/'new').is_dir() else ['low/ro/new']) + wrong += [] if (ro/'locked').stat().st_mode & 0o7777 == 0 else ['the read-only mount changed'] + wrong += ['low/ro2 copied up'] if (upper/'low/ro2').exists() and (upper/'low/ro2').stat().st_mode & 0o7777 != 0o555 else [] + ok = ok and not wrong + failed += not ok + print(f'{binary} rootdac_guest{" with the memory layer" if memory else ""}:', 'PASS' if ok else + f'FAIL exit={r.returncode} {r.stdout!r} wrong={wrong} {r.stderr.decode()[-1500:]}') + same = snapshot(lower) == before + failed += not same + print('rootdac lower layer unchanged:', 'PASS' if same else 'FAIL') + return failed + + ATTACK = r''' for f in /bin/busybox /lib/ld-musl-aarch64.so.1; do # what this shell runs on: changed, not broken echo pwn >> $f; chmod 777 $f; touch -d 2001-01-01 $f; ln $f $f.hard; mv $f.hard $f.moved; rm $f.moved @@ -156,6 +222,7 @@ def main(): failed += not same print('lower layer unchanged:', 'PASS' if same else f'FAIL {[k for k in set(before)|set(after) if before.get(k) != after.get(k)]}') failed += upm_runs(t) + failed += dac_runs(t) # busybox on alpine's files as the lower layer lower = t/'alpine' @@ -182,6 +249,14 @@ def busybox(upper, script, *more, background=False): ok = r.returncode == 0 and r.stdout.endswith(b'e2e PASS\n') and not leftovers(t/'e2e') failed += not ok print('busybox e2e on alpine layers:', 'PASS' if ok else f'FAIL exit={r.returncode} {r.stdout[-800:]!r} {r.stderr.decode()[-1500:]}') + # tar as root: 0555 directories made with their final mode, then filled; rm -rf after + script = ('set -e; mkdir -p /src/m/a/b /src/m/c; echo 1 > /src/m/a/f; echo 2 > /src/m/a/b/g; echo 3 > /src/m/c/h; chmod 444 /src/m/a/f /src/m/a/b/g /src/m/c/h; ' + 'chmod 555 /src/m/a/b /src/m/a /src/m/c /src/m; tar cf /t.tar -C /src m; mkdir /dst; tar xf /t.tar -C /dst; test "$(find /dst | wc -l)" = 8; ' + 'test "$(stat -c %a /dst/m/a)" = 555; test "$(stat -c %a /dst/m/a/b/g)" = 444; test "$(cat /dst/m/a/b/g)" = 2; rm -rf /dst /src; test ! -e /dst; test ! -e /src; echo tar PASS') + r = busybox(t/'tar', script, '--upper-memory', str(t/'tar-sync')) + ok = r.returncode == 0 and r.stdout.endswith(b'tar PASS\n') and not leftovers(t/'tar') + failed += not ok + print('busybox tar and rm -rf of 0555 directories as root:', 'PASS' if ok else f'FAIL exit={r.returncode} {r.stdout[-800:]!r} {r.stderr.decode()[-1500:]}') r = busybox(t/'attack', ATTACK) out = r.stdout.decode(errors='replace') ok = '==attack done==' in out and 'pwn' in out # the container sees its own changes diff --git a/tests/native/rootdac_guest.c b/tests/native/rootdac_guest.c new file mode 100644 index 0000000..4c34830 --- /dev/null +++ b/tests/native/rootdac_guest.c @@ -0,0 +1,172 @@ +/* Root's CAP_DAC_OVERRIDE (native/fs.h: fs_dac): root changes the entries of a + directory whose mode lacks write permission (0555, as tar leaves Go's module cache + before filling it) or every permission (000), and opens files whatever their mode; + the modes stay as set. Another user, and root without the capability, get EACCES for + each of them. In a directory made in the container (/w), a bind mount (/vol, when + mounted) and one the image has as 0555 (/low/ro), for files on disk and, with + --upper-memory, files still in memory (native/upm.h). Run by scripts/verify_overlay.py, + which then checks what the run left on the host. Hard links are made in /w only: + linkat of a file in a bind mount answers ENOENT beside image layers, whatever the modes. + A read-only bind mount (/ro) keeps its modes, so its 000 directory stays shut to root too. */ +typedef unsigned long U; +static long call(U n,U a,U b,U c,U d,U e,U f) { + register U x8 __asm__("x8")=n,x0 __asm__("x0")=a,x1 __asm__("x1")=b; + register U x2 __asm__("x2")=c,x3 __asm__("x3")=d,x4 __asm__("x4")=e,x5 __asm__("x5")=f; + __asm__ volatile("svc #0":"+r"(x0):"r"(x8),"r"(x1),"r"(x2),"r"(x3),"r"(x4),"r"(x5):"memory","cc");return (long)x0; +} +#define C1(n,a) call(n,(U)(a),0,0,0,0,0) +#define C2(n,a,b) call(n,(U)(a),(U)(b),0,0,0,0) +#define C3(n,a,b,c) call(n,(U)(a),(U)(b),(U)(c),0,0,0) +#define C4(n,a,b,c,d) call(n,(U)(a),(U)(b),(U)(c),(U)(d),0,0) +#define C5(n,a,b,c,d,e) call(n,(U)(a),(U)(b),(U)(c),(U)(d),(U)(e),0) +#define AT (-100) +#define EACCES (-13) +static unsigned slen(const char *s) { unsigned n=0;while(s[n])n++;return n; } +static const char *where=""; +static void fail_at(int line) { + char text[96]="rootdac FAIL line ";unsigned n=18;char digits[8];unsigned k=0; + while(line && k<8) { digits[k++]=(char)('0'+line%10);line/=10; } + while(k)text[n++]=digits[--k]; + text[n++]=' ';for(const char *p=where;*p && n<90;p++)text[n++]=*p; + text[n++]='\n';C3(64,1,text,n);C1(94,1);for(;;){} +} +#define CHECK(x) do { if(!(x))fail_at(__LINE__); } while(0) +static int same(const void *a,const void *b,unsigned n) { const unsigned char *p=a,*q=b;for(unsigned i=0;i=0;i--)v=v<<8|p[i];return v; } +static unsigned char st[128]; +static long stat_of(const char *path) { return C4(79,AT,path,st,256); } /* AT_SYMLINK_NOFOLLOW */ +#define MEMORY (1UL<<62) /* upm.h's inode numbers */ +static int pending(const char *path) { return stat_of(path)==0 && u64(st+8)>=MEMORY; } +static long mode(const char *path) { return stat_of(path)?-1:(long)((st[16]|st[17]<<8)&07777); } +static long open3(const char *path,U flags,U mode) { return C4(56,AT,path,flags,mode); } +static long put(const char *path,const char *text) { + long fd=open3(path,01101,0644);if(fd<0)return fd; /* O_WRONLY|O_CREAT|O_TRUNC */ + long n=C3(64,fd,text,slen(text));C1(57,fd);return n; +} +static long append(const char *path,const char *text) { + long fd=open3(path,02001,0);if(fd<0)return fd; /* O_WRONLY|O_APPEND */ + long n=C3(64,fd,text,slen(text));C1(57,fd);return n; +} +static int has(const char *path,const char *text) { + char buffer[64];long fd=open3(path,0,0);if(fd<0)return 0; + long n=C3(63,fd,buffer,sizeof buffer);C1(57,fd); + return n==(long)slen(text) && same(buffer,text,(unsigned)n); +} +static long mkdir(const char *path) { return C3(34,AT,path,0755); } +static long rmdir(const char *path) { return C3(35,AT,path,0x200); } +static long unlink(const char *path) { return C3(35,AT,path,0); } +static long symlink(const char *path) { return C3(36,"x",AT,path); } +static long mkfifo(const char *path) { return C4(33,AT,path,0010644,0); } +static long link(const char *from,const char *to) { return C5(37,AT,from,AT,to,0); } +static long rename(const char *from,const char *to) { return C4(38,AT,from,AT,to); } +static long chmod(const char *path,U mode) { return C3(53,AT,path,mode); } +static long chdir(const char *path) { return C1(49,path); } +/* Names in a directory (getdents64) besides . and .., or the open's error. */ +static long names(const char *dir) { + unsigned char buffer[1024];long fd=open3(dir,040000,0);if(fd<0)return fd; + long count=0; + for(;;) { + long n=C3(61,fd,buffer,sizeof buffer);if(n<=0)break; + for(long at=0;at0 && call(260,(U)pid,(U)&status,0,0,0,0)==pid && status==0); } + +/* base/a, 0755 still: directories and files that reach the disk before its mode changes. */ +static void setup(const char *b) { + where=b; + CHECK(mkdir(P(b,"/a"))==0 && mkdir(P(b,"/a/pre"))==0 && mkdir(P(b,"/a/pre2"))==0 && mkdir(P(b,"/a/mvd"))==0); + CHECK(put(P(b,"/a/d1"),"d")==1 && put(P(b,"/a/d2"),"d")==1 && put(P(b,"/a/d3"),"d")==1 && put(P(b,"/a/d4"),"d")==1 && put(P(b,"/a/d5"),"d")==1); +} +/* Root, in base/a once it is 0555, then 000. */ +static void root(const char *b) { + where=b; + int layer=b[1]=='w'; + int memory=pending(P(b,"/a/d1")); /* the memory layer holds this run's new files */ + if(memory) { nap();CHECK(!pending(P(b,"/a/d1")) && !pending(P(b,"/a/d5"))); } + CHECK(put(P(b,"/a/y1"),"y")==1 && put(P(b,"/a/y2"),"y")==1 && put(P(b,"/a/y3"),"y")==1 && pending(P(b,"/a/y1"))==memory); + CHECK(chmod(P(b,"/a/mvd"),0555)==0 && chmod(P(b,"/a"),0555)==0); + CHECK(mkdir(P(b,"/a/d"))==0); + CHECK(symlink(P(b,"/a/l"))==0); + CHECK(mkfifo(P(b,"/a/p"))==0); + CHECK(rmdir(P(b,"/a/pre"))==0 && stat_of(P(b,"/a/pre"))==-2); + CHECK(put(P(b,"/a/f"),"new")==3); /* a new file, renamed and removed */ + CHECK(rename(P(b,"/a/f"),P(b,"/a/g"))==0 && has(P(b,"/a/g"),"new") && unlink(P(b,"/a/g"))==0 && stat_of(P(b,"/a/g"))==-2); + CHECK(rename(P(b,"/a/d1"),P(b,"/a/d1b"))==0 && has(P(b,"/a/d1b"),"d")); /* files on disk */ + CHECK(unlink(P(b,"/a/d2"))==0 && stat_of(P(b,"/a/d2"))==-2); + CHECK(rename(P(b,"/a/y1"),P(b,"/a/y1b"))==0 && has(P(b,"/a/y1b"),"y")); /* files made just before the chmod */ + CHECK(unlink(P(b,"/a/y2"))==0 && stat_of(P(b,"/a/y2"))==-2); + CHECK(!layer || (link(P(b,"/a/d4"),P(b,"/a/h"))==0 && has(P(b,"/a/h"),"d"))); + CHECK(rename(P(b,"/a/mvd"),P(b,"/out"))==0 && mode(P(b,"/out"))==0555); /* a 0555 directory to another parent: its ".." is written */ + CHECK(put(P(b,"/a/keep"),"kept")==4); + CHECK(mode(P(b,"/a"))==0555); + /* files open whatever their mode */ + CHECK(chmod(P(b,"/a/d3"),0444)==0 && append(P(b,"/a/d3"),"+")==1 && mode(P(b,"/a/d3"))==0444); + CHECK(chmod(P(b,"/a/d3"),0)==0 && has(P(b,"/a/d3"),"d+") && append(P(b,"/a/d3"),"+")==1 && mode(P(b,"/a/d3"))==0); + CHECK(C2(45,P(b,"/a/d3"),2)==0 && has(P(b,"/a/d3"),"d+") && mode(P(b,"/a/d3"))==0); /* truncate */ + /* no permission at all: listed, searched, entered, changed */ + CHECK(chmod(P(b,"/a"),0)==0); + CHECK(names(P(b,"/a"))==11+layer); /* d l p pre2 d1b d3 d4 d5 y1b y3 keep, and h */ + CHECK(put(P(b,"/a/z"),"z")==1 && has(P(b,"/a/z"),"z") && mkdir(P(b,"/a/d/in"))==0 && mode(P(b,"/a"))==0); + CHECK(chdir(P(b,"/a"))==0 && stat_of("z")==0 && put("cwd","c")==1 && names(".")==13+layer && chdir("/")==0 && mode(P(b,"/a"))==0); + CHECK(chmod(P(b,"/a/."),0700)==0 && mode(P(b,"/a"))==0700); /* the call's own chmod is what stays */ + CHECK(chmod(P(b,"/a"),0555)==0); +} +/* Anyone the mode binds (another user; root without CAP_DAC_OVERRIDE): refused, in base/a at 0555 and 000. */ +static void bound(const char *b,int user) { + where=b; + CHECK(mkdir(P(b,"/a/e"))==EACCES && symlink(P(b,"/a/m"))==EACCES && mkfifo(P(b,"/a/q"))==EACCES); + CHECK(rmdir(P(b,"/a/pre2"))==EACCES && put(P(b,"/a/new"),"n")==EACCES); + CHECK(rename(P(b,"/a/d5"),P(b,"/a/d5x"))==EACCES && unlink(P(b,"/a/d4"))==EACCES); + CHECK(rename(P(b,"/a/y3"),P(b,"/a/y3x"))==EACCES && unlink(P(b,"/a/y3"))==EACCES); + CHECK((b[1]!='w' || link(P(b,"/a/d5"),P(b,"/a/h2"))==EACCES) && rename(P(b,"/out"),P(b,"/a/in"))==EACCES); + CHECK(append(P(b,"/a/d5"),"")==0); /* the file's own mode allows it */ + if(!user)return; /* (the test's root child stops here: a chmod is its to undo) */ + CHECK(chmod(P(b,"/a"),0)==0); + CHECK(names(P(b,"/a"))==EACCES && put(P(b,"/a/zz"),"z")==EACCES && stat_of(P(b,"/a/d5"))==EACCES && chdir(P(b,"/a"))==EACCES); + CHECK(chmod(P(b,"/a"),0555)==0 && chmod(P(b,"/a/d5"),0)==0 && open3(P(b,"/a/d5"),0,0)==EACCES && chmod(P(b,"/a/d5"),0644)==0); +} +void _start(void) { + int vol=mode("/vol")>=0; + CHECK(mkdir("/w")==0); + setup("/w");if(vol)setup("/vol"); + root("/w");if(vol)root("/vol"); + /* a 0555 directory of the image: a whiteout, a new directory and a new file in its upper copy */ + where="/low/ro"; + CHECK(mode("/low/ro")==0555 && unlink("/low/ro/x")==0 && stat_of("/low/ro/x")==-2 && rmdir("/low/ro/sub")==0 && mkdir("/low/ro/new")==0); + CHECK(put("/low/ro/f","f")==1 && rename("/low/ro/mv","/low/ro/mv2")==0 && has("/low/ro/mv2","mv") && mode("/low/ro")==0555 && names("/low/ro")==4); /* keep new f mv2 */ + where="/";CHECK(chmod("/",0555)==0 && mkdir("/top")==0 && rmdir("/top")==0 && mode("/")==0555 && chmod("/",0755)==0); + where="/ro";CHECK(mode("/ro")<0 || (names("/ro/locked")==EACCES && stat_of("/ro/locked/f")==EACCES && mkdir("/ro/new")==-30)); + long pid=fork(); + if(!pid) { /* another user */ + CHECK(C2(159,0,0)==0 && C1(144,1000)==0 && C1(146,1000)==0); + bound("/w",1);if(vol)bound("/vol",1); + where="/low";CHECK(unlink("/low/ro/keep")==EACCES && mkdir("/low/ro/e")==EACCES && mkdir("/low/ro2/e")==EACCES && unlink("/low/ro2/x")==EACCES); + C1(94,0); + } + wait_ok(pid); + pid=fork(); + if(!pid) { /* root having dropped CAP_DAC_OVERRIDE (capability 1) */ + unsigned header[2]={0x20080522,0},data[6]={~2u,~2u,0,0x1ff,0x1ff,0}; + CHECK(C2(91,header,data)==0); + bound("/w",0);if(vol)bound("/vol",0); + C1(94,0); + } + wait_ok(pid); + where="";CHECK(mode("/w/a")==0555 && mkdir("/w/a/last")==0 && (!vol || mkdir("/vol/a/last")==0)); /* the parent still holds it */ + C3(64,1,"rootdac PASS\n",13);C1(94,0);for(;;){} +} From 8b4a2aa5a49ee36ce58b2200dc81887689a46cc2 Mon Sep 17 00:00:00 2001 From: Chandarith Chea <45872539+chandarith-chea@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:17:09 +0700 Subject: [PATCH 2/2] Root's override: a mode that cannot be put back is checked and reported The Linux build (GCC, glibc) failed: fs_dac_leaf discarded fchmodat's result where it puts a mode back after the open it widened it for failed, and glibc marks fchmodat warn_unused_result, which a cast does not silence. That restore went by name, and it could also hit the wrong file: when the name had been renamed over or removed between the chmod and the open, it set the old file's mode on whatever was there now. The descriptor opened is now compared with the file that was widened (device and inode) before it is kept, and the mode goes back by name only where the name still is that file and its mode is still the one set here. Where it cannot go back there, and where fs_dac_end's fchmod fails, the engine says so on stderr (the container's log) with both modes and the error: the guest's call is already done or refused by then, so there is no result to carry it. Builds with GCC 13.3 on Ubuntu 24.04 with the CI's flags, plain and sanitized. --- native/fs.h | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/native/fs.h b/native/fs.h index 90c395e..c42be7d 100644 --- a/native/fs.h +++ b/native/fs.h @@ -525,7 +525,8 @@ static int fs_walk_fast(int base,const char *cur_start,const char *rest,int foll its already open parent with no symlink followed, so nothing outside the root is changed. Callers that open the leaf do the same for it (fs_dac_leaf). fs_dac_end puts each mode back by descriptor (the entry may be renamed or removed by then) unless it - changed meanwhile (a chmod by another thread; the call's own: fs_dac_chmod). + changed meanwhile (a chmod by another thread; the call's own: fs_dac_chmod). A mode + that cannot be put back is said on stderr, the container's log (fs_dac_stuck). ponytail: for those microseconds the container's other processes see the wider mode, and an engine killed then leaves it; CAP_DAC_READ_SEARCH alone is not honoured; exec, chown, inotify and unix-socket paths are not redone (a root user rarely meets @@ -536,11 +537,16 @@ static int fs_dac_begin(void) { if(!denied || fs_dac.on || !fs_dac_can())return 0; fs_dac.on=1;return 1; } +/* A mode the pass widened stays so (errno says why): the call itself is done or + refused already, so the container's log is where it can be told. */ +static void fs_dac_stuck(mode_t mode,mode_t set) { + fprintf(stderr,"dockiva: a file or directory root's call opened up keeps mode %04o, not its own %04o: %s\n",(unsigned)set,(unsigned)mode,strerror(errno)); +} static void fs_dac_end(void) { int saved=errno;struct stat st; while(fs_dac.count) { int i=--fs_dac.count,fd=fs_dac.held[i].fd; - if(!fstat(fd,&st) && (st.st_mode&07777)==fs_dac.held[i].set)(void)fchmod(fd,fs_dac.held[i].mode); + if(fstat(fd,&st) || ((st.st_mode&07777)==fs_dac.held[i].set && fchmod(fd,fs_dac.held[i].mode)))fs_dac_stuck(fs_dac.held[i].mode,fs_dac.held[i].set); close(fd); } fs_dac.on=0;errno=saved; @@ -567,9 +573,17 @@ static void fs_dac_leaf(int parent,const char *leaf) { if(!fs_dac.on || fs_dac.ro || fs_dac.count==FS_DAC_HELD || fstatat(parent,leaf,&st,FS_NOFOLLOW_AT) || !(need=fs_dac_need(&st)))return; mode_t mode=st.st_mode&07777; if(fchmodat(parent,leaf,mode|need,FS_NOFOLLOW_AT))return; - int fd=openat(parent,leaf,O_RDONLY|FS_NOFOLLOW_OPEN|O_CLOEXEC|O_NONBLOCK); - if(fd<0) { (void)fchmodat(parent,leaf,mode,FS_NOFOLLOW_AT);return; } - fs_dac_hold(fd,mode,need); + int fd=openat(parent,leaf,O_RDONLY|FS_NOFOLLOW_OPEN|O_CLOEXEC|O_NONBLOCK);struct stat now; + if(fd>=0 && !fstat(fd,&now) && now.st_ino==st.st_ino && now.st_dev==st.st_dev) { fs_dac_hold(fd,mode,need);return; } + /* Not opened (no descriptor to spare), or the name is another file's by now: put + back by name, where it still names the file widened and nothing changed its mode + since. Renamed or removed meanwhile, that file is out of reach. */ + int saved=errno; + if(fd>=0)close(fd); + if(fstatat(parent,leaf,&now,FS_NOFOLLOW_AT))fs_dac_stuck(mode,mode|need); + else if(now.st_ino!=st.st_ino || now.st_dev!=st.st_dev) { errno=ESTALE;fs_dac_stuck(mode,mode|need); } + else if((now.st_mode&07777)==(mode|need) && fchmodat(parent,leaf,mode,FS_NOFOLLOW_AT))fs_dac_stuck(mode,mode|need); + errno=saved; } /* The pass's own chmod of st's file to mode: that is what stays. */ static void fs_dac_chmod(const struct stat *st,mode_t mode) {