Tri-piki
Tri-piki
Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-05 | Status: Solved Techniques: charbychar_flag_reconstruction, code_object_inspection, manual_bytecode_analysis, nop_stripping_disassembly
Summary
Task: Python 2.7 .pyc file with NOP-padded bytecode that breaks standard decompilers. Solution: strip 0x09 NOP filler bytes between instructions, manually disassemble to reveal character-by-character flag comparison, reconstruct flag from positional checks.
Tri-piki — spbctf
Description
И заключительный этап - добавил кое-что еще
English summary: Final stage of a series ("Tri piki" = "Three spades/peaks", third in a series). A Python 2.7 byte-compiled file pyc_spbctf_3.n.pyc (1469 bytes) is provided. The hint "added something extra" refers to NOP bytes injected into the bytecode stream.
Analysis
Initial Recon
The file is identified as a Python 2.7 byte-compiled module. Standard decompilers (uncompyle6, decompyle3) immediately fail with a parse error at offset 0 — the bytecode stream is corrupted from the decompiler's perspective.
Identifying the Obfuscation
Hex dump of the bytecode reveals a systematic pattern: after every real bytecode instruction, a 0x09 byte (NOP opcode in Python 2.7) is inserted. This padding breaks decompilers because they expect instructions to follow each other contiguously without filler.
Pattern example from the hex dump:
64 00 00 09 64 01 00 09 6c 00 00 09 ...
Each real instruction (e.g., LOAD_CONST 0 = 64 00 00) is followed by a 09 NOP filler byte. The "added something extra" from the description is literally these extra NOP bytes.
Code Object Inspection
Using xdis.load.load_module() to unmarshal the Python 2.7 code object without executing it, the key metadata is extracted:
co_consts (constants pool):
python read-only • copy allowed(-1, None, 2, 'Bye!', 1, 25, 27, 'Bye-bye!', 26, 0, 's', 'p', 'b', 3, 'c', 4, 't', 5, 'f', 6, '{', 'Wow, but not', '}', 24, 'k', 'Bruh', 7, '0', 8, 9, 'h', 10, 'e', 11, 'n', 12, '_', 13, 'z', 14, 'l', 15, 16, 'j', 17, 18, '1', 19, 20, 21, '7', 22, '4', 23, 'Great!', 'Ahhh')
co_names (name references):
python read-only • copy allowed('sys', 'len', 'argv', 'exit', 'a', 'all', 'any')
The constants pool already leaks all individual flag characters along with their positional indices — a strong indicator of character-by-character validation.
Solution
Custom Disassembler
A custom Python 2.7 bytecode disassembler was written that:
- Walks opcodes using the
HAVE_ARGUMENT = 90threshold (opcodes >= 90 take a 2-byte argument) - Skips the
0x09NOP filler byte after each instruction - Produces a clean instruction listing
Reconstructed Logic
The disassembled program performs the following checks:
- Argument count:
len(sys.argv) == 2— requires exactly one command-line argument - Length check with decoys:
len(sys.argv[1]) == 25→ prints "Bye-bye!" (decoy)len(sys.argv[1]) == 27→ prints "Bye-bye!" (decoy)len(sys.argv[1]) == 26→ proceeds to validation (correct path)
- Stores input:
a = sys.argv[1] - Prefix validation via
all(): Checksa[0]='s', a[1]='p', a[2]='b', a[3]='c', a[4]='t', a[5]='f', a[6]='{' - Suffix validation via
any(): Checksa[25]='}', a[24]='k' - Character-by-character comparison for positions 7-24 using
COMPARE_OP ==:
| Position | Character | Position | Character |
|---|---|---|---|
| 0 | s | 13 | z |
| 1 | p | 14 | l |
| 2 | b | 15 | 0 |
| 3 | c | 16 | j |
| 4 | t | 17 | n |
| 5 | f | 18 | 1 |
| 6 | { | 19 | j |
| 7 | 0 | 20 | _ |
| 8 | c | 21 | 7 |
| 9 | h | 22 | 4 |
| 10 | e | 23 | s |
| 11 | n | 24 | k |
| 12 | _ | 25 | } |
- If all match → prints "Great!", otherwise "Ahhh"
Flag Assembly
Concatenating all 26 characters in order: spbctf{REDACTED}
This is leetspeak for "очень сложный таск" (Russian for "very hard task").