Dve-piki
Dve-piki
Platform: spbctf | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-07-05 | Status: Solved Techniques: corrupted_pyc_header_recovery, python_version_fingerprinting_from_bytecode, manual_bytecode_disassembly, marshal_format_parsing, constraint_reconstruction_from_opcodes
Summary
Task: corrupted .pyc file with wiped header bytes, Python 3.5 bytecode flag checker. Solution: fingerprint Python version from opcode format, manually disassemble bytecode and parse marshal co_consts, reconstruct flag from positional character equality constraints.
Dve-piki — spbctf
Description
И снова привет! Потер еще больше
English summary: A .pyc file (pyc_spbctf_2.pyc, 1043 bytes) is provided, downloaded from https://pwn.spbctf.ru/files/ny/pyc_spbctf_2.pyc. The file has its header completely wiped, making standard decompilation impossible. The task name "Dve piki" (Две пики) means "Two peaks" in Russian — a hint at the vv (two chevrons/peaks) in the flag. "Потер еще больше" means "Wiped even more" — referring to the erased .pyc header.
Analysis
Identifying the corruption
The file command reports the .pyc as just "data" — the standard 8-byte header (4-byte magic number + 4-byte timestamp) is completely wiped. The file starts directly with the marshal TYPE_CODE byte 0x73 (s) instead of the expected header.
Python version fingerprinting
Without the magic number, the Python version must be determined from the bytecode format itself:
- 3-byte instructions with 2-byte opargs (wordcode format)
HAVE_ARGUMENTboundary at opcode 90- Opcodes like
LOAD_CONST(100),CALL_FUNCTION(131),COMPARE_OP(107) match the Python 3.5 opcode table exactly
This is significant because Python 3.5 was the last version before the wordcode format changed in 3.6 (to 2-byte instructions with EXTENDED_ARG).
Bytecode structure
The file contains:
- An outer code object (module level)
- An inner function code object starting at offset
0x5d— the flag checker function nameda(aliased asspbctf) - Raw bytecode of 398 bytes starting at offset
0x71 - A
co_conststuple at offset0x1FFcontaining 37 constants
Solution
Step 1: Manual bytecode disassembly
Since no Python 3.5 interpreter was available locally, a custom disassembler was built using the Python 3.5 opcode table. The 398 bytes of raw bytecode were decoded into readable instructions.
Step 2: Extract co_consts
The marshal TYPE_TUPLE at offset 0x1FF was parsed, yielding 37 constants:
| Index | Value | Purpose |
|---|---|---|
| 0 | None | — |
| 1 | 27 | Expected flag length |
| 2 | 'spbctf{' | Required prefix |
| 3 | '}' | Required suffix |
| 4–34 | alternating ints and chars | Positional character constraints |
| 35 | 'Yay!' | Success message |
| 36 | 'Harder, plz' | Failure message |
| |
Step 3: Reconstruct the checker logic
The disassembled bytecode implements a flag checker with these constraints:
python read-only • copy alloweddef check(flag): if len(flag) != 27: return False if not flag.startswith('spbctf{'): return False if not flag.endswith('}'): return False # Positional character checks: # flag[7] == 'v', flag[8] == 'v' # flag[9] == 'H', flag[10] == 'y' # flag[11] == '_', flag[12] == 's', flag[13] == '0' # flag[14] == '_', flag[15] == 'U', flag[16] == 'n' # flag[17] == '3', flag[18] == '4' # flag[19] == 'Z', flag[20] == 'y' # flag[21] == '_', flag[22] == '_' # flag[23] == 'T', flag[24] == '_', flag[25] == 'T' # flag[26] == '}' (already checked by endswith) return True
Step 4: Assemble the flag
All 27 positions are uniquely determined by the constraints:
Position: 0123456 7890123456789012345 6
Content: spbctf{ REDACTED }
The flag reads as leetspeak: "why so uneasy" with a T_T crying emoticon at the end.
Step 5: Verification
python read-only • copy allowed#!/usr/bin/env python3 flag = "spbctf{REDACTED}" # Length check assert len(flag) == 27 # Prefix/suffix assert flag.startswith('spbctf{') assert flag.endswith('}') # Positional checks checks = { 7: 'v', 8: 'v', 9: 'H', 10: 'y', 11: '_', 12: 's', 13: '0', 14: '_', 15: 'U', 16: 'n', 17: '3', 18: '4', 19: 'Z', 20: 'y', 21: '_', 22: '_', 23: 'T', 24: '_', 25: 'T' } for idx, char in checks.items(): assert flag[idx] == char, f"Mismatch at {idx}: expected {char}, got {flag[idx]}" print("All checks passed!") print(f"Flag: {flag}")