Prototype FreeBSD build user isolation

This commit is contained in:
2026-04-01 11:43:56 +02:00
parent 7621798ef5
commit d65b2afb27
4 changed files with 724 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
struct options {
const char *job_name;
uid_t uid;
gid_t gid;
const char *allowed_file;
const char *own_output;
const char *peer_file;
const char *hidden_file;
const char *protected_file;
unsigned int hold_seconds;
};
static int failures = 0;
static void usage(const char *argv0)
{
fprintf(stderr,
"usage: %s --job-name NAME --uid UID --gid GID --allowed-file PATH "
"--own-output PATH --peer-file PATH --hidden-file PATH "
"--protected-file PATH [--hold-seconds N]\n",
argv0);
}
static void print_value(const char *key, const char *value)
{
printf("%s=%s\n", key, value);
}
static void print_number(const char *key, long value)
{
printf("%s=%ld\n", key, value);
}
static void report_failure(const char *key, const char *detail)
{
failures++;
printf("%s=fail:%s\n", key, detail);
}
static bool parse_ulong(const char *text, unsigned long *value)
{
char *end = NULL;
unsigned long parsed;
errno = 0;
parsed = strtoul(text, &end, 10);
if (errno != 0 || end == text || *end != '\0')
return false;
*value = parsed;
return true;
}
static int parse_options(int argc, char **argv, struct options *opts)
{
int i;
memset(opts, 0, sizeof(*opts));
opts->hold_seconds = 0;
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--job-name") == 0 && i + 1 < argc) {
opts->job_name = argv[++i];
} else if (strcmp(argv[i], "--uid") == 0 && i + 1 < argc) {
unsigned long value;
if (!parse_ulong(argv[++i], &value))
return -1;
opts->uid = (uid_t)value;
} else if (strcmp(argv[i], "--gid") == 0 && i + 1 < argc) {
unsigned long value;
if (!parse_ulong(argv[++i], &value))
return -1;
opts->gid = (gid_t)value;
} else if (strcmp(argv[i], "--allowed-file") == 0 && i + 1 < argc) {
opts->allowed_file = argv[++i];
} else if (strcmp(argv[i], "--own-output") == 0 && i + 1 < argc) {
opts->own_output = argv[++i];
} else if (strcmp(argv[i], "--peer-file") == 0 && i + 1 < argc) {
opts->peer_file = argv[++i];
} else if (strcmp(argv[i], "--hidden-file") == 0 && i + 1 < argc) {
opts->hidden_file = argv[++i];
} else if (strcmp(argv[i], "--protected-file") == 0 && i + 1 < argc) {
opts->protected_file = argv[++i];
} else if (strcmp(argv[i], "--hold-seconds") == 0 && i + 1 < argc) {
unsigned long value;
if (!parse_ulong(argv[++i], &value))
return -1;
opts->hold_seconds = (unsigned int)value;
} else {
return -1;
}
}
if (opts->job_name == NULL || opts->allowed_file == NULL || opts->own_output == NULL ||
opts->peer_file == NULL || opts->hidden_file == NULL || opts->protected_file == NULL)
return -1;
return 0;
}
static void require_drop(uid_t uid, gid_t gid)
{
gid_t groups[1];
groups[0] = gid;
if (setgroups(1, groups) != 0) {
report_failure("drop.setgroups", strerror(errno));
return;
}
if (setgid(gid) != 0) {
report_failure("drop.setgid", strerror(errno));
return;
}
if (setuid(uid) != 0) {
report_failure("drop.setuid", strerror(errno));
return;
}
print_number("post_drop.euid", (long)geteuid());
print_number("post_drop.egid", (long)getegid());
}
static void check_setuid_regain_root(void)
{
if (setuid(0) == 0) {
report_failure("drop.regain_root", "unexpected-success");
return;
}
print_value("drop.regain_root", strerror(errno));
}
static void check_read_allowed(const char *path)
{
int fd;
char buffer[256];
ssize_t nread;
fd = open(path, O_RDONLY);
if (fd < 0) {
report_failure("access.allowed_read", strerror(errno));
return;
}
nread = read(fd, buffer, sizeof(buffer) - 1);
close(fd);
if (nread < 0) {
report_failure("access.allowed_read", strerror(errno));
return;
}
buffer[nread] = '\0';
print_value("access.allowed_read", buffer);
}
static void check_write_own_output(const char *path, const char *job_name)
{
int fd;
char buffer[256];
struct stat st;
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) {
report_failure("access.own_output", strerror(errno));
return;
}
snprintf(buffer, sizeof(buffer), "%s-ran-as-build-user\n", job_name);
if (write(fd, buffer, strlen(buffer)) < 0) {
close(fd);
report_failure("access.own_output", strerror(errno));
return;
}
close(fd);
if (stat(path, &st) != 0) {
report_failure("access.own_output_stat", strerror(errno));
return;
}
print_number("access.own_output_uid", (long)st.st_uid);
print_number("access.own_output_gid", (long)st.st_gid);
}
static void expect_denied(const char *key, const char *path)
{
int fd;
errno = 0;
fd = open(path, O_RDONLY);
if (fd >= 0) {
close(fd);
report_failure(key, "unexpected-success");
return;
}
print_value(key, strerror(errno));
}
static void expect_protected_create_denied(const char *path)
{
int fd;
errno = 0;
fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd >= 0) {
close(fd);
unlink(path);
report_failure("access.protected_create", "unexpected-success");
return;
}
print_value("access.protected_create", strerror(errno));
}
static void expect_chown_denied(const char *path)
{
if (chown(path, 0, 0) == 0) {
report_failure("drop.chown_root", "unexpected-success");
return;
}
print_value("drop.chown_root", strerror(errno));
}
int main(int argc, char **argv)
{
struct options opts;
if (parse_options(argc, argv, &opts) != 0) {
usage(argv[0]);
return 2;
}
print_value("job", opts.job_name);
print_number("target.uid", (long)opts.uid);
print_number("target.gid", (long)opts.gid);
require_drop(opts.uid, opts.gid);
check_setuid_regain_root();
check_read_allowed(opts.allowed_file);
check_write_own_output(opts.own_output, opts.job_name);
expect_denied("access.peer_file", opts.peer_file);
expect_denied("access.hidden_file", opts.hidden_file);
expect_protected_create_denied(opts.protected_file);
expect_chown_denied(opts.own_output);
if (opts.hold_seconds > 0)
sleep(opts.hold_seconds);
return failures == 0 ? 0 : 1;
}

