./contents.sh

z0d1ak@ctf:~$ cat sections.md
z0d1ak@ctf:~$ _
writeup.md - z0d1ak@ctf
Reverse Engineering
TSGCTF
December 21, 2025
4 min read

TSGCTF - medicine

theg1239
z0d1ak@ctf:~$ ./author.sh

# medicine (reversing)

## TL;DR

Flag: TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}

This challenge hides the flag check behind self-modifying code: it deliberately triggers SIGILL (ud2), and the signal handler decrypts the next chunk of code using your input as a key.

## Recon

Basic observations from a quick run / disassembly:

  • It prompts flag? and reads exactly 32 bytes via scanf("%32s", buf).
  • It installs a SIGILL handler.
  • It calls mprotect to make part of .text writable + executable (RWX).
  • It executes ud2 at a 0x40-aligned address to intentionally fault and enter the signal handler.

So the “real” logic is not executed directly; it’s decrypted on-demand inside the handler.

## What the SIGILL handler does

Inside the SIGILL handler, the program has access to the saved machine context (registers, RIP, etc.). The important parts:

  1. It grabs a pointer to the user buffer from the saved context.
  2. It applies ROT13 to the buffer in-place.
  3. It decrypts 0x40 bytes starting at the faulting RIP, word-by-word (16-bit words).

For each i in [0..31]:

  • Read one signed key byte from the (possibly ROT13’d) buffer: bib_i.
  • Compute a 16-bit “mask”:
extmask(bi)=(int8(bi)0x0DC5+0x3DE2)mod216 ext{mask}(b_i) = (\mathrm{int8}(b_i) \cdot 0x0DC5 + 0x3DE2) \bmod 2^{16}
  • XOR that into the ciphertext word at RIP + 2*i.

Finally, it increments saved RIP by 1 so execution resumes at ud2+1 (i.e., after the illegal opcode byte).

### Important consequence: the key alternates

Because the handler applies ROT13 to the input buffer on every SIGILL, the effective key bytes alternate each 0x40-byte block:

  • block 0 uses rot13(flag)
  • block 1 uses flag
  • block 2 uses rot13(flag)

## Key idea: recover the flag offline with a crib

Rather than single-stepping the self-modifying code, you can recover key bytes by using a known-plaintext “crib”.

There is a failure routine at .text+0x1200 that prints Wrong ;( and then _exit().
The decrypted code makes many relative calls to that function.

On x86-64, a near call is encoded as:

  • E8 <rel32>

Given the callsite address, the <rel32> is fixed, so for any candidate offset p in the encrypted region we know the exact 5 plaintext bytes that would represent call 0x1200.

Whenever those 5 bytes fully cover a 16-bit word inside a 0x40-byte block, we can compute the needed mask directly:

extneededMask=cipherWordplainWord ext{neededMask} = \text{cipherWord} \oplus \text{plainWord}

Then we invert the mask back into the key byte by precomputing reverse lookup tables:

  • even/“normal” blocks: mask(flag[i]) -> flag[i]
  • odd/“ROT13” blocks: mask(rot13(flag[i])) -> flag[i]

With enough call-site hits, this recovers indices 5..31. Indices 0..4 are the fixed prefix TSGCT in this challenge binary.

## Solver

Implementation: solve.py.

It:

  • parses .text from the ELF (via pyelftools),
  • slices the encrypted tail starting at .text+0x1500,
  • scans for possible E8 <rel32-to-0x1200> placements,
  • extracts neededMask constraints per (block, word_index),
  • inverts them to recover flag[word_index].

Running it prints the flag:

bash
python3 solve.py --bin medicine/medicine

## Verification

The binary is a Linux x86-64 ELF, so on macOS you’ll need a Linux environment.

If Docker is available, this verifies end-to-end:

bash
docker run --rm --platform linux/amd64 \ -v "$PWD":/work -w /work ubuntu:24.04 \ bash -lc 'chmod +x medicine/medicine && echo TSGCTF{51gn4l_h4ndl3r_r0t13_x0r} | ./medicine/medicine'

Expected output includes:

  • Correct :)
  • Here is your flag : TSGCTF{51gn4l_h4ndl3r_r0t13_x0r}
solve.sh - z0d1ak@ctf
#!/usr/bin/env python3
import argparse
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple

from elftools.elf.elffile import ELFFile


A = 0x0DC5
B = 0x3DE2


def rot13_byte(x: int) -> int:
    if 0x41 <= x <= 0x5A:
        return ((x - 0x41 + 13) % 26) + 0x41
    if 0x61 <= x <= 0x7A:
        return ((x - 0x61 + 13) % 26) + 0x61
    return x


