Skip to content

Commit b9806f6

Browse files
author
Roy Lin
committed
fix(box): base64-encode exec args/env so quotes survive libkrun env passing
A container command or env value containing a double-quote was corrupted reaching the guest: `a3s-box run alpine -- sh -c 'echo "a b"'` failed with the guest sh reporting 'unterminated quoted string' (plain spaces and quote-free values were fine). a3s-box passes the command byte-clean end-to-end (cmd Vec<String> -> one BOX_EXEC_ARG_<i> env var each -> clean KEY=VALUE CStrings -> krun_set_exec), so the corruption is in libkrun/libkrunfw's env delivery to guest PID 1 — the same layer whose space-truncation quirk the per-arg BOX_EXEC_ARG design already works around. Work around it completely at the a3s-box layer: the runtime now base64-encodes (URL-safe, no padding — A-Za-z0-9-_ only) the BOX_EXEC_EXEC / ARG_<i> / WORKDIR / USER / ENV_<k> VALUES and sets BOX_EXEC_B64=1; guest-init decodes them in ExecConfig::from_env (falling back to the raw value if the marker is absent or a value fails to decode, so old runtime + new guest still works). base64 contains no byte that libkrun mishandles, so any container command/env now reaches the container exactly as written. Found by comprehensive real-OCI testing. clippy -D warnings clean on runtime + x86_64-unknown-linux-musl. KVM verification (the repro + no regression) to follow.
1 parent 8589a5d commit b9806f6

2 files changed

Lines changed: 44 additions & 13 deletions

File tree

