# 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 viascanf("%32s", buf). - It installs a
SIGILLhandler. - It calls
mprotectto make part of.textwritable + executable (RWX). - It executes
ud2at 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:
- It grabs a pointer to the user buffer from the saved context.
- It applies ROT13 to the buffer in-place.
- 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: .
- Compute a 16-bit “mask”:
- 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:
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
.textfrom the ELF (viapyelftools), - slices the encrypted tail starting at
.text+0x1500, - scans for possible
E8 <rel32-to-0x1200>placements, - extracts
neededMaskconstraints per(block, word_index), - inverts them to recover
flag[word_index].
Running it prints the flag:
bashpython3 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:
bashdocker 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}
#!/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!