def mask_for_byte(x: int) -> int:
    signed = x if x < 0x80 else x - 0x100
    return (signed * A + B) & 0xFFFF


def load_text_section(binary_path: str) -> Tuple[int, bytes]:
    with open(binary_path, "rb") as f:
        elf = ELFFile(f)
        text = elf.get_section_by_name(".text")
        if text is None:
            raise RuntimeError("ELF has no .text section")
        return int(text["sh_addr"]), text.data()


def u16_le(buf: bytes, off: int) -> int:
    return buf[off] | (buf[off + 1] << 8)


def call_rel32_bytes(callsite_addr: int, target_addr: int) -> bytes:
    rel = (target_addr - (callsite_addr + 5)) & 0xFFFFFFFF
    return bytes([0xE8]) + rel.to_bytes(4, "little")


@dataclass(frozen=True)
class Constraint:
    block_index: int
    word_index: int
    value: int


def recover_flag(binary_path: str) -> str:
    # These are stable in this challenge binary.
    encrypted_start = 0x1500
    fail_fn = 0x1200

    text_addr, text = load_text_section(binary_path)
    if encrypted_start < text_addr or encrypted_start >= text_addr + len(text):
        raise RuntimeError("encrypted region not inside .text")

    cipher = text[encrypted_start - text_addr :]

    # Precompute reverse maps mask -> byte for the two parities.
    # Parity detail: the SIGILL handler applies ROT13 to the input buffer
    # each time it's invoked, so key bytes alternate between ROT13(flag[i])
    # and flag[i] on each 0x40-aligned UD2 block.
    even_mask_to_byte: Dict[int, int] = {}
    odd_mask_to_byte: Dict[int, int] = {}
    for b in range(256):
        even_mask_to_byte[mask_for_byte(b)] = b
        odd_mask_to_byte[mask_for_byte(rot13_byte(b))] = b

    constraints: Dict[int, int] = {}

    # Scan for places where the decrypted bytes could be:
    #   E8 <rel32-to-0x1200>
    # and where those bytes fully cover at least one 16-bit word.
    for p in range(0, len(cipher) - 5):
        callsite = encrypted_start + p
        seq = call_rel32_bytes(callsite, fail_fn)

        # Map (block,word) -> {0: low_byte, 1: high_byte}
        by_word: Dict[Tuple[int, int], Dict[int, int]] = {}
        for j, plain_b in enumerate(seq):
            o = p + j
            block = o // 0x40
            within = o % 0x40
            word_index = within // 2
            lohi = within % 2
            by_word.setdefault((block, word_index), {})[lohi] = plain_b

        solved: List[Constraint] = []
        ok = True
        for (block, word_index), parts in by_word.items():
            if 0 not in parts or 1 not in parts:
                continue
            plain_w = parts[0] | (parts[1] << 8)
            cipher_w = u16_le(cipher, block * 0x40 + word_index * 2)
            need_mask = cipher_w ^ plain_w

            # Block 0 is the first SIGILL stage => odd parity (ROT13 applied).
            parity_is_odd = (block % 2) == 0
            key_byte = (odd_mask_to_byte if parity_is_odd else even_mask_to_byte).get(need_mask)
            if key_byte is None:
                ok = False
                break
            solved.append(Constraint(block, word_index, key_byte))

        if not ok or not solved:
            continue

        for c in solved:
            prev = constraints.get(c.word_index)
            if prev is not None and prev != c.value:
                raise RuntimeError(
                    f"conflicting constraints for flag[{c.word_index}]: {prev:#x} vs {c.value:#x}"
                )
            constraints[c.word_index] = c.value

    # In this binary, the CALL-to-fail cribs recover indices 5..31.
    # Indices 0..4 are the fixed prefix "TSGCT".
    flag_bytes: List[Optional[int]] = [None] * 32
    for i, ch in enumerate(b"TSGCT"):
        flag_bytes[i] = ch

    for idx, val in constraints.items():
        if 0 <= idx < 32:
            flag_bytes[idx] = val

    if any(v is None for v in flag_bytes):
        missing = [i for i, v in enumerate(flag_bytes) if v is None]
        raise RuntimeError(f"could not recover all bytes; missing indices: {missing}")

    return bytes(flag_bytes).decode("latin1")


def main() -> None:
    ap = argparse.ArgumentParser(description="Recover the medicine flag offline")
    ap.add_argument(
        "--bin",
        default="medicine/medicine",
        help="Path to the medicine ELF (default: medicine/medicine)",
    )
    args = ap.parse_args()

    flag = recover_flag(args.bin)
    print(flag)


if __name__ == "__main__":
    main()

Comments(0)

No comments yet. Be the first to share your thoughts!