$ cat writeup.md…
$ cat writeup.md…
b01lersc
Task: a Rust jail lets us control the body of `pub fn jail(input: In) -> Out`, while the host embeds a random expected token and prints the flag only if program stdout matches it. Solution: read the generated ELF via `argv[0]`, recover the `reveal_token` return value from its machine code, print that token, and bypass `Out` construction entirely.
'Safe Rust is the true Rust programming language. If all you do is write Safe Rust, you will never have to worry about type-safety or memory-safety. You will never endure a dangling pointer, a use-after-free, or any other kind of Undefined Behavior (a.k.a. UB)' - Rustonomicon
We are given a remote service that compiles the body of pub fn jail(input: In) -> Out into a 32-bit Rust binary and runs it. At first glance the challenge looks like a “safe transmute” puzzle where we must somehow fabricate Out despite private wrapper types.
The real win condition is simpler: the Python wrapper prints the actual flag only when the program's stdout is exactly a random hidden token generated for that run. So the task is not “construct Out at all costs”; it is “make stdout equal the expected token by any safe-Rust-only route.”
The provided source in chall.py seeds an In, calls our jail(input), then passes the returned value into host::check(out). host::check compares a private-layout Out against internal expectations and prints the embedded token only if all fields match.
However, the outer Python script does one extra check after the binary exits: if the binary's stdout equals the per-run token, it prints the flag. That means we can ignore the nominal type puzzle and instead recover the token directly from the generated executable.
The important logic from tasks/b01lersc/blazinglyfast/tmpdist/chall.py is:
reveal_token() -> &'static str.pub fn jail(input: In) -> Out.In and Out wrap private inner structs, so direct construction of Out is intentionally blocked.0o111, and runs it.That last point completely changes the problem. We do not actually need a valid Out; we only need the token.
The local source claims the validator rejects unsafe, extern, trait, impl, std, #, and !. In practice, the live service really did reject unsafe, extern, trait, impl, and !, but std usage and # attributes/comments were accepted during exploitation.
I treat this as an observed deployment discrepancy, not a guaranteed property of the source tree. The solve used the live behavior.
The first idea was the classic “totally safe transmute” direction: if a soundness bug or weird layout trick lets us reinterpret In as Out, host::check would print the token for us.
I also tried a /proc/self/mem style route to inspect the running image and private data without unsafe code. That failed because /proc was not mounted inside the jail.
Other useful probes established the environment:
/ contained /app,/bin,/dev,/etc,/lib,/lib32,/lib64,/tmp,/usr/dev only exposed null, zero, and urandom/tmp was writable but not persistent between connections1000env::current_exe() failed because /proc/self/exe was unavailableOperation not permittedSo the usual Linux self-inspection shortcuts were gone.
argv[0]env::args().next() revealed the actual generated binary path, for example /tmp/rust_jail_xxx/chall32.
Reading that file immediately failed with Permission denied, because the wrapper had already changed it to execute-only (0o111). But the running process owns the file, so safe Rust can simply do:
fs::set_permissions(&a0, fs::Permissions::from_mode(0o700)).ok();
After that, fs::read(&a0) succeeds. This is the key pivot: the challenge becomes pure self-introspection of our own ELF.
reveal_tokenFrom safe Rust, I invoked system tools on my own binary with std::process::Command:
nm -an to find the symbol address of reveal_tokenreadelf -SW to map virtual addresses to file offsets for .text and .rodataobjdump/raw byte parsing to understand how reveal_token returns its stringOn i686, objdump showed a stub like:
a270: call next a275: pop eax a276: mov edx,0x20 a27b: add eax,0x58b73 a281: lea eax,[eax-0x14db8] a287: ret
For a Rust &'static str return on 32-bit, eax holds the pointer and edx holds the length. So this function directly returns the embedded expected token from .rodata, and the token length here is 0x20 == 32 bytes.
Once the symbol VMA and section bases are known:
reveal_token VMA into a file offset inside .text.mov edx, imm32 → returned string lengthadd eax, imm32 → PIC addendlea eax, [eax+disp32] → final displacementstr_vma = sym_vma + 5 + add + disp
The + 5 comes from the call/pop PIC pattern: after call next, the popped value is the address of the next instruction.
str_vma into a file offset using .rodata VMA and file offset.len bytes from the ELF and write them to stdout.At that point the Python wrapper sees stdout equal to the hidden token and prints the flag.
Full working payload body from tasks/b01lersc/blazinglyfast/final_exploit.txt:
use std::{env,fs,process::Command,io::Write}; use std::os::unix::fs::PermissionsExt; fn hx(s: &str) -> usize { usize::from_str_radix(s, 16).unwrap() } fn u32le(b: &[u8]) -> usize { u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize } fn i32le(b: &[u8]) -> isize { i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as isize } let a0 = env::args().next().unwrap_or_default(); fs::set_permissions(&a0, fs::Permissions::from_mode(0o700)).ok(); let data = fs::read(&a0).unwrap(); let nm = Command::new("/bin/nm").args(["-an", &a0]).output().unwrap(); let nm_s = String::from_utf8_lossy(&nm.stdout); let mut sym_vma = 0usize; for line in nm_s.lines() { if line.contains("reveal_token") { let p: Vec<&str> = line.split_whitespace().collect(); if p.len() >= 3 { sym_vma = hx(p[0]); break; } } } let re = Command::new("/bin/readelf").args(["-SW", &a0]).output().unwrap(); let re_s = String::from_utf8_lossy(&re.stdout); let mut text_vma = 0usize; let mut text_off = 0usize; let mut rod_vma = 0usize; let mut rod_off = 0usize; for line in re_s.lines() { let p: Vec<&str> = line.split_whitespace().collect(); if p.len() >= 6 && p[1] == ".text" { text_vma = hx(p[3]); text_off = hx(p[4]); } if p.len() >= 6 && p[1] == ".rodata" { rod_vma = hx(p[3]); rod_off = hx(p[4]); } } let sym_off = text_off + sym_vma - text_vma; let f = &data[sym_off..sym_off + 24]; let len = u32le(&f[7..11]); let add = u32le(&f[13..17]) as isize; let disp = i32le(&f[19..23]); let str_vma = sym_vma as isize + 5 + add + disp; let str_off = rod_off + (str_vma as usize - rod_vma); let token = &data[str_off..str_off + len]; let mut out = std::io::stdout(); out.write_all(token).unwrap(); out.flush().ok(); std::process::exit(0);
The challenge framing pushes us toward type construction: how can safe Rust create a valid Out when its inner representation is private? But the outer wrapper accidentally provides a stronger primitive than Out construction: it rewards any program whose stdout equals a secret embedded in the binary.
So the exploit completely sidesteps the nominal Rust type barrier. We never construct Out, never call host::check successfully, and never need a safe transmute bug. We just read our own executable, recover the constant returned by reveal_token, print it, and let the wrapper hand us the flag.
The execute-only chmod was also not a real barrier because file ownership remained with the running process. In safe Rust, set_permissions is enough to re-enable reads. Once the ELF is readable, the token is just data-flow through a tiny PIC function.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar