From 5104f8d5cae4e88cf1e6b42f41f4c26166600ee8 Mon Sep 17 00:00:00 2001 From: Cloud_Yun Date: Thu, 1 Oct 2026 13:02:31 +0900 Subject: [PATCH 1/4] Leave a started program a descriptor for itself, not for its base directory kal_process_spawn duplicated the base directory without close-on-exec and passed the copy to execveat, so that a `#!` script or a binfmt_misc interpreter could open /dev/fd// after the replacement. The copy stays open in the started program and is passed on to whatever it starts. The base is often `/`, and a command run under bubblewrap could reach the host file system through /proc/self/fd//. Open the program itself with O_PATH, without O_CLOEXEC, and start it with execveat(fd, "", ..., AT_EMPTY_PATH). The interpreter is then given /dev/fd/, which names the file, and nothing can be walked out of a descriptor for a file. A name that cannot be opened is reported through the same pipe as one that cannot be started, with the same errno. A script now sees $0 as /dev/fd/ and not as a path under its directory, as with fexecve. The descriptor can still be reopened through /proc/self/fd/, so a program that sandboxes what it starts should close what it inherits. The test starts a `#!` script and checks that it inherits no descriptor for the base directory. It fails without the change. --- src/process.cpp | 53 ++++++++++++++++++++++++--------- src/sys.h | 11 +++---- tests/conformance_additions.cpp | 31 +++++++++++++++++++ 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/process.cpp b/src/process.cpp index ab2e174..e930a36 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -246,7 +246,7 @@ int kal_process_spawn(const kal_spawn* how, // THE DIRECTORY THE PROGRAM RUNS IN, AND THIS LINE IS THE WHOLE OF IT. // - // `execveat' below takes `b' as a dirfd, but that only RESOLVES the + // `openat' below takes `b' as a dirfd, but that only RESOLVES the // name --- resolving a name is not entering a directory, which is what // the comment here used to get wrong. Until 0.11 there was no second // directory to enter, and a started program ran wherever this @@ -279,8 +279,9 @@ int kal_process_spawn(const kal_spawn* how, okl::sys(okl::nr_exit_group, 127); } - // THE BASE IS DUPLICATED SO THAT IT SURVIVES THE REPLACEMENT, AND - // WITHOUT THIS A WHOLE CLASS OF PROGRAMS COULD NOT BE STARTED AT ALL. + // WHAT THE STARTED PROGRAM KEEPS IS A DESCRIPTOR FOR THE PROGRAM, NOT + // FOR THE DIRECTORY IT WAS FOUND IN, AND WITHOUT A DESCRIPTOR AT ALL A + // WHOLE CLASS OF PROGRAMS COULD NOT BE STARTED. // // `execveat' with a dirfd and a relative name gives the program's name to // the kernel as `/dev/fd//'. That spelling is invisible to a @@ -289,13 +290,13 @@ int kal_process_spawn(const kal_spawn* how, // needs an INTERPRETER: a `#!' script, or a binary of another // architecture registered through `binfmt_misc'. The kernel then starts // the interpreter and hands it that name to open --- AFTER the - // replacement, by which time a close-on-exec dirfd is gone. The + // replacement, by which time a close-on-exec descriptor is gone. The // interpreter is told the script does not exist. // // Measured in twenty lines of plain C, with everything else identical: // - // dirfd WITH O_CLOEXEC execveat -> ENOENT - // dirfd WITHOUT O_CLOEXEC STARTED ok + // descriptor WITH O_CLOEXEC execveat -> ENOENT + // descriptor WITHOUT O_CLOEXEC STARTED ok // // It is not a property of one architecture. It was FOUND on aarch64, // where every foreign binary needs the binfmt interpreter and so every @@ -303,17 +304,41 @@ int kal_process_spawn(const kal_spawn* how, // It reproduces natively on x86_64 with a `#!' script, which is what a // consumer meets on any machine. // - // Duplicated HERE, in the started image, and not where the preopens are - // made: the caller's own descriptors stay close-on-exec, which is what - // every other operation of this implementation relies upon. `dup' clears - // the flag by definition, so the copy is the exec-visible one. - const okl_long visible = okl::sys(okl::nr_fcntl, b, okl::f_dupfd, 0); - const okl_long base = okl::failed(visible) ? b : visible; + // The descriptor that survives must not be the base. A copy of the base + // is a handle on a whole directory, often `/', and a program that runs + // other programs in a sandbox hands it on to them, where + // `/proc/self/fd//' reaches whatever the sandbox hid. So the program + // itself is opened here, with `O_PATH' because it is only to be named + // and not read, and without `O_CLOEXEC' for the reason above, and + // `execveat' is given that descriptor and an empty name. The + // interpreter's `/dev/fd/' then names the file, and nothing can be + // walked out of a descriptor for a file. + // + // What is given up: a script now sees `$0' as `/dev/fd/' and not as + // a path under its directory, so one that locates its neighbours with + // `dirname "$0"' no longer finds them. That is the price of leaving a + // descriptor for the file and not for the directory, the same one + // `fexecve' charges. The descriptor still names the program, and a + // caller that sandboxes what it starts should close what it inherits. + // + // Opened HERE, in the started image, and not where the preopens are made: + // the caller's own descriptors stay close-on-exec, which is what every + // other operation of this implementation relies upon. + // + // A name that cannot be opened is reported exactly as one that cannot be + // started is, through the same pipe. + const okl_long exe = okl::sys(okl::nr_openat, b, reinterpret_cast(p.buf), + okl::o_path, 0); + if (okl::failed(exe)) { + report.say(exe); + okl::sys(okl::nr_exit_group, 127); + for (;;) { } + } const okl_long why = - okl::sys(okl::nr_execveat, base, reinterpret_cast(p.buf), + okl::sys(okl::nr_execveat, exe, reinterpret_cast(""), reinterpret_cast(args.slots), - reinterpret_cast(envs.slots), 0); + reinterpret_cast(envs.slots), okl::at_empty_path); // Reached only when the replacement did not happen, because when it does // there is nothing here to reach. report.say(why); diff --git a/src/sys.h b/src/sys.h index 0c07fd8..3fc1ee6 100644 --- a/src/sys.h +++ b/src/sys.h @@ -256,6 +256,10 @@ enum : okl_long { o_creat = 0100, o_excl = 0200, o_trunc = 01000, o_append = 02000, o_cloexec = 02000000, + // A descriptor that only NAMES a node, and the empty name `execveat' accepts + // for it. See the start in `kal_process_spawn'. + o_path = 010000000, at_empty_path = 0x1000, + // THE LOWEST FREE DESCRIPTOR AT OR ABOVE A BOUND, which is the one // primitive that moves a descriptor out of the way WITHOUT NAMING the // number it moves to --- and therefore without closing whatever a caller @@ -263,13 +267,6 @@ enum : okl_long { // closes what is on it. f_dupfd_cloexec = 1030, - // The same primitive WITHOUT the flag, which is the point of having both. - // A descriptor duplicated this way survives a replacement, and starting a - // program that needs an interpreter depends on exactly that --- see the - // duplication in `kal_process_spawn'. `dup' would do as well and this - // architecture pair does not agree on whether it exists. - f_dupfd = 0, - // THE OPEN-FILE FORM AND NOT THE PROCESS FORM, WHICH IS THE WHOLE // DIFFERENCE. // diff --git a/tests/conformance_additions.cpp b/tests/conformance_additions.cpp index 1f459cc..e5aef1e 100644 --- a/tests/conformance_additions.cpp +++ b/tests/conformance_additions.cpp @@ -181,6 +181,37 @@ int main() { kal_fs_remove(here(), prog, pn); } + // A started `#!' script is handed `/dev/fd/' by the kernel and opens it + // after the replacement, so a descriptor has to be left behind for it. It + // must be one for the script and not for the directory the name was found + // in: a program that starts other programs under a sandbox passes every + // descriptor on, and a directory among them reaches outside it. The base + // and the working directory are the same here, so it is `.' that must not + // be among them. + { + const char* prog = "okl-fd-probe.tmp"; + const kal_uintptr pn = std::strlen(prog); + check(put(prog, "#!/bin/sh\nfor f in /proc/self/fd/*; do\n" + " [ \"$f\" -ef . ] && exit 1\ndone\nexit 0\n"), + "a script is written"); + check(kal_fs_set_executable_at(here(), prog, pn, 1) == kal_ok, + "the script is recorded as startable"); + + kal_process p{}; + const char* argv[1] = { prog }; + const kal_uintptr lens[1] = { pn }; + const kal_spawn how{ here(), here(), nullptr, nullptr, 0, 0 }; + check(kal_process_spawn(&how, prog, pn, argv, lens, 1, + nullptr, nullptr, 0, nullptr, &p) == kal_ok, + "a `#!' script is started"); + int status = -1, terminated = -1; + kal_process_wait(p, &status, &terminated); + check(terminated == 0 && status == 0, + "the script ran, and it inherited no descriptor for its base directory"); + kal_process_close(p); + kal_fs_remove(here(), prog, pn); + } + std::printf("openkal-linux: the operations version 0.5 added\n"); return failures == 0 ? 0 : 1; } From d5a3208cbb42bbdf2b577f25a99ada7e7fd9338f Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 1 Oct 2026 20:04:44 +0800 Subject: [PATCH 2/4] A started program receives the three streams and its grants, and nothing else Builds on the previous commit, which left a started program a descriptor for its own file instead of one for its base directory. Three further departures from clause 7.13 of the specification are removed. The descriptor for the program is opened close-on-exec, and only a program that needs an interpreter keeps it: the kernel refuses such a program with ENOENT before the point of no return, and the start is repeated with the flag cleared. An ordinary executable now starts with nothing above the three streams. The descriptor is moved above the placed positions, so that a caller without a standard input does not hand a script its own descriptor as one. What the caller itself inherited without the close-on-exec flag is no longer passed on. Everything above the grants is marked in the started image with close_range, or from /proc/self/fd or up to the descriptor limit on a kernel older than 5.11. Grants arrive. Every source is moved above the positions being filled before any is placed, so one placement no longer overwrites another grant, a stream, or the base the program's name is resolved under; a grant already at its own position no longer keeps the flag that closed it. The names travel in KAL_PREOPENS={;,,}, bound to the started process by its pid in the way systemd's LISTEN_FDS is, and kal_fs_preopen in the started program enumerates exactly the grants, in order and by name. A count of zero leaves none; not asking leaves the working directory and / as before. tests/conformance_spawn.cpp observes each of these from the started program. Against 0.15.0 seven of its observations fail. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/fs.cpp | 69 ++++++++- src/process.cpp | 271 +++++++++++++++++++++++++++++++----- src/sys.h | 8 +- tests/conformance_spawn.cpp | 226 ++++++++++++++++++++++++++++++ 4 files changed, 534 insertions(+), 40 deletions(-) create mode 100644 tests/conformance_spawn.cpp diff --git a/src/fs.cpp b/src/fs.cpp index 725084c..01ebe71 100644 --- a/src/fs.cpp +++ b/src/fs.cpp @@ -3,6 +3,8 @@ #include #include +namespace okl { extern char** g_envp; } + namespace { @@ -20,11 +22,74 @@ struct preopen { const char* name; kal_uintptr len; okl_uptr handle; }; char g_cwd[4096]; +constexpr kal_uintptr kMaxGrants = 16; + +// THE DIRECTORIES A STARTER GRANTED, WHEN THE PROGRAM WAS STARTED WITH GRANTS. +// +// They arrive as descriptors at 3 and upward, and their names in the variable +// `kal_process_spawn' writes (see `vector::build_env' in process.cpp): +// +// KAL_PREOPENS={;,,} +// +// The value is read only when `' is this process, so one inherited through +// a program that does not read it is not mistaken for a grant. A descriptor that +// is not a directory is reported as a preopen this program may not use, which is +// how an entry that could not be opened is reported anyway. Each descriptor is +// marked to close on replacement as it is taken over, so that a grant reaches +// the program it was made to and not that program's own children (SPEC.md +// clause 7.13). The names point into the environment, which lives as long as +// the program does. +// +// Answers false when there is no such value, which leaves the directories this +// implementation supplies by default. +bool granted(preopen* t, kal_uintptr* n) { + constexpr char key[] = "KAL_PREOPENS="; + constexpr okl_uptr key_len = sizeof key - 1; + const char* v = nullptr; + for (char** e = okl::g_envp; e && *e; ++e) { + okl_uptr i = 0; + while (i < key_len && (*e)[i] == key[i]) ++i; + if (i == key_len) { v = *e + key_len; break; } + } + if (v == nullptr) return false; + + auto number = [&](okl_uptr& out) { + if (*v < '0' || *v > '9') return false; + out = 0; + while (*v >= '0' && *v <= '9') out = out * 10 + static_cast(*v++ - '0'); + return true; + }; + okl_uptr pid = 0; + if (!number(pid) || static_cast(pid) != okl::sys(okl::nr_getpid)) return false; + + kal_uintptr k = 0; + while (*v == ';' && k < kMaxGrants) { + ++v; + okl_uptr fd = 0, len = 0; + if (!number(fd) || *v++ != ',' || !number(len) || *v++ != ',') return false; + for (okl_uptr i = 0; i < len; ++i) if (v[i] == '\0') return false; + + okl::kstat st{}; + const bool dir = !okl::failed(okl::sys(okl::nr_fstat, static_cast(fd), + reinterpret_cast(&st))) + && (st.mode & okl::s_ifmt) == okl::s_ifdir; + if (dir) okl::sys(okl::nr_fcntl, static_cast(fd), okl::f_setfd, okl::fd_cloexec); + t[k++] = { v, len, dir ? okl::pack(static_cast(fd)) : 0u }; + v += len; + } + if (*v != '\0') return false; + *n = k; + return true; +} + preopen* table(kal_uintptr* count) { - static preopen t[2]; + static preopen t[kMaxGrants]; + static kal_uintptr n = 2; static bool opened = false; if (!opened) { opened = true; + if (granted(t, &n)) { if (count) *count = n; return t; } + n = 2; const okl_long n = okl::sys(okl::nr_getcwd, reinterpret_cast(g_cwd), static_cast(sizeof g_cwd)); @@ -45,7 +110,7 @@ preopen* table(kal_uintptr* count) { t[0] = { g_cwd, cwd_len, okl::failed(fd0) ? 0u : okl::pack(static_cast(fd0)) }; t[1] = { "/", 1, okl::failed(fd1) ? 0u : okl::pack(static_cast(fd1)) }; } - if (count) *count = 2; + if (count) *count = n; return t; } diff --git a/src/process.cpp b/src/process.cpp index e930a36..c3903d2 100644 --- a/src/process.cpp +++ b/src/process.cpp @@ -8,6 +8,70 @@ namespace { constexpr okl_uptr kMaxEntries = 512; +constexpr char kPreopens[] = "KAL_PREOPENS="; +constexpr okl_uptr kPreopensLen = sizeof kPreopens - 1; +constexpr okl_uptr kPidDigits = 10; + +bool names_preopens(const char* s, kal_uintptr n) { + if (n < kPreopensLen) return false; + for (okl_uptr i = 0; i < kPreopensLen; ++i) + if (s[i] != kPreopens[i]) return false; + return true; +} + +okl_uptr digits(okl_uptr v) { + okl_uptr n = 1; + while (v >= 10) { v /= 10; ++n; } + return n; +} + +char* put_decimal(char* o, okl_uptr v) { + char b[24]; int i = 0; + do { b[i++] = static_cast('0' + v % 10); v /= 10; } while (v); + while (i) *o++ = b[--i]; + return o; +} + +// Every descriptor from `from' upward is marked to close when the image is +// replaced. `close_range' does it in one call from Linux 5.11; before that +// kernel the descriptors the process has are listed from /proc/self/fd, and +// where that is not mounted every number below the descriptor limit is tried. +void close_on_exec_from(okl_long from) { + const okl_long r = okl::sys(okl::nr_close_range, from, static_cast(~0u), + okl::close_range_cloexec); + if (!okl::failed(r)) return; + + const okl_long dir = okl::sys(okl::nr_openat, okl::at_fdcwd, + reinterpret_cast("/proc/self/fd"), + okl::o_rdonly | okl::o_directory | okl::o_cloexec, 0); + if (!okl::failed(dir)) { + alignas(8) char buf[2048]; + for (;;) { + const okl_long n = okl::sys(okl::nr_getdents64, dir, + reinterpret_cast(buf), sizeof buf); + if (n <= 0) break; + for (okl_long at = 0; at < n; ) { + const auto* d = reinterpret_cast(buf + at); + okl_long fd = 0; bool number = d->name[0] != '\0'; + for (const char* c = d->name; *c; ++c) { + if (*c < '0' || *c > '9') { number = false; break; } + fd = fd * 10 + (*c - '0'); + } + if (number && fd >= from && fd != dir) + okl::sys(okl::nr_fcntl, fd, okl::f_setfd, okl::fd_cloexec); + at += d->reclen; + } + } + okl::sys(okl::nr_close, dir); + return; + } + + okl_ulong lim[2] = { 1024, 1024 }; + okl::sys(okl::nr_prlimit64, 0, okl::rlimit_nofile, 0, reinterpret_cast(lim)); + for (okl_long fd = from; fd < static_cast(lim[0]); ++fd) + okl::sys(okl::nr_fcntl, fd, okl::f_setfd, okl::fd_cloexec); +} + // The counted arrays the interface takes become the terminated arrays the // kernel takes. Every allocation happens before the program is duplicated, so // that the duplicate performs nothing but two system calls: a duplicate of a @@ -40,6 +104,82 @@ struct vector { return true; } + // THE ENVIRONMENT, WHICH CARRIES THE NAMES OF THE GRANTED DIRECTORIES. + // + // A granted directory reaches the started program as a descriptor, and a + // descriptor carries no name. The names travel in one variable, + // + // KAL_PREOPENS={;,,} + // + // which the started program reads when it first enumerates its preopens + // (`table' in fs.cpp). It is the arrangement this environment already uses + // to hand a started program named descriptors --- systemd's LISTEN_FDS, + // LISTEN_FDNAMES and LISTEN_PID --- and it is bound to the process it was + // written for in the same way: `' is ten digits written by the + // duplicate once it knows its own number, so a value inherited through a + // program that does not read it, a shell for example, names no one when it + // arrives one generation further down. + // + // Every variable of that name the caller supplied is left out, so a value + // is never forwarded; one is added only when the caller asked for grants, + // and asking for none (a count of zero) is a value with no entries. + char* pid_digits = nullptr; + + bool build_env(const char** items, const kal_uintptr* lens, kal_uintptr n, + const kal_preopen* grants, kal_uintptr g) { + if (n > kMaxEntries) { ok = false; return false; } + kal_uintptr kept = 0; + okl_uptr total = 0; + for (kal_uintptr i = 0; i < n; ++i) { + if (names_preopens(items[i], lens[i])) continue; + ++kept; total += lens[i] + 1; + } + okl_uptr extra = 0; + if (grants) { + extra = kPreopensLen + kPidDigits + 1; + for (kal_uintptr i = 0; i < g; ++i) + extra += 3 + digits(3 + i) + digits(grants[i].len) + grants[i].len; + } + slots_bytes = (kept + (grants ? 1 : 0) + 1) * sizeof(char*); + bytes_bytes = total + extra == 0 ? 1 : total + extra; + slots = static_cast(kal_alloc(slots_bytes, alignof(char*))); + bytes = static_cast(kal_alloc(bytes_bytes, 1)); + if (!slots || !bytes) { ok = false; return false; } + okl_uptr at = 0, k = 0; + for (kal_uintptr i = 0; i < n; ++i) { + if (names_preopens(items[i], lens[i])) continue; + okl::copy(bytes + at, items[i], lens[i]); + bytes[at + lens[i]] = '\0'; + slots[k++] = bytes + at; + at += lens[i] + 1; + } + if (grants) { + char* o = bytes + at; + slots[k++] = o; + okl::copy(o, kPreopens, kPreopensLen); o += kPreopensLen; + pid_digits = o; + okl::fill(o, '0', kPidDigits); o += kPidDigits; + for (kal_uintptr i = 0; i < g; ++i) { + *o++ = ';'; o = put_decimal(o, 3 + i); + *o++ = ','; o = put_decimal(o, grants[i].len); + *o++ = ','; + okl::copy(o, grants[i].name, grants[i].len); o += grants[i].len; + } + *o = '\0'; + } + slots[k] = nullptr; + return true; + } + + // In the duplicate, which is the first point at which the number is known. + void stamp(okl_long pid) const { + if (!pid_digits) return; + for (int i = static_cast(kPidDigits) - 1; i >= 0; --i) { + pid_digits[i] = static_cast('0' + pid % 10); + pid /= 10; + } + } + ~vector() { if (slots) kal_free(slots, slots_bytes, alignof(char*)); if (bytes) kal_free(bytes, bytes_bytes, 1); @@ -192,20 +332,27 @@ int kal_process_spawn(const kal_spawn* how, okl::terminated p(path, path_len); if (!p.ok) return kal_err_invalid; - vector args, envs; - if (!args.build(argv, argv_lens, argc)) return kal_err_no_memory; - if (!envs.build(envp, envp_lens, envc)) return kal_err_no_memory; - // Resolved before the duplication, because a failure after it would leave a - // child to be reaped and a caller with an error it cannot act upon. + // child to be reaped and a caller with an error it cannot act upon. A name + // travels in the environment, which cannot carry a zero byte. constexpr kal_uintptr max_grants = 16; if (how->grant_count > max_grants) return kal_err_invalid; - int granted[max_grants]; + okl_long granted[max_grants]; for (kal_uintptr i = 0; i < how->grant_count; ++i) { - granted[i] = okl::unpack(how->grants[i].dir.h); - if (granted[i] < 0) return kal_err_invalid; + const kal_preopen& g = how->grants[i]; + const int fd = okl::unpack(g.dir.h); + if (fd < 0) return kal_err_invalid; + if (g.len > okl::max_name || (g.len > 0 && g.name == nullptr)) return kal_err_invalid; + for (kal_uintptr c = 0; c < g.len; ++c) + if (g.name[c] == '\0') return kal_err_invalid; + granted[i] = fd; } + vector args, envs; + if (!args.build(argv, argv_lens, argc)) return kal_err_no_memory; + if (!envs.build_env(envp, envp_lens, envc, how->grants, how->grant_count)) + return kal_err_no_memory; + const okl_long in = streams ? static_cast(streams->in.h) : 0; const okl_long ou = streams ? static_cast(streams->out.h) : 0; const okl_long er = streams ? static_cast(streams->err.h) : 0; @@ -229,24 +376,54 @@ int kal_process_spawn(const kal_spawn* how, if (okl::failed(child)) { report.close_both(); return okl::translate(child); } if (child == 0) { - if (in != 0) okl::sys(okl::nr_dup3, in, 0, 0); - if (ou != 0) okl::sys(okl::nr_dup3, ou, 1, 0); - if (er != 0) okl::sys(okl::nr_dup3, er, 2, 0); - - // dup3 REFUSES A DUPLICATION ONTO ITSELF, which the ordinary case - // reaches whenever a granted directory already occupies the number it - // is destined for. Refusing there is correct of dup3 --- the flags could - // not be applied --- and here it means the descriptor is already in - // place, so it is left alone rather than treated as a failure. - for (kal_uintptr i = 0; i < how->grant_count; ++i) { - const okl_long want = static_cast(3 + i); - if (granted[i] != want) - okl::sys(okl::nr_dup3, granted[i], want, 0); - } + // WHAT THE STARTED PROGRAM RECEIVES IS THE THREE STREAMS AND THE GRANTED + // DIRECTORIES, AND NOTHING ELSE (SPEC.md clause 7.13). + // + // Every source is first moved above the positions being filled, because + // placing one source on its position must not overwrite another that + // still has to be read: a granted directory, a stream, the base or the + // working directory may each occupy a number between 0 and 3+n. Placing + // them one at a time where they stood is how a grant named `work' + // arrived as a second copy of `/', and how a placement overwrote the + // base the program's name is resolved under. A source that is moved has + // its own descriptor flag; `dup3' then clears it on the copy it places, + // including where the source happened to be the position itself --- + // which `dup3' refuses, and which used to leave the copy marked to + // close, so that the grant never arrived. + const okl_long top = static_cast(3 + how->grant_count); + auto lift = [&](okl_long fd) -> okl_long { + const okl_long r = okl::sys(okl::nr_fcntl, fd, okl::f_dupfd_cloexec, top); + if (okl::failed(r)) { + report.say(r); + okl::sys(okl::nr_exit_group, 127); + for (;;) { } + } + return r; + }; + const okl_long sin = in != 0 ? lift(in) : 0; + const okl_long sou = ou != 0 ? lift(ou) : 0; + const okl_long ser = er != 0 ? lift(er) : 0; + for (kal_uintptr i = 0; i < how->grant_count; ++i) granted[i] = lift(granted[i]); + const okl_long base = lift(b); + const okl_long work = lift(w); + + if (sin != 0) okl::sys(okl::nr_dup3, sin, 0, 0); + if (sou != 0) okl::sys(okl::nr_dup3, sou, 1, 0); + if (ser != 0) okl::sys(okl::nr_dup3, ser, 2, 0); + for (kal_uintptr i = 0; i < how->grant_count; ++i) + okl::sys(okl::nr_dup3, granted[i], static_cast(3 + i), 0); + + // What the CALLER itself inherited without the flag is not a handle it + // granted. A program that starts others inside a sandbox passes every + // descriptor it holds to them, and one of those reaching beyond the + // sandbox is exactly what the sandbox was for. + close_on_exec_from(top); + + envs.stamp(okl::sys(okl::nr_getpid)); // THE DIRECTORY THE PROGRAM RUNS IN, AND THIS LINE IS THE WHOLE OF IT. // - // `openat' below takes `b' as a dirfd, but that only RESOLVES the + // `openat' below takes the base as a dirfd, but that only RESOLVES the // name --- resolving a name is not entering a directory, which is what // the comment here used to get wrong. Until 0.11 there was no second // directory to enter, and a started program ran wherever this @@ -255,7 +432,7 @@ int kal_process_spawn(const kal_spawn* how, // A FAILURE HERE MUST NOT REACH `execveat'. Running the right program // in the wrong directory is precisely the silent wrongness this exists to // remove, so it is reported through the same pipe an exec failure uses. - if (const okl_long e = okl::sys(okl::nr_fchdir, w); okl::failed(e)) { + if (const okl_long e = okl::sys(okl::nr_fchdir, work); okl::failed(e)) { report.say(e); okl::sys(okl::nr_exit_group, 127); for (;;) { } @@ -309,17 +486,30 @@ int kal_process_spawn(const kal_spawn* how, // other programs in a sandbox hands it on to them, where // `/proc/self/fd//' reaches whatever the sandbox hid. So the program // itself is opened here, with `O_PATH' because it is only to be named - // and not read, and without `O_CLOEXEC' for the reason above, and - // `execveat' is given that descriptor and an empty name. The - // interpreter's `/dev/fd/' then names the file, and nothing can be - // walked out of a descriptor for a file. + // and not read, and `execveat' is given that descriptor and an empty + // name. The interpreter's `/dev/fd/' then names the file, and nothing + // can be walked out of a descriptor for a file. + // + // AND ONLY A PROGRAM THAT NEEDS AN INTERPRETER KEEPS EVEN THAT. The + // descriptor is opened with `O_CLOEXEC', and an ordinary executable, + // which the kernel holds open itself, starts with nothing left behind. + // A program that needs an interpreter is refused with ENOENT BEFORE the + // point of no return --- `binfmt_script' and `binfmt_misc' test whether + // the name will still be reachable and answer that it will not --- so + // the image is still here to clear the flag and ask again. The other + // sources of ENOENT on an open descriptor, an interpreter that does not + // exist, answer the same the second time. + // + // The descriptor is moved above the placed positions as well: where + // the caller had no standard input and supplied none, the lowest free + // number is 0, and a script would read its own descriptor as input. // // What is given up: a script now sees `$0' as `/dev/fd/' and not as // a path under its directory, so one that locates its neighbours with // `dirname "$0"' no longer finds them. That is the price of leaving a // descriptor for the file and not for the directory, the same one - // `fexecve' charges. The descriptor still names the program, and a - // caller that sandboxes what it starts should close what it inherits. + // `fexecve' charges. The descriptor still names the program: it can be + // reopened through `/proc/self/fd/', as `/proc/self/exe' can. // // Opened HERE, in the started image, and not where the preopens are made: // the caller's own descriptors stay close-on-exec, which is what every @@ -327,18 +517,25 @@ int kal_process_spawn(const kal_spawn* how, // // A name that cannot be opened is reported exactly as one that cannot be // started is, through the same pipe. - const okl_long exe = okl::sys(okl::nr_openat, b, reinterpret_cast(p.buf), - okl::o_path, 0); + okl_long exe = okl::sys(okl::nr_openat, base, reinterpret_cast(p.buf), + okl::o_path | okl::o_cloexec, 0); if (okl::failed(exe)) { report.say(exe); okl::sys(okl::nr_exit_group, 127); for (;;) { } } - - const okl_long why = - okl::sys(okl::nr_execveat, exe, reinterpret_cast(""), - reinterpret_cast(args.slots), - reinterpret_cast(envs.slots), okl::at_empty_path); + if (exe < top) exe = lift(exe); + + auto start = [&] { + return okl::sys(okl::nr_execveat, exe, reinterpret_cast(""), + reinterpret_cast(args.slots), + reinterpret_cast(envs.slots), okl::at_empty_path); + }; + okl_long why = start(); + if (why == -okl::e_noent) { + okl::sys(okl::nr_fcntl, exe, okl::f_setfd, 0); + why = start(); + } // Reached only when the replacement did not happen, because when it does // there is nothing here to reach. report.say(why); diff --git a/src/sys.h b/src/sys.h index 3fc1ee6..0c7f819 100644 --- a/src/sys.h +++ b/src/sys.h @@ -97,6 +97,7 @@ enum : okl_long { nr_clock_getres = 229, nr_exit_group = 231, nr_tgkill = 234, nr_openat = 257, nr_mkdirat = 258, nr_newfstatat = 262, nr_unlinkat = 263, nr_renameat = 264, nr_readlinkat = 267, nr_dup3 = 292, nr_execveat = 322, + nr_close_range = 436, nr_prlimit64 = 302, nr_dup2 = 33, nr_utimensat = 280, nr_symlinkat = 266, nr_fstatfs = 138, nr_getrandom = 318, // openkal 0.13: whether a node may be started @@ -183,7 +184,8 @@ enum : okl_long { nr_sched_yield = 124, nr_kill = 129, nr_tgkill = 131, nr_gettid = 178, nr_getpid = 172, nr_mmap = 222, nr_munmap = 215, nr_mprotect = 226, nr_clone = 220, nr_execve = 221, nr_wait4 = 260, nr_renameat = 38, - nr_dup3 = 24, nr_execveat = 281, nr_dup2 = -1, nr_fcntl = 25, + nr_dup3 = 24, nr_execveat = 281, + nr_close_range = 436, nr_prlimit64 = 261, nr_dup2 = -1, nr_fcntl = 25, nr_prctl = 167, nr_sched_getaffinity = 123, nr_getppid = 173, nr_arch_prctl = -1, nr_utimensat = 88, nr_symlinkat = 36, nr_fstatfs = 44, nr_getrandom = 278, @@ -260,6 +262,10 @@ enum : okl_long { // for it. See the start in `kal_process_spawn'. o_path = 010000000, at_empty_path = 0x1000, + // The descriptor flag, set and cleared one descriptor at a time, and the + // form of `close_range' that sets it on every descriptor of a range. + f_setfd = 2, fd_cloexec = 1, close_range_cloexec = 4, rlimit_nofile = 7, + // THE LOWEST FREE DESCRIPTOR AT OR ABOVE A BOUND, which is the one // primitive that moves a descriptor out of the way WITHOUT NAMING the // number it moves to --- and therefore without closing whatever a caller diff --git a/tests/conformance_spawn.cpp b/tests/conformance_spawn.cpp new file mode 100644 index 0000000..3260a2e --- /dev/null +++ b/tests/conformance_spawn.cpp @@ -0,0 +1,226 @@ +// What a started program receives: the three streams and the directories it was +// granted, and nothing else (SPEC.md clause 7.13). +// +// Each case starts a program and has the PROGRAM report what it received, since +// the claim is about the started image and not about the caller. Two programs +// are used. `/bin/sh' is an ordinary executable that needs nothing to survive +// the start, so any descriptor above 2 it holds was conveyed by the start. This +// test itself, started again with `--grant-child', reads its preopens through +// `kal_fs_preopen', which is how a granted directory is defined to arrive. +#include +#include +#include +#include +import openkal.fs; +import openkal.stream; +import openkal.types; +import openkal.process; + +namespace { + +int failures = 0; + +void check(bool held, const char* what) { + if (!held) { std::printf("FAIL: %s\n", what); ++failures; } +} + +const char kMarker[] = "okl-grant-marker"; + +kal_dir working() { return kal::fs::working(); } + +kal_dir root() { + for (kal_uintptr i = 0; i < kal_fs_preopen_count(); ++i) { + kal_dir d{}; char n[8]; kal_uintptr l = 0; + if (kal_fs_preopen(i, &d, n, sizeof n, &l) == kal_ok && l == 1 && n[0] == '/') return d; + } + return kal_dir{}; +} + +bool write_marker(kal_dir d, const char* text) { + kal_file f{}; + const auto flags = kal::fs::open::write | kal::fs::open::create | kal::fs::open::truncate; + if (kal::fs::open_file(d, kMarker, sizeof kMarker - 1, flags, &f) != kal_ok) return false; + const kal_intptr r = kal_stream_write(kal_fs_stream(f), text, std::strlen(text)); + kal_fs_close_file(f); + return r == static_cast(std::strlen(text)); +} + +// --- the started side -------------------------------------------------------- +// +// argv: --grant-child { } +// is the content of the marker file in that directory, or `@' +// for a directory entry that must exist there. The status names the first +// disagreement, so that a failure in the caller says which one it was. +int grant_child(int argc, char** argv) { + const kal_uintptr n = kal_fs_preopen_count(); + if (std::strcmp(argv[2], "ambient") == 0) { + kal_dir d{}; char nm[8]; kal_uintptr l = 0; + if (n != 2) return 10; + if (kal_fs_preopen(1, &d, nm, sizeof nm, &l) != kal_ok || l != 1 || nm[0] != '/') return 11; + return 0; + } + const kal_uintptr want = static_cast(std::atoi(argv[2])); + if (n != want) return 20; + for (kal_uintptr i = 0; i < want; ++i) { + const char* name = argv[3 + 2 * i]; + const char* expect = argv[4 + 2 * i]; + kal_dir d{}; char nm[256]; kal_uintptr l = 0; + if (kal_fs_preopen(i, &d, nm, sizeof nm, &l) != kal_ok) return 30 + static_cast(i); + if (l != std::strlen(name) || std::memcmp(nm, name, l) != 0) return 40 + static_cast(i); + if (expect[0] == '@') { + kal_node_info info = kal::fs::info_for_caller(); + if (kal_fs_info(d, expect + 1, std::strlen(expect + 1), kal::fs::field::kind, 0, &info) != kal_ok + || info.kind != kal_node_directory) + return 50 + static_cast(i); + } else { + kal_file f{}; char buf[64]; + if (kal::fs::open_file(d, kMarker, sizeof kMarker - 1, kal::fs::open::read, &f) != kal_ok) + return 60 + static_cast(i); + const kal_intptr r = kal_stream_read(kal_fs_stream(f), buf, sizeof buf); + kal_fs_close_file(f); + if (r != static_cast(std::strlen(expect)) || std::memcmp(buf, expect, r) != 0) + return 70 + static_cast(i); + } + } + return 0; +} + +// --- the starting side ------------------------------------------------------- + +char g_self[1024]; + +// Starts this test again under the root preopen and answers the status it +// finished with, or -1 when the start itself was refused. +int run_child(const kal_preopen* grants, kal_uintptr count, + const char* const* args, kal_uintptr nargs, + const char** envp = nullptr, kal_uintptr envc = 0) { + const char* argv[16] = { "conformance_spawn", "--grant-child" }; + kal_uintptr lens[16] = { 17, 13 }; + for (kal_uintptr i = 0; i < nargs; ++i) { argv[2 + i] = args[i]; lens[2 + i] = std::strlen(args[i]); } + kal_uintptr elens[4] = {}; + for (kal_uintptr i = 0; i < envc; ++i) elens[i] = std::strlen(envp[i]); + const kal_spawn how{ root(), working(), nullptr, grants, count, 0 }; + kal_process p{}; + const char* rel = g_self + 1; + if (kal_process_spawn(&how, rel, std::strlen(rel), argv, lens, 2 + nargs, + envp, elens, envc, nullptr, &p) != kal_ok) + return -1; + int status = -1, terminated = -1; + kal_process_wait(p, &status, &terminated); + kal_process_close(p); + return terminated == 0 ? status : -1; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc > 2 && std::strcmp(argv[1], "--grant-child") == 0) return grant_child(argc, argv); + + const ssize_t sl = readlink("/proc/self/exe", g_self, sizeof g_self - 1); + check(sl > 1 && g_self[0] == '/', "the test can name its own program"); + g_self[sl > 0 ? sl : 0] = '\0'; + check(root().h != 0, "a directory covering the file system is supplied"); + + // Two directories with distinct contents, so that a grant arriving under + // the wrong name is told apart from one arriving under the right name. + const char* da = "okl-grant-a.tmp"; + const char* db = "okl-grant-b.tmp"; + kal_fs_mkdir(working(), da, std::strlen(da)); + kal_fs_mkdir(working(), db, std::strlen(db)); + kal_dir a{}, b{}; + check(kal_fs_open_dir(working(), da, std::strlen(da), &a) == kal_ok + && kal_fs_open_dir(working(), db, std::strlen(db), &b) == kal_ok, + "two directories are made"); + check(write_marker(a, "A") && write_marker(b, "B"), "each holds its own marker"); + + // Named grants arrive in order, under their names, as the directories named. + // The second name holds both separators the variable uses. + { + const kal_preopen g[2] = { { a, "/first", 6 }, { b, "b;2,x", 5 } }; + const char* args[] = { "2", "/first", "A", "b;2,x", "B" }; + check(run_child(g, 2, args, 5) == 0, + "granted directories arrive in order, under their names, as the directories named"); + } + { + const kal_preopen g[2] = { { b, "/b", 2 }, { a, "/a", 2 } }; + const char* args[] = { "2", "/b", "B", "/a", "A" }; + check(run_child(g, 2, args, 5) == 0, "and in the other order"); + } + + // The caller's own preopens occupy the numbers grants are placed at. Placing + // one at a time where it stood made the second of these a copy of the first, + // and made the first, already at its own position, never arrive at all. + { + const kal_preopen g[2] = { { root(), "/", 1 }, { working(), "/work", 5 } }; + const char* args[] = { "2", "/", "@proc", "/work", "@okl-grant-a.tmp" }; + check(run_child(g, 2, args, 5) == 0, + "grants whose sources occupy each other's positions arrive as themselves"); + const kal_preopen h[2] = { { working(), "/work", 5 }, { root(), "/", 1 } }; + const char* hargs[] = { "2", "/work", "@okl-grant-a.tmp", "/", "@proc" }; + check(run_child(h, 2, hargs, 5) == 0, "and in the other order"); + } + + // A count of zero is a request for no preopens, which is different from not + // asking. Not asking leaves what the implementation supplies by default. + { + const kal_preopen none[1] = {}; + const char* zero[] = { "0" }; + check(run_child(none, 0, zero, 1) == 0, "a count of zero starts a program with no preopens"); + const char* ambient[] = { "ambient" }; + check(run_child(nullptr, 0, ambient, 1) == 0, + "not asking leaves the directories supplied by default"); + } + + // A value of the variable the caller supplies is not forwarded, whether or + // not it names the process: it is the implementation's to write. + { + const char* env[] = { "KAL_PREOPENS=0000000001;3,1,/" }; + const char* ambient[] = { "ambient" }; + check(run_child(nullptr, 0, ambient, 1, env, 1) == 0, + "a variable the caller supplies is not taken for a grant"); + } + + // A name travels in the environment, which cannot carry a zero byte. + { + const kal_preopen g[1] = { { a, "x\0y", 3 } }; + const char* args[] = { "1", "x", "A" }; + check(run_child(g, 1, args, 3) == -1, "a name holding a zero byte is refused"); + } + + // An ordinary executable needs nothing to survive the start, so it receives + // nothing above the three streams --- including what this process itself + // inherited. The glob is expanded before the loop, so the descriptor the + // shell had open to list the directory is gone by the time it is tested. + { + const char* body = "for f in /proc/self/fd/*; do [ -e \"$f\" ] || continue; " + "[ \"${f##*/}\" -gt 2 ] && { echo \"inherited $f -> $(readlink $f)\" >&2; exit 1; }; " + "done; exit 0"; + const char* sargv[3] = { "sh", "-c", body }; + const kal_uintptr slens[3] = { 2, 2, std::strlen(body) }; + const char* paths[2] = { "bin/sh", "usr/bin/sh" }; + const kal_spawn how{ root(), working(), nullptr, nullptr, 0, 0 }; + kal_process p{}; + int rc = kal_err_invalid; + for (int i = 0; i < 2 && rc != kal_ok; ++i) + rc = kal_process_spawn(&how, paths[i], std::strlen(paths[i]), sargv, slens, 3, + nullptr, nullptr, 0, nullptr, &p); + check(rc == kal_ok, "an ordinary executable is started"); + if (rc == kal_ok) { + int status = -1, terminated = -1; + kal_process_wait(p, &status, &terminated); + kal_process_close(p); + check(terminated == 0 && status == 0, + "an ordinary executable receives no descriptor above the three streams"); + } + } + + kal_fs_remove(a, kMarker, sizeof kMarker - 1); + kal_fs_remove(b, kMarker, sizeof kMarker - 1); + kal_fs_close_dir(a); + kal_fs_close_dir(b); + kal_fs_remove(working(), da, std::strlen(da)); + kal_fs_remove(working(), db, std::strlen(db)); + + std::printf("openkal-linux: what a started program receives\n"); + return failures == 0 ? 0 : 1; +} From d5148c66e43da2a20d0a66e3c56194a9e3b2e8b4 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 1 Oct 2026 20:04:44 +0800 Subject: [PATCH 3/4] 0.15.1 --- what a started program receives, and the README that says so The version is a patch: the changes bring the implementation into line with contracts already declared, and no declaration changes. The specification is named by its development line until openkal 0.14.1 is published. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- README.md | 27 +++++++++++++++++++++++++-- mcpp.toml | 4 ++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f37038d..bfeb694 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ for Linux, written on the kernel's own system-call interface. ```toml [dependencies] -openkal = "0.14.0" +openkal = "0.14.1" [target.'cfg(os = "linux")'.dependencies] -openkal-linux = "0.14.0" +openkal-linux = "0.15.1" ``` ## Why it does not use a C library @@ -101,6 +101,29 @@ resolve an absolute path and report one, and a name of `"."` leaves it able to do only the first — which version 0.4 did, and which is why `getcwd` could not have worked above it. +**A started program receives the three streams and its grants, and nothing +else.** Clause 7.13. Every source is first moved above the positions being +filled, so that placing one cannot overwrite another; everything above the +grants is then marked to close on replacement, including what the calling +program itself inherited (`close_range`, from Linux 5.11; `/proc/self/fd` or the +descriptor limit before it). The program is started through a descriptor for its +own file with `O_CLOEXEC`, and only a program that needs an interpreter --- a +`#!` script, or a `binfmt_misc` binary --- keeps that descriptor: the kernel +refuses such a program with `ENOENT` before the point of no return, and the start +is repeated with the flag cleared. Such a script observes `$0` as `/dev/fd/`. + +**Granted directories are named in the environment.** A grant arrives as a +descriptor at 3 and upward, and its name in the variable +`KAL_PREOPENS={;,,}`, the arrangement of systemd's +`LISTEN_FDS` and `LISTEN_FDNAMES`. `` is written by the started process +itself, so a value inherited through a program that does not read it names no +one. A program started with grants enumerates exactly those directories, the +first of which is the directory it regards as the one it was started in; a +program started without them enumerates the working directory and `/`, as +before. A grant names directories to a program that confines itself to its +preopens; it does not confine a program that opens `/` on its own, which is the +environment's responsibility (clause 11, entry 6). + **Interruption is retried, not reported.** A caller cannot distinguish an interrupted call from a genuine failure without knowledge of the platform, and an implementation that reports it produces short transfers on any system that diff --git a/mcpp.toml b/mcpp.toml index 43f740d..e434287 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "openkal-linux" -version = "0.15.0" +version = "0.15.1" description = "The reference implementation of openkal for Linux, written on the kernel's own system-call interface so that it can be placed beneath a C library as well as above one." license = "Apache-2.0" @@ -63,7 +63,7 @@ provides-interfaces = [ ] [dependencies] -openkal = "0.14.0" +openkal = { git = "https://github.com/mcpplibs/openkal.git", branch = "openkal-0.14.1" } # The package contributes definitions and no modules. The interface it # implements is declared by the specification package, which this package From 293aeaa7d06fa85a00522c093c948f71ccc8d674 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 1 Oct 2026 20:15:36 +0800 Subject: [PATCH 4/4] The specification is named by its released version, 0.14.1 Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- mcpp.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpp.toml b/mcpp.toml index e434287..90367b0 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -63,7 +63,7 @@ provides-interfaces = [ ] [dependencies] -openkal = { git = "https://github.com/mcpplibs/openkal.git", branch = "openkal-0.14.1" } +openkal = "0.14.1" # The package contributes definitions and no modules. The interface it # implements is declared by the specification package, which this package