Что-то новое
Ссылка на задание 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
Что-то новое
| Category | Reverse |
| Difficulty | Hard |
| Technique | Python bytecode · nibble-swap · dead code |
| Flag | flag{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 |
| Результат OR | 0011 0111 = 0x37 = '7' |
Decryption
| Шаг | Действие | Формула / команда |
|---|---|---|
| 1 | Снять base64-слой из task.py без запуска | regex + base64.b64decode |
| 2 | Извлечь marshal-payload | ast.literal_eval + .encode('latin1') |
| 3 | Загрузить code object с magic Python 2.7 | xdis.unmarshal.load_code |
| 4 | Читать хвост co_code[96:] — 26 байт данных | срез байтового массива |
| 5 | Nibble-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
Многослойная обфускация Python.
exec(base64.b64decode(...))снимается без запуска через regex. Marshal payload внутри требуетxdisс правильным magic-числом для Python 2.7 — стандартный Python 3 его не читает.Байткод как контейнер данных. Поле
co_codecode object’а хранит не только инструкции — автор может записать в него произвольные байты. Декомпилятор видит только исполняемую часть; аналитик, читающий сырой массив, видит всё.Ловушка задачи: правильный ввод ≠ флаг. Nibble-swap от первых 96 байт
co_codeвозвращает 96-символьную строку, которую checker принимает как верную. Большинство участников останавливались здесь. Настоящий флаг — в мёртвой зоне байткода, которую интерпретатор никогда не исполняет.