src/guest/init/src/main.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,31 +247,53 @@ impl ExecConfig {
247247
// (runtime/src/vm/spec.rs), so this default is only a defensive fallback.
248248
// Use /bin/sh — universal across distros — never /sbin/init, which does
249249
// not exist on Alpine and was the original cause of issue #3.
250-
let executable = std::env::var("BOX_EXEC_EXEC").unwrap_or_else(|_| "/bin/sh".to_string());
250+
// BOX_EXEC_* values are base64-encoded (URL-safe, no pad) by the runtime
251+
// when BOX_EXEC_B64=1, so arbitrary bytes (quotes, spaces, `$`, …) survive
252+
// libkrun's env serialization. Decode them back; fall back to the raw value
253+
// on any decode error or when the marker is absent (older runtime).
254+
use base64::Engine;
255+
let b64 = std::env::var("BOX_EXEC_B64").map(|v| v == "1").unwrap_or(false);
256+
let decode = |s: String| -> String {
257+
if !b64 {
258+
return s;
259+
}
260+
base64::engine::general_purpose::URL_SAFE_NO_PAD
261+
.decode(s.as_bytes())
262+
.ok()
263+
.and_then(|bytes| String::from_utf8(bytes).ok())
264+
.unwrap_or(s)
265+
};
266+
267+
let executable = std::env::var("BOX_EXEC_EXEC")
268+
.map(&decode)
269+
.unwrap_or_else(|_| "/bin/sh".to_string());
251270

252271
// Parse args from individual env vars (BOX_EXEC_ARGC + BOX_EXEC_ARG_0..N)
253272
let args: Vec<String> = match std::env::var("BOX_EXEC_ARGC")
254273
.ok()
255274
.and_then(|s| s.parse::<usize>().ok())
256275
{
257276
Some(argc) => (0..argc)
258-
.filter_map(|i| std::env::var(format!("BOX_EXEC_ARG_{}", i)).ok())
277+
.filter_map(|i| std::env::var(format!("BOX_EXEC_ARG_{}", i)).ok().map(&decode))
259278
.collect(),
260279
None => vec![],
261280
};
262281

263-
let workdir = std::env::var("BOX_EXEC_WORKDIR").unwrap_or_else(|_| "/".to_string());
282+
let workdir = std::env::var("BOX_EXEC_WORKDIR")
283+
.map(&decode)
284+
.unwrap_or_else(|_| "/".to_string());
264285

265286
// Optional container user (image USER directive or CLI --user).
266287
let user = std::env::var("BOX_EXEC_USER")
267288
.ok()
289+
.map(&decode)
268290
.filter(|u| !u.is_empty());
269291

270-
// Collect BOX_EXEC_ENV_* variables
292+
// Collect BOX_EXEC_ENV_* variables (values decoded as above).
271293
let env: Vec<(String, String)> = std::env::vars()
272294
.filter_map(|(key, value)| {
273295
key.strip_prefix("BOX_EXEC_ENV_")
274-
.map(|stripped| (stripped.to_string(), value))
296+
.map(|stripped| (stripped.to_string(), decode(value)))
275297
})
276298
.collect();
277299

src/runtime/src/vm/spec.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,20 @@ impl VmManager {
134134
};
135135
a3s_box_core::env::merge_env_pairs(&mut container_env, &self.config.extra_env);
136136

137-
// Pass exec + args as individual env vars (avoids spaces being truncated
138-
// by libkrun's env serialization).
137+
// Pass exec + args as individual env vars, base64-encoded (URL-safe, no
138+
// padding) so arbitrary bytes — spaces, quotes, `$`, `;` — survive
139+
// libkrun's env serialization intact (a raw `"` in a value was being
140+
// corrupted). `BOX_EXEC_B64=1` tells guest-init to decode them.
141+
use base64::Engine;
142+
let b64 =
143+
|s: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(s.as_bytes());
139144
let mut env: Vec<(String, String)> = vec![
140-
("BOX_EXEC_EXEC".to_string(), exec),
145+
("BOX_EXEC_B64".to_string(), "1".to_string()),
146+
("BOX_EXEC_EXEC".to_string(), b64(&exec)),
141147
("BOX_EXEC_ARGC".to_string(), args.len().to_string()),
142148
];
143149
for (i, arg) in args.iter().enumerate() {
144-
env.push((format!("BOX_EXEC_ARG_{}", i), arg.clone()));
150+
env.push((format!("BOX_EXEC_ARG_{}", i), b64(arg)));
145151
}
146152

147153
// Prototype: deferred-main-spawn. If the host set BOX_DEFERRED_MAIN=1,
@@ -157,20 +163,23 @@ impl VmManager {
157163

158164
// Pass the effective working directory to guest init so PID 1 and
159165
// the container entrypoint agree even when no OCI WORKDIR is set.
160-
env.push(("BOX_EXEC_WORKDIR".to_string(), workdir.clone()));
166+
env.push(("BOX_EXEC_WORKDIR".to_string(), b64(&workdir)));
161167

162168
// Pass the container user (image USER / --user) to guest init, which
163169
// applies it (setgroups+setgid+setuid) to the MAIN process right
164170
// before exec — after PID 1 has done its root-only setup. This must
165171
// NOT go through the shim's libkrun set_uid, which would drop guest
166172
// PID 1 to the user and break init (mount/chroot need root).
167173
if let Some(user) = &user {
168-
env.push(("BOX_EXEC_USER".to_string(), user.clone()));
174+
env.push(("BOX_EXEC_USER".to_string(), b64(user)));
169175
}
170176

171-
// Pass container environment variables with BOX_EXEC_ENV_ prefix
177+
// Pass container environment variables with BOX_EXEC_ENV_ prefix (values
178+
// base64-encoded like the rest, so a value containing `"`/spaces/etc.
179+
// reaches the container intact). The key stays raw — env names are
180+
// restricted to a safe character set.
172181
for (key, value) in container_env {
173-
env.push((format!("BOX_EXEC_ENV_{}", key), value));
182+
env.push((format!("BOX_EXEC_ENV_{}", key), b64(&value)));
174183
}
175184

176185
// Pass user volume mounts to guest init for mounting inside the VM.

0 commit comments

Comments
 (0)