Skip to content

Commit 5104f8d

Browse files
committed
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/<n>/<name> 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/<n>/. 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/<n>, 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/<n> and not as a path under its directory, as with fexecve. The descriptor can still be reopened through /proc/self/fd/<n>, 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.
1 parent 9ef9720 commit 5104f8d

3 files changed

Lines changed: 74 additions & 21 deletions

File tree

‎src/process.cpp‎

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ int kal_process_spawn(const kal_spawn* how,
246246

247247
// THE DIRECTORY THE PROGRAM RUNS IN, AND THIS LINE IS THE WHOLE OF IT.
248248
//
249-
// `execveat' below takes `b' as a dirfd, but that only RESOLVES the
249+
// `openat' below takes `b' as a dirfd, but that only RESOLVES the
250250
// name --- resolving a name is not entering a directory, which is what
251251
// the comment here used to get wrong. Until 0.11 there was no second
252252
// directory to enter, and a started program ran wherever this
@@ -279,8 +279,9 @@ int kal_process_spawn(const kal_spawn* how,
279279
okl::sys(okl::nr_exit_group, 127);
280280
}
281281

282-
// THE BASE IS DUPLICATED SO THAT IT SURVIVES THE REPLACEMENT, AND
283-
// WITHOUT THIS A WHOLE CLASS OF PROGRAMS COULD NOT BE STARTED AT ALL.
282+
// WHAT THE STARTED PROGRAM KEEPS IS A DESCRIPTOR FOR THE PROGRAM, NOT
283+
// FOR THE DIRECTORY IT WAS FOUND IN, AND WITHOUT A DESCRIPTOR AT ALL A
284+
// WHOLE CLASS OF PROGRAMS COULD NOT BE STARTED.
284285
//
285286
// `execveat' with a dirfd and a relative name gives the program's name to
286287
// the kernel as `/dev/fd/<dirfd>/<name>'. That spelling is invisible to a
@@ -289,31 +290,55 @@ int kal_process_spawn(const kal_spawn* how,
289290
// needs an INTERPRETER: a `#!' script, or a binary of another
290291
// architecture registered through `binfmt_misc'. The kernel then starts
291292
// the interpreter and hands it that name to open --- AFTER the
292-
// replacement, by which time a close-on-exec dirfd is gone. The
293+
// replacement, by which time a close-on-exec descriptor is gone. The
293294
// interpreter is told the script does not exist.
294295
//
295296
// Measured in twenty lines of plain C, with everything else identical:
296297
//
297-
// dirfd WITH O_CLOEXEC execveat -> ENOENT
298-
// dirfd WITHOUT O_CLOEXEC STARTED ok
298+
// descriptor WITH O_CLOEXEC execveat -> ENOENT
299+
// descriptor WITHOUT O_CLOEXEC STARTED ok
299300
//
300301
// It is not a property of one architecture. It was FOUND on aarch64,
301302
// where every foreign binary needs the binfmt interpreter and so every
302303
// start failed --- and it was mistaken there for a limit of the emulator.
303304
// It reproduces natively on x86_64 with a `#!' script, which is what a
304305
// consumer meets on any machine.
305306
//
306-
// Duplicated HERE, in the started image, and not where the preopens are
307-
// made: the caller's own descriptors stay close-on-exec, which is what
308-
// every other operation of this implementation relies upon. `dup' clears
309-
// the flag by definition, so the copy is the exec-visible one.
310-
const okl_long visible = okl::sys(okl::nr_fcntl, b, okl::f_dupfd, 0);
311-
const okl_long base = okl::failed(visible) ? b : visible;
307+
// The descriptor that survives must not be the base. A copy of the base
308+
// is a handle on a whole directory, often `/', and a program that runs
309+
// other programs in a sandbox hands it on to them, where
310+
// `/proc/self/fd/<n>/' reaches whatever the sandbox hid. So the program
311+
// itself is opened here, with `O_PATH' because it is only to be named
312+
// and not read, and without `O_CLOEXEC' for the reason above, and
313+
// `execveat' is given that descriptor and an empty name. The
314+
// interpreter's `/dev/fd/<n>' then names the file, and nothing can be
315+
// walked out of a descriptor for a file.
316+
//
317+
// What is given up: a script now sees `$0' as `/dev/fd/<n>' and not as
318+
// a path under its directory, so one that locates its neighbours with
319+
// `dirname "$0"' no longer finds them. That is the price of leaving a
320+
// descriptor for the file and not for the directory, the same one
321+
// `fexecve' charges. The descriptor still names the program, and a
322+
// caller that sandboxes what it starts should close what it inherits.
323+
//
324+
// Opened HERE, in the started image, and not where the preopens are made:
325+
// the caller's own descriptors stay close-on-exec, which is what every
326+
// other operation of this implementation relies upon.
327+
//
328+
// A name that cannot be opened is reported exactly as one that cannot be
329+
// started is, through the same pipe.
330+
const okl_long exe = okl::sys(okl::nr_openat, b, reinterpret_cast<okl_long>(p.buf),
331+
okl::o_path, 0);
332+
if (okl::failed(exe)) {
333+
report.say(exe);
334+
okl::sys(okl::nr_exit_group, 127);
335+
for (;;) { }
336+
}
312337

313338
const okl_long why =
314-
okl::sys(okl::nr_execveat, base, reinterpret_cast<okl_long>(p.buf),
339+
okl::sys(okl::nr_execveat, exe, reinterpret_cast<okl_long>(""),
315340
reinterpret_cast<okl_long>(args.slots),
316-
reinterpret_cast<okl_long>(envs.slots), 0);
341+
reinterpret_cast<okl_long>(envs.slots), okl::at_empty_path);
317342
// Reached only when the replacement did not happen, because when it does
318343
// there is nothing here to reach.
319344
report.say(why);

‎src/sys.h‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,20 +256,17 @@ enum : okl_long {
256256
o_creat = 0100, o_excl = 0200, o_trunc = 01000, o_append = 02000,
257257
o_cloexec = 02000000,
258258

259+
// A descriptor that only NAMES a node, and the empty name `execveat' accepts
260+
// for it. See the start in `kal_process_spawn'.
261+
o_path = 010000000, at_empty_path = 0x1000,
262+
259263
// THE LOWEST FREE DESCRIPTOR AT OR ABOVE A BOUND, which is the one
260264
// primitive that moves a descriptor out of the way WITHOUT NAMING the
261265
// number it moves to --- and therefore without closing whatever a caller
262266
// already had there. `dup3' cannot do this: it is told the number, and it
263267
// closes what is on it.
264268
f_dupfd_cloexec = 1030,
265269

266-
// The same primitive WITHOUT the flag, which is the point of having both.
267-
// A descriptor duplicated this way survives a replacement, and starting a
268-
// program that needs an interpreter depends on exactly that --- see the
269-
// duplication in `kal_process_spawn'. `dup' would do as well and this
270-
// architecture pair does not agree on whether it exists.
271-
f_dupfd = 0,
272-
273270
// THE OPEN-FILE FORM AND NOT THE PROCESS FORM, WHICH IS THE WHOLE
274271
// DIFFERENCE.
275272
//

‎tests/conformance_additions.cpp‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,37 @@ int main() {
181181
kal_fs_remove(here(), prog, pn);
182182
}
183183

184+
// A started `#!' script is handed `/dev/fd/<n>' by the kernel and opens it
185+
// after the replacement, so a descriptor has to be left behind for it. It
186+
// must be one for the script and not for the directory the name was found
187+
// in: a program that starts other programs under a sandbox passes every
188+
// descriptor on, and a directory among them reaches outside it. The base
189+
// and the working directory are the same here, so it is `.' that must not
190+
// be among them.
191+
{
192+
const char* prog = "okl-fd-probe.tmp";
193+
const kal_uintptr pn = std::strlen(prog);
194+
check(put(prog, "#!/bin/sh\nfor f in /proc/self/fd/*; do\n"
195+
" [ \"$f\" -ef . ] && exit 1\ndone\nexit 0\n"),
196+
"a script is written");
197+
check(kal_fs_set_executable_at(here(), prog, pn, 1) == kal_ok,
198+
"the script is recorded as startable");
199+
200+
kal_process p{};
201+
const char* argv[1] = { prog };
202+
const kal_uintptr lens[1] = { pn };
203+
const kal_spawn how{ here(), here(), nullptr, nullptr, 0, 0 };
204+
check(kal_process_spawn(&how, prog, pn, argv, lens, 1,
205+
nullptr, nullptr, 0, nullptr, &p) == kal_ok,
206+
"a `#!' script is started");
207+
int status = -1, terminated = -1;
208+
kal_process_wait(p, &status, &terminated);
209+
check(terminated == 0 && status == 0,
210+
"the script ran, and it inherited no descriptor for its base directory");
211+
kal_process_close(p);
212+
kal_fs_remove(here(), prog, pn);
213+
}
214+
184215
std::printf("openkal-linux: the operations version 0.5 added\n");
185216
return failures == 0 ? 0 : 1;
186217
}

0 commit comments

Comments
 (0)