Skip to content

Commit fd7b938

Browse files
ZhiXiao-LinRoy Lin
andauthored
fix(guest): apply seccomp/caps/no_new_privs confinement on the PTY path (#11) (#115)
CRI tty:true containers run their workload through pty_server's raw fork, which applied NONE of the A3S_SEC_* controls the exec path enforces: no capability drop/keep-set, no seccomp filter, no_new_privs unset, no supplemental groups. A pod that requested a securityContext (RuntimeDefault seccomp, dropped caps, runAsNonRoot) but also set tty:true silently ran with FULL capabilities and no syscall filter — kubelet believed the policy was in effect. Any tenant that requested a TTY got an isolation bypass. Parse the same A3S_SEC_* controls from the PtyRequest env (same KEY=VALUE format the exec path consumes) and build the seccomp filter BEFORE the fork (building allocates; the post-fork child stays async-signal-safe). In the child apply, reusing the exact async-signal-safe namespace:: primitives the exec path uses: supplemental groups + capabilities BEFORE the uid switch (they need CAP_SETGID/CAP_SETPCAP), then no_new_privs and the prebuilt seccomp filter AFTER it. The exec path is untouched. Ordering note: the PTY child does its own setsid (controlling terminal) and chroot/user.apply, so only the confinement sequence is shared — not the exec path's setpgid/chroot, which would conflict with setsid. KNOWN follow-up: tty containers still get NO cgroup (pty_server never calls ContainerCgroup::create) so cpu/mem/pids limits remain unenforced for them; and masked/readonly paths (parent-side fs ops) aren't applied here yet. Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent eae2da1 commit fd7b938

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

src/guest/init/src/pty_server.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,75 @@ fn handle_pty_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box<dyn std::er
164164

165165
info!(cmd = ?request.cmd, "PTY session starting");
166166

167+
// Parse the A3S_SEC_* confinement controls from the request env (same keys
168+
// and KEY=VALUE format the exec path consumes) and build the seccomp filter
169+
// BEFORE the fork — building a filter allocates, and the post-fork child must
170+
// stay async-signal-safe. The TTY workload previously applied NONE of these,
171+
// running with full capabilities, no seccomp and no_new_privs unset despite
172+
// the pod's securityContext (#11). The same async-signal-safe namespace::
173+
// primitives the exec path uses are applied in the child below.
174+
let sec_supplemental_groups: Vec<u32> = request
175+
.env
176+
.iter()
177+
.find_map(|entry| entry.strip_prefix("A3S_SEC_SUPPLEMENTAL_GROUPS="))
178+
.map(|csv| {
179+
csv.split(',')
180+
.filter_map(|gid| gid.trim().parse::<u32>().ok())
181+
.collect()
182+
})
183+
.unwrap_or_default();
184+
let sec_cap_drop: Vec<String> = request
185+
.env
186+
.iter()
187+
.find_map(|entry| entry.strip_prefix("A3S_SEC_CAP_DROP="))
188+
.map(|csv| {
189+
csv.split(',')
190+
.map(|name| name.trim().to_string())
191+
.filter(|name| !name.is_empty())
192+
.collect()
193+
})
194+
.unwrap_or_default();
195+
let sec_cap_keep: Option<Vec<String>> = request
196+
.env
197+
.iter()
198+
.find_map(|entry| entry.strip_prefix("A3S_SEC_CAP_KEEP="))
199+
.map(|csv| {
200+
csv.split(',')
201+
.map(|name| name.trim().to_string())
202+
.filter(|name| !name.is_empty())
203+
.collect()
204+
});
205+
let sec_no_new_privs = request
206+
.env
207+
.iter()
208+
.any(|entry| entry == "A3S_SEC_NO_NEW_PRIVS=1");
209+
#[cfg(target_os = "linux")]
210+
let seccomp_filter: Option<Vec<libc::sock_filter>> = {
211+
let localhost: Vec<u32> = request
212+
.env
213+
.iter()
214+
.find_map(|entry| entry.strip_prefix("A3S_SEC_SECCOMP_LOCALHOST="))
215+
.map(|csv| {
216+
csv.split(',')
217+
.filter_map(|name| crate::namespace::syscall_name_to_number(name.trim()))
218+
.collect()
219+
})
220+
.unwrap_or_default();
221+
let apply_default = request
222+
.env
223+
.iter()
224+
.any(|entry| entry == "A3S_SEC_SECCOMP=default");
225+
if !localhost.is_empty() {
226+
Some(crate::namespace::build_seccomp_errno_filter(&localhost))
227+
} else if apply_default {
228+
Some(crate::namespace::build_default_bpf_filter())
229+
} else {
230+
None
231+
}
232+
};
233+
#[cfg(not(target_os = "linux"))]
234+
let _ = (&sec_cap_drop, &sec_cap_keep, sec_no_new_privs);
235+
167236
// Step 2: Allocate PTY
168237
let pty = openpty(None, None)?;
169238
let master_fd = pty.master;
@@ -219,13 +288,63 @@ fn handle_pty_connection(fd: std::os::fd::OwnedFd) -> Result<(), Box<dyn std::er
219288
let _ = std::env::set_current_dir(dir);
220289
}
221290

