diff options
| author | Mattias Andrée <m@maandree.se> | 2026-11-02 19:16:28 +0100 |
|---|---|---|
| committer | Mattias Andrée <m@maandree.se> | 2026-11-02 19:16:28 +0100 |
| commit | ec7bbbf0a56ceebd4ec763360520acd2c16e24c2 (patch) | |
| tree | 0e1540cf792960ae49587564db7bc7a38fa10cf9 | |
| download | fsshield-1.0.tar.gz fsshield-1.0.tar.bz2 fsshield-1.0.tar.xz | |
Signed-off-by: Mattias Andrée <m@maandree.se>
Diffstat (limited to '')
| -rw-r--r-- | .gitignore | 19 | ||||
| -rw-r--r-- | LICENSE | 15 | ||||
| -rw-r--r-- | Makefile | 44 | ||||
| -rw-r--r-- | README | 68 | ||||
| -rw-r--r-- | config-coverage-gcc.mk | 14 | ||||
| -rw-r--r-- | config.mk | 18 | ||||
| -rw-r--r-- | fsshield.1 | 145 | ||||
| -rw-r--r-- | fsshield.c | 323 |
8 files changed, 646 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c691fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +*\#* +*~ +*.o +*.a +*.t +*.f +*.lo +*.to +*.fo +*.su +*.so +*.so.* +*.dll +*.dylib +*.gch +*.gcov +*.gcno +*.gcda +/fsshield @@ -0,0 +1,15 @@ +ISC License + +© 2026 Mattias Andrée <m@maandree.se> + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77dae54 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.POSIX: + +CONFIGFILE = config.mk +include $(CONFIGFILE) + +OBJ =\ + fsshield.o + +HDR = + + +all: fsshield +$(OBJ): $(HDR) + +.c.o: + $(CC) -c -o $@ $< $(CFLAGS) $(GOV_CFLAGS) $(CPPFLAGS) $(GOV_CPPFLAGS) + +fsshield: $(OBJ) + $(CC) -o $@ $(OBJ) $(LDFLAGS) $(COV_LDFLAGS) + $(BUILD_SUDO) sh -c 'chown -- 0:0 $@ && chmod -- 4755 $@' + +install: fsshield + mkdir -p -- "$(DESTDIR)$(PREFIX)/bin" + mkdir -p -- "$(DESTDIR)$(MANPREFIX)/man1/" + cp -- fsshield "$(DESTDIR)$(PREFIX)/bin/" + cp -- fsshield.1 "$(DESTDIR)$(MANPREFIX)/man1/" + +post-install: + chown -- '0:0' "$(DESTDIR)$(PREFIX)/bin/fsshield" + chmod -- 4755 "$(DESTDIR)$(PREFIX)/bin/fsshield" + +uninstall: + -rm -f -- "$(DESTDIR)$(PREFIX)/bin/fsshield" + -rm -f -- "$(DESTDIR)$(MANPREFIX)/man1/fsshield.1" + +clean: + -rm -f -- *.o *.a *.lo *.su *.so *.so.* *.gch + -rm -f -- *.gcov *.gcno *.gcda *.t *.to *.f *.fo + -rm -f -- fsshield + +.SUFFIXES: +.SUFFIXES: .o .c + +.PHONY: all install uninstall post-install clean @@ -0,0 +1,68 @@ +NAME + fsshield - Limit a new process's access to the filesystem + +SYNOPSIS + fsshield [-a file] ... [-r file] ... [-t file] ... [-nR] utility [argument] ... + +DESCRIPTION + The fsshield utility runs a specified utility, but limits it's access to + the filesystem. By default it gives read-only access to some fundamental + directories and /etc/passwd, access to the current working directory, and + clean tmpfs mounts on /tmp and /var/tmp. + + In this man page, "read-only" means that a bind-mount with the read-only + option is created. Other accessible files are normal bind-mounts. The + permissions on the files are not modified. + +OPTIONS + The following options are supported: + + -a file + Make file accessible. + + -r file + Make file accessible but read-only. + + -t file + Mount a tmpfs on file. + + -n + Do not apply the preset file accesses. + + -R + Make the current working directory read-only. + + The order of options do not matter, however -a overrides -r and -t, + while -r overrides -t. The preset files accesses are + -t /tmp -t /var/tmp -r /var/empty -r /etc/passwd -r /usr -r /bin + -r /sbin -r /lib -r /lib64 -r /lib32 -r /libexec, however limited + to files that exist (including broken symlinks). Additionally + the current working directory is always accessible (-a ., or + if -R is used: -r .), regardless of the use of -n. + +OPERANDS + The following operands are supported: + + utility + The name of the utility to be invoked. + + argument + A string to pass as an argument for the invoked utility. + +EXIT STATUS + If utility is invoked, the exit status of fsshield is the exit status + of utility; otherwise, the fsshield utility exits with one of the + following values: + + 125 An error occurred in the fsshield utility. + + 126 The utility specified by utility was found but could not be invoked. + + 127 The utility specified by utility could not be found. + +NOTES + The files can be any type of file, including directories, not just + regular files. + +SEE ALSO + None. diff --git a/config-coverage-gcc.mk b/config-coverage-gcc.mk new file mode 100644 index 0000000..60f81f8 --- /dev/null +++ b/config-coverage-gcc.mk @@ -0,0 +1,14 @@ +CONFIGFILE_PROPER = config.mk +include $(CONFIGFILE_PROPER) + +CC = $(CC_PREFIX)gcc -std=c99 +GCOV = gcov + +COV_CPPFLAGS = -DCOVERAGE_TEST +COV_CFLAGS = --coverage -g -O0 +COV_LDFLAGS = --coverage -g -O0 + +G = + +coverage: check + $(GCOV) -pr -- *.gcda 2>&1 diff --git a/config.mk b/config.mk new file mode 100644 index 0000000..f0726da --- /dev/null +++ b/config.mk @@ -0,0 +1,18 @@ +PREFIX = /usr +MANPREFIX = $(PREFIX)/share/man + +CC = c99 + +COMMON_SANITIZE = -fsanitize=alignment,shift,signed-integer-overflow,object-size,null,undefined,bounds,address +CLANG_SANITIZE = -O1 $(COMMON_SANITIZE),cfi -flto -fvisibility=hidden -fno-sanitize-trap=cfi +GCC_SANITIZE = -O1 $(COMMON_SANITIZE) +#SANITIZE = $(CLANG_SANITIZE) +#SANITIZE = $(GCC_SANITIZE) + +CPPFLAGS = -D_DEFAULT_SOURCE -D_BSD_SOURCE -D_XOPEN_SOURCE=700 -D_GNU_SOURCE +CFLAGS = $(SANITIZE) +LDFLAGS = $(SANITIZE) -lsimple + +G = -g + +BUILD_SUDO = @: diff --git a/fsshield.1 b/fsshield.1 new file mode 100644 index 0000000..0d1ce9f --- /dev/null +++ b/fsshield.1 @@ -0,0 +1,145 @@ +.TH FSSHIELD 1 FSSHIELD +.SH NAME +fsshield \- Limit a new process's access to the filesystem + +.SH SYNOPSIS +.B fsshield +.RB [ -a +.IR file ]\ ...\, +.BR [ -r +.IR file ]\ ...\, +.RB [ -t +.IR file ]\ ...\, +.RB [ -nR ] +.I utility +.RI [ argument ]\ ... + +.SH DESCRIPTION +The +.B +fsshield utility runs a specified utility, but limits it's access to +the filesystem. By default it gives read-only access to some fundamental +directories and +.IR /etc/passwd , +access to the current working directory, and +clean tmpfs mounts on +.I /tmp +and +.IR /var/tmp . + +In this man page, \(dqread-only\(dq means that a bind-mount with the read-only +option is created. Other accessible files are normal bind-mounts. The +permissions on the files are not modified. + +.SH OPTIONS +The following options are supported: +.TP +.BR -a \ \fIfile\fR +Make +.I file +accessible. +.TP +.BR -r \ \fIfile\fR +Make +.I file +accessible but read-only. +.TP +.BR -t \ \fIfile\fR +Mount a tmpfs on +.IR file . +.TP +.B -n +Do not apply the preset file accesses. +.TP +.B -R +Make the current working directory read-only. +.PP +The order of options do not matter, however +.B -a +overrides +.B -r +and +.BR -t , +while +.B -r +overrides +.BR -t . +The preset files accesses are +.B -t +.I /tmp +.B -t +.I /var/tmp +.B -r +.I /var/empty +.B -r +.I /etc/passwd +.B -r +.I /usr +.B -r +.I /bin +.B -r +.I /sbin +.B -r +.I /lib +.B -r +.I /lib64 +.B -r +.I /lib32 +.B -r +.IR /libexec , +however limited +to files that exist (including broken symlinks). Additionally +the current working directory is always accessible +.RB ( -a +.IR . , +or if +.B -R +is used: +.B -r +.IR . ), +regardless of the use of +.BR -n . + +.SH OPERANDS +The following operands are supported: +.TP +.I utility +The name of the utility to be invoked. +.TP +.I argument +A string to pass as an argument for the invoked utility. + +.SH EXIT STATUS +If +.I utility +is invoked, the exit status of +.B fsshield +is the exit status of +.IR utility ; +otherwise, the +.B fsshield +utility exits with one of the following values: +.TP +125 +An error occurred in the +.B fsshield +utility. +.TP +126 +The utility specified by +.I utility +was found but could not be invoked. +.TP +127 +The utility specified by +.I utility +could not be found. + +.SH NOTES +The +.IR file s +can be any type of file, including directories, +not just regular files. + +.SH SEE ALSO +None. diff --git a/fsshield.c b/fsshield.c new file mode 100644 index 0000000..4518e4d --- /dev/null +++ b/fsshield.c @@ -0,0 +1,323 @@ +/* See LICENSE file for copyright and license details. */ +#include <sys/mount.h> +#include <sys/syscall.h> +#include <sched.h> +#include <libsimple.h> +#include <libsimple-arg.h> + +NUSAGE(125, "[-a file] ... [-r file] ... [-t file] ... [-nR] utility [argument] ..."); + + +enum type { + ACCESSIBLE, + READONLY, + TMPFS +}; + +struct file { + const char *path; + enum type type; +}; + + +static struct file *files = NULL; +static size_t nfiles = 0u; + +static const struct file preset[] = { + {".", ACCESSIBLE}, + {"/tmp", TMPFS}, + {"/var/tmp", TMPFS}, + {"/var/empty", READONLY}, + {"/etc/passwd", READONLY}, + {"/usr", READONLY}, + {"/bin", READONLY}, + {"/sbin", READONLY}, + {"/lib", READONLY}, + {"/lib64", READONLY}, + {"/lib32", READONLY}, + {"/libexec", READONLY} +}; + +static size_t npreset = ELEMSOF(preset); + +static char *cwd; + + +static void +add(const char *path, enum type type) +{ + if (!*path) + usage(); + files[nfiles].path = path; + files[nfiles].type = type; + nfiles += 1u; +} + + +static void +make_dirs(char *path, mode_t mode, int make_top) +{ + char *p; + for (p = &path[1u]; *p; p++) { + if (*p != '/') + continue; + *p = '\0'; + if (mkdir(path, mode) && errno != EEXIST) + eprintf("mkdir %s %03o:", path, mode); + *p = '/'; + } + if (make_top) + if (mkdir(path, mode) && errno != EEXIST) + eprintf("mkdir %s %03o:", path, mode); +} + + +static void +touch(const char *path, mode_t mode) +{ + int fd; + if (!faccessat(AT_FDCWD, path, F_OK, AT_SYMLINK_NOFOLLOW)) + return; + fd = open(path, O_CREAT | O_WRONLY, mode); + if (fd < 0) + eprintf("open %s O_CREAT|O_WRONLY %03o:", path, mode); + close(fd); +} + + +static char * +clean_path(const char *path) +{ + char *ret, *r, *w; + + if (*path != '/') { + ret = emalloc(strlen(cwd) + strlen(path) + sizeof("/")); + stpcpy(stpcpy(stpcpy(ret, cwd), "/"), path); + } else { + ret = estrdup(path); + } + + r = w = &ret[1u]; + + while (*r) { + while (*r == '/') r++; + if (r[0u] == '.' && r[1u] == '/') { + r = &r[2u]; + } else if (r[0u] == '.' && !r[1u]) { + break; + } else if (r[0u] == '.' && r[1u] == '.' && (r[2u] == '/' || !r[2u])) { + if (--w != ret) + while (*w != '/') + w--; + w++; + r = &r[2u]; + if (!*r++) + break; + } else { + do { + *w++ = *r; + } while (*r && *r++ != '/'); + } + } + + if (w[-1] == '/' && w != &ret[1u]) + w--; + + *w = '\0'; + + if (!strcmp(ret, "/.oldroot")) + eprintf("/.oldroot must not be included as an accessible path"); + + return ret; +} + + +static int +cmpfiles(const void *av, const void *bv) +{ + const struct file *a = av; + const struct file *b = bv; + int cmp = strcmp(a->path, b->path); + return cmp ? cmp : (int)a->type - (int)b->type; +} + + +static int +isdir(const char *path) +{ + struct stat st; + return stat(path, &st) || S_ISDIR(st.st_mode); +} + + +static void +setup_filesystem(void) +{ + struct mount_attr attr, ro = {.attr_set = MOUNT_ATTR_RDONLY}; + char root[] = "/tmp/fsshield.XXXXXX"; + char *pathbuf; + size_t i, len, maxlen = sizeof("/.oldroot") - 1u; + int fds[2]; + pid_t pid; + + if (socketpair(PF_LOCAL, SOCK_STREAM | SOCK_CLOEXEC, 0, fds)) + eprintf("socketpair PF_LOCAL SOCK_STREAM|SOCK_CLOEXEC 0:"); + + if (!mkdtemp(root)) + eprintf("mkdtemp:"); + + pid = fork(); + switch (pid) { + case -1: + eprintf("fork:"); + case 0: + close(fds[1]); + pid = fork(); + switch (pid) { + case -1: + eprintf("fork:"); + case 0: + if (write(fds[0], &(char){0}, 1u) != 1) + eprintf("write <socket>:"); + if (read(fds[0], &(char){0}, 1u) != 0) + eprintf("read <socket>:"); + close(fds[0]); + if (rmdir(root)) + weprintf("rmdir %s:", root); + break; + default: + break; + } + exit(0); + default: + close(fds[0]); + switch (read(fds[1], &(char){0}, 1u)) { + case 0: + exit(125); + case 1: + break; + default: + eprintf("read <socket>:"); + } + waitpid(pid, NULL, 0); + break; + } + + for (i = 0u; i < nfiles; i++) { + len = strlen(files[i].path); + maxlen = MAX(maxlen, len); + } + pathbuf = emalloc(maxlen + sizeof(root)); + + if (unshare(CLONE_NEWNS)) + eprintf("unshare CLONE_NEWNS:"); + + if (mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL)) + eprintf("mount NULL / NULL MS_REC|MS_PRIVATE NULL:"); + + if (mount("tmpfs", root, "tmpfs", MS_NOSUID | MS_NODEV, "mode=0755")) + eprintf("mount tmpfs %s tmpfs MS_NOSUID|MS_NODEV mode=0755:", root); + + for (i = 0u; i < nfiles; i++) { + stpcpy(stpcpy(pathbuf, root), files[i].path); + switch (files[i].type) { + case ACCESSIBLE: + case READONLY: + if (isdir(files[i].path)) { + make_dirs(pathbuf, 0755, 1); + if (mount(files[i].path, pathbuf, NULL, MS_BIND | MS_REC, NULL)) + eprintf("mount %s %s NULL MS_BIND|MS_REC NULL:", files[i].path, pathbuf); + } else { + make_dirs(pathbuf, 0755, 0); + touch(pathbuf, 0644); + if (mount(files[i].path, pathbuf, NULL, MS_BIND, NULL)) + eprintf("mount %s %s NULL MS_BIND NULL:", files[i].path, pathbuf); + } + if (files[i].type == READONLY) { + attr = ro; + if (mount_setattr(AT_FDCWD, pathbuf, AT_RECURSIVE, &attr, sizeof(attr))) + eprintf("mount_setattr AT_FDCWD %s AT_RECURSIVE {.attr_set = MOUNT_ATTR_RDONLY}:", pathbuf); + } + break; + case TMPFS: + make_dirs(pathbuf, 01777, 1); + if (mount("tmpfs", pathbuf, "tmpfs", MS_NOSUID | MS_NODEV, "mode=1777")) + eprintf("mount tmpfs %s tmpfs MS_NOSUID|MS_NODEV mode=1777:", pathbuf); + break; + default: + abort(); + } + } + + stpcpy(stpcpy(pathbuf, root), "/.oldroot"); + make_dirs(pathbuf, 0700, 1); + if (chdir(root)) + eprintf("chdir %s:", root); + if (syscall(SYS_pivot_root, ".", ".oldroot")) + eprintf("pivot_root . .oldroot (from %s):", root); + if (chdir(cwd)) + eprintf("chdir %s:", cwd); + if (umount2("/.oldroot", MNT_DETACH)) + eprintf("umount2 /.oldroot MNT_DETACH"); + if (rmdir("/.oldroot")) + eprintf("rmdir /.oldroot"); + free(pathbuf); +} + + +int +main(int argc, char *argv[]) +{ + size_t i, n; + enum type cwd_access = ACCESSIBLE; + + libsimple_default_failure_exit = 125; + + argv0 = argv[0]; + files = ecalloc((size_t)argc + npreset, sizeof(*files)); + + ARGBEGIN { + case 'a': add(ARG(), ACCESSIBLE); break; + case 'r': add(ARG(), READONLY); break; + case 't': add(ARG(), TMPFS); break; + case 'n': npreset = 1u; break; + case 'R': cwd_access = READONLY; break; + default: + usage(); + } ARGEND; + + if (!argc) + usage(); + + files[nfiles] = preset[0u]; + files[nfiles++].type = cwd_access; + for (i = 1u; i < npreset; i++) + if (!faccessat(AT_FDCWD, preset[i].path, F_OK, AT_SYMLINK_NOFOLLOW)) + files[nfiles++] = preset[i]; + + cwd = get_current_dir_name(); + if (!cwd) + eprintf("get_current_dir_name:"); + if (*cwd != '/') + abort(); + + for (i = 0u; i < nfiles; i++) + files[i].path = clean_path(files[i].path); + + qsort(files, nfiles, sizeof(*files), &cmpfiles); + for (i = n = 1u; i < nfiles; i++) + if (strcmp(files[i].path, files[n - 1u].path)) + files[n++] = files[i]; + nfiles = n; + + setup_filesystem(); + unsetenv("PWD"); + + if (setegid(getgid())) + eprintf("setegid <real group>:"); + if (seteuid(getuid())) + eprintf("seteuid <real user>:"); + + execvp(argv[0], argv); + enprintf(errno == ENOENT ? 127 : 126, "execvp %s:", argv[0]); +} |