View File

@@ -0,0 +1,228 @@
#!/bin/sh
set -eu
cc_bin=${CC_BIN:-/usr/bin/cc}
uid1=${BUILD_UID1:-35001}
gid1=${BUILD_GID1:-35001}
uid2=${BUILD_UID2:-35002}
gid2=${BUILD_GID2:-35002}
hold_seconds=${HOLD_SECONDS:-2}
cleanup_workdir=0
mount_points=
jail_ids=
if [ -n "${WORKDIR:-}" ]; then
workdir=$WORKDIR
mkdir -p "$workdir"
else
workdir=$(mktemp -d /tmp/fruix-privdrop-prototype.XXXXXX)
cleanup_workdir=1
fi
if [ "${KEEP_WORKDIR:-0}" -eq 1 ]; then
cleanup_workdir=0
fi
record_mount() {
mount_points="$1
$mount_points"
}
record_jail() {
jail_ids="$1
$jail_ids"
}
cleanup() {
set +e
old_ifs=$IFS
IFS='
'
for jail_id in $jail_ids; do
[ -n "$jail_id" ] || continue
sudo jail -r "$jail_id" >/dev/null 2>&1 || true
done
for mount_point in $mount_points; do
[ -n "$mount_point" ] || continue
sudo umount "$mount_point" >/dev/null 2>&1 || true
done
IFS=$old_ifs
if [ "$cleanup_workdir" -eq 1 ]; then
sudo rm -rf "$workdir"
fi
}
trap cleanup EXIT INT TERM
helper_src=tests/daemon/freebsd-build-user-helper.c
helper_bin=$workdir/tools/freebsd-build-user-helper
protected_dir=$workdir/protected
outside_sentinel=$workdir/outside-sentinel
metadata_file=$workdir/freebsd-privdrop-metadata.txt
helper_compile_log=$workdir/helper-compile.log
helper_compile_err=$workdir/helper-compile.err
job1_out=$workdir/job1.out
job1_err=$workdir/job1.err
job2_out=$workdir/job2.out
job2_err=$workdir/job2.err
job1_stat=$workdir/job1.stat
job2_stat=$workdir/job2.stat
mkdir -p "$workdir/tools" "$protected_dir" "$workdir/job1-host" "$workdir/job2-host"
printf 'host-only-outside-sentinel\n' > "$outside_sentinel"
printf 'protected-root-owned\n' > "$protected_dir/root-note.txt"
chmod 0755 "$protected_dir"
chmod 0644 "$protected_dir/root-note.txt"
"$cc_bin" -Wall -Wextra -std=c11 "$helper_src" -o "$helper_bin" >"$helper_compile_log" 2>"$helper_compile_err"
prepare_job_tree() {
job_name=$1
job_uid=$2
job_gid=$3
peer_uid=$4
peer_gid=$5
job_host=$workdir/$job_name-host
root=$job_host/root
work_mount=$job_host/work
peer_mount=$job_host/peer
mkdir -p "$root/tools" "$root/protected" "$root/work" "$root/peer" "$root/tmp" "$work_mount" "$peer_mount"
chmod 1777 "$root/tmp"
printf '%s-allowed\n' "$job_name" > "$work_mount/allowed.txt"
printf '%s-secret\n' "$job_name" > "$work_mount/secret.txt"
chmod 0600 "$work_mount/allowed.txt" "$work_mount/secret.txt"
chmod 0700 "$work_mount"
sudo chown -R "$job_uid:$job_gid" "$work_mount"
printf 'peer-for-%s\n' "$job_name" > "$peer_mount/secret.txt"
chmod 0600 "$peer_mount/secret.txt"
chmod 0700 "$peer_mount"
sudo chown -R "$peer_uid:$peer_gid" "$peer_mount"
for host_path in /lib /libexec; do
sudo mkdir -p "$root$host_path"
sudo mount_nullfs -o ro "$host_path" "$root$host_path"
record_mount "$root$host_path"
done
sudo mount_nullfs -o ro "$workdir/tools" "$root/tools"
record_mount "$root/tools"
sudo mount_nullfs "$work_mount" "$root/work"
record_mount "$root/work"
sudo mount_nullfs -o ro "$peer_mount" "$root/peer"
record_mount "$root/peer"
sudo mount_nullfs "$protected_dir" "$root/protected"
record_mount "$root/protected"
jail_name=fruix-privdrop-$job_name-$$
jail_id=$(sudo jail -i -c \
name="$jail_name" \
path="$root" \
host.hostname="$jail_name" \
persist \
ip4=disable \
ip6=disable)
record_jail "$jail_id"
printf '%s\n' "$jail_id" > "$job_host/jid"
}
prepare_job_tree job1 "$uid1" "$gid1" "$uid2" "$gid2"
prepare_job_tree job2 "$uid2" "$gid2" "$uid1" "$gid1"
jid1=$(cat "$workdir/job1-host/jid")
jid2=$(cat "$workdir/job2-host/jid")
start_epoch=$(date +%s)
sudo jexec "$jid1" /tools/freebsd-build-user-helper \
--job-name job1 \
--uid "$uid1" \
--gid "$gid1" \
--allowed-file /work/allowed.txt \
--own-output /work/result.txt \
--peer-file /peer/secret.txt \
--hidden-file /outside-sentinel \
--protected-file /protected/escape-job1 \
--hold-seconds "$hold_seconds" >"$job1_out" 2>"$job1_err" &
pid1=$!
sudo jexec "$jid2" /tools/freebsd-build-user-helper \
--job-name job2 \
--uid "$uid2" \
--gid "$gid2" \
--allowed-file /work/allowed.txt \
--own-output /work/result.txt \
--peer-file /peer/secret.txt \
--hidden-file /outside-sentinel \
--protected-file /protected/escape-job2 \
--hold-seconds "$hold_seconds" >"$job2_out" 2>"$job2_err" &
pid2=$!
set +e
wait "$pid1"
rc1=$?
wait "$pid2"
rc2=$?
set -e
end_epoch=$(date +%s)
elapsed=$((end_epoch - start_epoch))
if [ "$rc1" -ne 0 ] || [ "$rc2" -ne 0 ]; then
echo "freebsd privilege-drop prototype failed" >&2
cat "$job1_out" >&2 || true
cat "$job1_err" >&2 || true
cat "$job2_out" >&2 || true
cat "$job2_err" >&2 || true
exit 1
fi
sudo stat -f '%Su %Sg %u %g %Sp' "$workdir/job1-host/work/result.txt" > "$job1_stat"
sudo stat -f '%Su %Sg %u %g %Sp' "$workdir/job2-host/work/result.txt" > "$job2_stat"
cat > "$metadata_file" <<EOF
workdir=$workdir
helper_src=$helper_src
helper_bin=$helper_bin
helper_compile_log=$helper_compile_log
helper_compile_err=$helper_compile_err
jail1_id=$jid1
jail2_id=$jid2
build_uid1=$uid1
build_gid1=$gid1
build_uid2=$uid2
build_gid2=$gid2
hold_seconds=$hold_seconds
elapsed_seconds=$elapsed
job1_rc=$rc1
job2_rc=$rc2
job1_result_stat=$(cat "$job1_stat")
job2_result_stat=$(cat "$job2_stat")
job1_output_begin
$(cat "$job1_out")
job1_output_end
job2_output_begin
$(cat "$job2_out")
job2_output_end
EOF
if [ -n "${METADATA_OUT:-}" ]; then
mkdir -p "$(dirname "$METADATA_OUT")"
cp "$metadata_file" "$METADATA_OUT"
fi
printf 'PASS freebsd-privilege-drop-prototype\n'
printf 'Working directory: %s\n' "$workdir"
printf 'Metadata file: %s\n' "$metadata_file"
if [ -n "${METADATA_OUT:-}" ]; then
printf 'Copied metadata to: %s\n' "$METADATA_OUT"
fi
printf '%s\n' '--- job1 output ---'
cat "$job1_out"
printf '%s\n' '--- job2 output ---'
cat "$job2_out"
printf 'Elapsed seconds: %s\n' "$elapsed"
printf '%s\n' '--- metadata ---'
cat "$metadata_file"