291+
// Confinement, part 1 (while still root, BEFORE the uid switch):
292+
// supplemental groups need CAP_SETGID and capset needs CAP_SETPCAP,
293+
// both cleared once user.apply() drops to a non-root uid. The default
294+
// keep-set retains CAP_SETUID/CAP_SETGID so user.apply still works.
295+
if !sec_supplemental_groups.is_empty() {
296+
let ret = unsafe {
297+
libc::setgroups(
298+
sec_supplemental_groups.len() as _,
299+
sec_supplemental_groups.as_ptr() as *const libc::gid_t,
300+
)
301+
};
302+
if ret != 0 {
303+
eprintln!("Failed to set PTY supplemental groups");
304+
std::process::exit(127);
305+
}
306+
}
307+
#[cfg(target_os = "linux")]
308+
{
309+
if let Some(ref keep) = sec_cap_keep {
310+
if let Err(error) = crate::namespace::restrict_capabilities_to_keep(keep) {
311+
eprintln!("Failed to restrict PTY capabilities: {}", error);
312+
std::process::exit(127);
313+
}
314+
} else if !sec_cap_drop.is_empty() {
315+
if let Err(error) = crate::namespace::drop_capabilities(&sec_cap_drop) {
316+
eprintln!("Failed to drop PTY capabilities: {}", error);
317+
std::process::exit(127);
318+
}
319+
}
320+
}
321+
222322
if let Some(user) = process_user {
223323
if let Err(error) = user.apply() {
224324
eprintln!("Failed to apply PTY user: {}", error);
225325
std::process::exit(127);
226326
}
227327
}
228328

329+
// Confinement, part 2 (AFTER the uid switch): no_new_privs before
330+
// seccomp so a later execve cannot regain privileges, then install
331+
// the prebuilt seccomp filter last so it is active across execvp.
332+
#[cfg(target_os = "linux")]
333+
{
334+
if sec_no_new_privs {
335+
if let Err(error) = crate::namespace::set_no_new_privs() {
336+
eprintln!("Failed to set PTY no_new_privs: {}", error);
337+
std::process::exit(127);
338+
}
339+
}
340+
if let Some(ref filter) = seccomp_filter {
341+
if let Err(error) = crate::namespace::install_seccomp_filter(filter) {
342+
eprintln!("Failed to install PTY seccomp filter: {}", error);
343+
std::process::exit(127);
344+
}
345+
}
346+
}
347+
229348
let program = request.cmd[0].clone();
230349
let args = request.cmd[1..].to_vec();
231350

0 commit comments

Comments
 (0)