Skip to main content

Что-то новое

Ссылка на задание http://f8tasks.ru/challenges#%D0%A7%D1%82%D0%BE-%D1%82%D0%BE%20%D0%BD%D0%BE%D0%B2%D0%BE%D0%B5?-12

Что-то новое

CategoryReverse
DifficultyHard
TechniquePython bytecode · nibble-swap · dead code
Flagflag{d1s_i5_n07_4_p4nac3a}

Recon

Все знают тебя как эксперта в области реверса, но ты вроде специализируешься на плюсах. Как думаешь, эта работа тебе по плечу?

Артефакты: task.py, analyze_payload.py, payload.marshal.

Главное правило реверса: не запускай незнакомый код без понимания того, что он делает. Сначала изучаем структуру:

file task.py
wc -c task.py
strings -a task.py | head -n 5
task.py: ASCII text, with very long lines (3586 characters)
3588 task.py
exec(__import__('base64').b64decode("cHJpbnQobGFtYmRhIGk..."))

Ключевые наблюдения:

  • task.py — однострочный exec(base64.b64decode(...)): типичный Python-лоадер первого уровня
  • Ключевые слова exec, base64, marshal — сигнал многослойной обфускации (запутывания кода)
  • Размер файла (~3.5 КБ) указывает на ненулевой payload внутри

Разворачиваем base64 как текст, но не исполняем:

python3 -c "
from pathlib import Path; import re, base64
text = Path('task.py').read_text()
m = re.search(r'b64decode\(\"([A-Za-z0-9+/=\n]+)\"\)', text)
print(base64.b64decode(m.group(1)).decode('latin1'))
"
print(lambda i:("Correct"if type(lambda:None)(i,{})(i,ord,raw_input("Flag: "))else "Incorrect"))(__import__('marshal').loads('c\x03\x00...'))

Ключевые наблюдения:

  • raw_input вместо input — это синтаксис Python 2, не Python 3
  • Реальная логика проверки флага спрятана в marshal.loads(...) — сериализованном code object’е
  • Стандартный декомпилятор Python 3 не прочитает Python 2 bytecode без специальных инструментов

Анализ Python-байткода

Python bytecode — набор низкоуровневых инструкций виртуальной машины CPython; каждый байт соответствует конкретной операции (opcode). Байткод можно читать напрямую как массив байт, не запуская программу.

Marshal — встроенный формат сериализации Python для code object’ов (функций, лямбд). marshal.loads() восстанавливает объект из байт, как pickle — только для кода.

Загружаем marshal-payload через xdis — библиотеку, которая умеет разбирать старые версии Python bytecode:

python3 -c "
from pathlib import Path; import re, base64, ast
from xdis.unmarshal import load_code; from xdis.magics import magic2int
text = Path('task.py').read_text()
outer = base64.b64decode(re.search(r'b64decode\(\"([A-Za-z0-9+/=\n]+)\"\)', text).group(1)).decode('latin1')
payload = ast.literal_eval(re.search(r'loads\((.+)\)\)\s*$', outer).group(1)).encode('latin1')
co = load_code(payload, magic2int(b'\x03\xf3\r\n'))
print('co_consts:', co.co_consts)
print('co_names:', co.co_names)
print('co_varnames:', co.co_varnames)
print('co_code len:', len(co.co_code))
"
co_consts: (96, 4, 255, False, True)
co_names: ('co_code',)
co_varnames: ('$', '$$', '$$$')
co_code len: 122

Ключевые наблюдения:

  • co_names = ('co_code',) — функция читает своё собственное поле co_code как данные. Код является одновременно и программой, и контейнером секрета
  • co_consts[0] = 96 — длина валидного ввода
  • len(co_code) = 122 = 96 + 26 — байткод делится на исполняемую часть (96 байт) и невидимый хвост (26 байт)

После ручного разбора опкодов логика checker выглядит так:

def check_flag(code_obj, ord_func, user_input):
    # берёт первые 96 байт собственного байткода
    transformed = [((b << 4) | (b >> 4)) & 0xff for b in code_obj.co_code[:96]]
    return (
        all(ord(ch) == transformed[i] for i, ch in enumerate(user_input))
        and len(user_input) == 96
    )

Nibble-swap — перестановка тетрад (4-битных половинок) байта: старшие 4 бита и младшие 4 бита меняются местами:

$$f(b) = ((b \ll 4) \mid (b \gg 4)) \;\&\; 255$$

Ручной расчёт на первом байте co_code[0] = 0x73:

ШагЗначение
Исходный байт0x73 = 0111 0011
Старшая тетрада 0111 → на место младшей0000 0111
Младшая тетрада 0011 → на место старшей0011 0000
Результат OR0011 0111 = 0x37 = '7'

Decryption

ШагДействиеФормула / команда
1Снять base64-слой из task.py без запускаregex + base64.b64decode
2Извлечь marshal-payloadast.literal_eval + .encode('latin1')
3Загрузить code object с magic Python 2.7xdis.unmarshal.load_code
4Читать хвост co_code[96:] — 26 байт данныхсрез байтового массива
5Nibble-swap каждого байта хвоста((b << 4) | (b >> 4)) & 0xff

Checker принимает строку длиной 96 символов и проверяет её против nibble-swap первых 96 байт co_code. Это корректный ввод — но не флаг.

Настоящий флаг находится в мёртвом хвосте co_code[96:122]. Интерпретатор никогда не исполняет эти байты: они идут после завершения логики функции. Но аналитик читает co_code как сырой массив байт — и именно там спрятан ответ:

tail = co.co_code[96:]
flag = "".join(chr(((b << 4) | (b >> 4)) & 0xff) for b in tail)
# → flag{d1s_i5_n07_4_p4nac3a}

Root cause — байткод как двойной контейнер:

# co_names = ('co_code',)   ← функция ссылается на своё поле .co_code
# co_code[:96]  — реальные opcode-инструкции + эталон для проверки ввода
# co_code[96:]  — dead bytes: интерпретатор их игнорирует, аналитик — нет

При анализе похожих задач:

  • Всегда сравнивай len(co_code) с реальной длиной инструкций — расхождение указывает на скрытый payload
  • Проверяй co_consts, co_names, co_varnames до декомпиляции: они раскрывают намерения автора
  • Если автоматический декомпилятор падает — переходи к ручному разбору опкодов: это подсказка, что ты у цели
  • Байты после RETURN_VALUE в co_code — всегда подозрительные

Automation

#!/usr/bin/env python3
import ast
import base64
import re
from pathlib import Path

from xdis.magics import magic2int
from xdis.unmarshal import load_code


def main() -> None:
    text = Path("task.py").read_text()

    # 1. Снимаем внешний base64-слой без запуска кода
    match = re.search(r'b64decode\("([A-Za-z0-9+/=\n]+)"\)', text)
    outer = base64.b64decode(match.group(1)).decode("latin1")

    # 2. Достаём marshal-payload как байты через ast.literal_eval (безопасно)
    match = re.search(r"loads\((.+)\)\)\s*$", outer)
    payload = ast.literal_eval(match.group(1)).encode("latin1")

    # 3. Загружаем code object: magic 0x03f3 соответствует Python 2.7
    co = load_code(payload, magic2int(b'\x03\xf3\r\n'))

    # 4. Хвост co_code[96:] — скрытый контейнер флага (dead bytes)
    tail = co.co_code[96:]

    # 5. Nibble-swap: меняем местами старшую и младшую тетрады каждого байта
    flag = "".join(chr(((b << 4) | (b >> 4)) & 0xff) for b in tail)
    print(flag)


if __name__ == "__main__":
    main()
python3 solve.py
# flag{d1s_i5_n07_4_p4nac3a}

Key Takeaways

  1. Многослойная обфускация Python. exec(base64.b64decode(...)) снимается без запуска через regex. Marshal payload внутри требует xdis с правильным magic-числом для Python 2.7 — стандартный Python 3 его не читает.

  2. Байткод как контейнер данных. Поле co_code code object’а хранит не только инструкции — автор может записать в него произвольные байты. Декомпилятор видит только исполняемую часть; аналитик, читающий сырой массив, видит всё.

  3. Ловушка задачи: правильный ввод ≠ флаг. Nibble-swap от первых 96 байт co_code возвращает 96-символьную строку, которую checker принимает как верную. Большинство участников останавливались здесь. Настоящий флаг — в мёртвой зоне байткода, которую интерпретатор никогда не исполняет.