miscfreehard

rustjail

b01lersc

Task: a remote Rust jail compiles attacker code under a harsh substring blacklist, hiding the flag as a randomized file in the working directory. Solution: use test::TestOpts.logfile to reach safe PathBuf procfs primitives, find the parent shell's open runner.sh FD, then overwrite the script so dash resumes into `cat flag_*.txt` at the old EOF offset.

$ ls tags/ techniques/
blacklist_bypasstestopts_pathbuf_indirectionprocfs_reconfile_descriptor_script_overwriteoffset_aligned_script_injection

rustjail — b01lers CTF 2026

Description

The original organizer prompt was not preserved in the provided files; the challenge was solved through the remote Rust jail service and local notes.

The service accepted a Rust payload over ncat --ssl, wrote it into src/main.rs, and executed cargo run with nightly 2025-09-25 and edition 2024. The goal was to escape the restrictions, discover the randomized flag_<32hex>.txt in the temporary working directory, and print its contents.

Overview / Challenge Setup

At runtime the working directory looked like /tmp/tmp_x/src and contained:

  • Cargo.toml
  • runner.sh
  • wrapper.py
  • get_payload.py
  • cargo_home/
  • src/main.rs
  • flag_<32hex>.txt

The filter was a simple Python substring blacklist over the Rust source. These raw substrings were banned:

unsafe, path, flag, std, alloc, core, include, impl, concat, macro, <, >, '

This is much weaker than an AST-based filter. It blocks exact text, not semantics, so string escapes like "\x66lag_*.txt" still produce the forbidden string at runtime.

Restriction Analysis

The obvious read-file routes were cut off:

  • std::fs::* was impossible because std was blacklisted.
  • FFI through libc::open was blocked because edition 2024 requires unsafe extern, and unsafe was blacklisted.
  • include_* and #[path] were also blocked directly.

The important positive discovery was that extern crate test; and #![feature(test)] still worked. That gave access to test::TestOpts, test::TestDescAndFn, and test::test_main.

Two key primitives came out of that:

  1. Blacklist bypass inside strings. Raw source checks can be dodged with escapes such as "\x66lag_*.txt".
  2. Indirect PathBuf construction. test::TestOpts.logfile has type Option<PathBuf>, so writing
let p = test::TestOpts { logfile: Some("/proc/self/exe".into()), ..opts }.logfile.unwrap();

produced a safe path object without ever writing banned tokens like std or path in source. From there, safe methods such as read_link, read_dir, join, and metadata became available.

Recon via TestOpts.logfile and procfs

Using that indirect PathBuf, procfs reconnaissance revealed the real execution model:

  • /proc/self/exe -> /tmp/tmp_x/src/target/debug/rustjail
  • Process tree on remote:
    • PID 1: python3
    • PID 2: /usr/bin/dash
    • PID 7: our compiled binary
  • PID 2 had FD 10 -> /tmp/tmp_x/src/runner.sh

That immediately suggested a better target than the executable itself: the parent shell script that launched cargo run and was still open in dash.

Key Failed Idea: self-overwriting the executable

The first serious exploitation idea was to overwrite target/debug/rustjail and then let the next spawned child execute modified content. On the real service that failed with:

Os { code: 26, kind: ExecutableFileBusy, message: "Text file busy" }

So direct self-overwrite hit ETXTBSY and was a dead end.

Final Exploit Chain

The winning pivot was to target runner.sh instead of target/debug/rustjail.

Why runner.sh worked

test::test_main --list --logfile <path> writes test output to the chosen file. Pointing --logfile at /proc/2/fd/10 effectively opened the parent shell's already-open runner.sh for writing. The crucial behavior was:

  • the underlying runner.sh was truncated and rewritten from byte 0;
  • the parent dash process kept its original open file descriptor and read position;
  • after cargo run returned, dash continued reading from that old descriptor.

The original remote runner.sh size was 372 bytes. That meant the shell would resume reading at about the old EOF. So the rewritten file had to be longer than 372 bytes, with a valid shell command landing exactly where dash would continue.

Offset 372 trick

test::test_main --list writes lines beginning with test . The final payload exploited that prefix: it used one giant first test name so that after the five-byte prefix, cat flag_*.txt started at the required location near byte offset 372. Two extra fallback lines of x;cat flag_*.txt were appended in case alignment drifted.

Once our Rust program exited, the parent shell resumed execution inside attacker-controlled bytes past the old EOF and ran cat flag_*.txt. The flag printed three times: once from the aligned hit and twice from the backup lines.

Final Exploit Code

This was the final working payload from exploit_runner_offset.rs:

#![feature(test)] extern crate test; fn main() { let a = test::TestDescAndFn { desc: test::TestDesc { name: test::DynTestName( format!("{}cat \x66lag_*.txt", "x".repeat(367)), ), ignore: false, ignore_message: None, source_file: file!(), start_line: line!() as _, start_col: 0, end_line: line!() as _, end_col: 0, should_panic: test::ShouldPanic::No, compile_fail: false, no_run: false, test_type: test::TestType::Unknown, }, testfn: test::DynTestFn(Box::new(|| Ok(()))), }; let b = test::TestDescAndFn { desc: test::TestDesc { name: test::DynTestName("x;cat \x66lag_*.txt".to_string()), ignore: false, ignore_message: None, source_file: file!(), start_line: line!() as _, start_col: 0, end_line: line!() as _, end_col: 0, should_panic: test::ShouldPanic::No, compile_fail: false, no_run: false, test_type: test::TestType::Unknown, }, testfn: test::DynTestFn(Box::new(|| Ok(()))), }; let c = test::TestDescAndFn { desc: test::TestDesc { name: test::DynTestName("x;cat \x66lag_*.txt".to_string()), ignore: false, ignore_message: None, source_file: file!(), start_line: line!() as _, start_col: 0, end_line: line!() as _, end_col: 0, should_panic: test::ShouldPanic::No, compile_fail: false, no_run: false, test_type: test::TestType::Unknown, }, testfn: test::DynTestFn(Box::new(|| Ok(()))), }; let v = vec![ "x".to_string(), "--list".to_string(), "--logfile".to_string(), "runner.sh".to_string(), ]; test::test_main(&v, vec![a, b, c], None); }

Solution Summary

  1. Send a safe Rust payload to the remote service.
  2. Use test::TestOpts.logfile to obtain a safe PathBuf indirectly.
  3. Traverse procfs and discover that the parent dash process holds runner.sh open as FD 10.
  4. Confirm that self-overwriting target/debug/rustjail fails with ETXTBSY.
  5. Use test::test_main --list --logfile runner.sh to rewrite the script instead.
  6. Make the new script longer than 372 bytes and place cat flag_*.txt exactly where the shell resumes reading.
  7. Let cargo run finish; the parent shell continues into the injected command and prints the randomized flag file.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups