← Back to Writeups
CUSTOMEasyReversing

Dve-piki

XESXOR9/2/20264 min read
#reversing#custom#easy#corrupted_pyc_header_recovery#python_version_fingerprinting_#manual_bytecode_disassembly#marshal_format_parsing#constraint_reconstruction_from

Dve-piki

Platform: CUSTOM | Category: Reversing | Type: Challenge | Difficulty: Easy | OS: N/A | **Author: XESXOR Techniques: corrupted_pyc_header_recovery, python_version_fingerprinting_from_bytecode, manual_bytecode_disassembly, marshal_format_parsing, constraint_reconstruction_from_opcodes

Summary

Walkthrough of the CUSTOM Reversing Dve-piki. 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,

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_ARGUMENT boundary 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:

  1. An outer code object (module level)
  2. An inner function code object starting at offset 0x5d — the flag checker function named a (aliased as spbctf)
  3. Raw bytecode of 398 bytes starting at offset 0x71
  4. A co_consts tuple at offset 0x1FF containing 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:

IndexValuePurpose
0None—
127Expected flag length
2'spbctf{'Required prefix
3'}'Required suffix
4–34alternating ints and charsPositional 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
def 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
#!/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}")

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering corrupted_pyc_header_recovery, python_version_fingerprinting_from_bytecode, manual_bytecode_disassembly, marshal_format_parsing, constraint_reconstruction_from_opcodes was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-07-05 • CUSTOM • Dve-piki • Difficulty: Easy