HITCON 2016 REVERSE 50 — HANDCRAFTED PYC

Alexandr Vishniakov
Hackerstan CTF Team
7 min readNov 24, 2016

--

Условие задачи:

Handcrafted pyc
XXX Teams solved.
Description
Can your brain be a Python VM? (Please use Python 2.7)
crackme
Hint
None

И нашему вниманию, представляется crackme.py - файл следующего содержимого:

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import marshal, zlib, base64exec(marshal.loads(zlib.decompress(base64.b64decode('eJyNVktv00AQXm/eL0igiaFA01IO4cIVCUGFBBJwqRAckLhEIQmtRfPwI0QIeio/hRO/hJ/CiStH2M/prj07diGRP43Hs9+MZ2fWMxbnP6mux+oK9xVMHPFViLdCTB0xkeKDFEFfTIU4E8KZq8dCvB4UlN3hGEsdddXU9QTLv1eFiGKGM4cKUgsFCNLFH7dFrS9poayFYmIZm1b0gyqxMOwJaU3r6xs9sW1ooakXuRv+un7Q0sIlLVzOCZq/XtsK2oTSYaZlStogXi1HV0iazoN2CV2HZeXqRQ54TlJRb7FUlKyUatISsdzo+P7UU1Gb1POdMruckepGwk9tIXQTftz2yBaT5JQovWvpSa6poJPuqgao+b9l5Aj/R+mLQIP4f6Q8Vb3g/5TB/TJxWGdZr9EQrmn99fwKtTvAZGU7wzS7GNpZpDm2JgCrr8wrmPoo54UqGampFIeS9ojXjc4E2yI06bq/4DRoUAc0nVnng4k6p7Ks0+j/S8z9V+NZ5dhmrJUM/y7JTJeRtnJ2TSYJvsFq3CQt/vnfqmQXt5KlpuRcIvDAmhnn2E0t9BJ3SvB/SfLWhuOWNiNVZ+h28g4wlwUp00w95si43rZ3r6+fUIEdgOZbQAsyFRRvBR6dla8KCzRdslar7WS+a5HFb39peIAmG7uZTHVm17Czxju4m6bayz8e7J40DzqM0jr0bmv9PmPvk6y5z57HU8wdTDHeiUJvBMAM4+0CpoAZ4BPgJeAYEAHmgAUgAHiAj4AVAGORtwd4AVgC3gEmgBBwCPgMWANOAQ8AbwBHgHuAp4D3gLuARwoGmNUizF/j4yDC5BWM1kNvvlxFA8xikRrBxHIUhutFMBlgQoshhPphGAXe/OggKqqb2cibxwuEXjUcQjccxi5eFRL1fDSbKrUhy2CMb2aLyepkegDWsBwPlrVC0/kLHmeCBQ=='))))

Сразу становится понятно, что тут используется сериализованный код python. Нам необходимо получить pycфайл, для того чтобы воспользоваться средствами декомпиляции, в читаемый, исходный код.

Воспользуемся следующим скриптом, чтобы превратить наш сериализованный код в pyc файл.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import marshal, zlib, base64, time, py_compilecode = marshal.loads(zlib.decompress(base64.b64decode('eJyNVktv00AQXm/eL0igiaFA01IO4cIVCUGFBBJwqRAckLhEIQmtRfPwI0QIeio/hRO/hJ/CiStH2M/prj07diGRP43Hs9+MZ2fWMxbnP6mux+oK9xVMHPFViLdCTB0xkeKDFEFfTIU4E8KZq8dCvB4UlN3hGEsdddXU9QTLv1eFiGKGM4cKUgsFCNLFH7dFrS9poayFYmIZm1b0gyqxMOwJaU3r6xs9sW1ooakXuRv+un7Q0sIlLVzOCZq/XtsK2oTSYaZlStogXi1HV0iazoN2CV2HZeXqRQ54TlJRb7FUlKyUatISsdzo+P7UU1Gb1POdMruckepGwk9tIXQTftz2yBaT5JQovWvpSa6poJPuqgao+b9l5Aj/R+mLQIP4f6Q8Vb3g/5TB/TJxWGdZr9EQrmn99fwKtTvAZGU7wzS7GNpZpDm2JgCrr8wrmPoo54UqGampFIeS9ojXjc4E2yI06bq/4DRoUAc0nVnng4k6p7Ks0+j/S8z9V+NZ5dhmrJUM/y7JTJeRtnJ2TSYJvsFq3CQt/vnfqmQXt5KlpuRcIvDAmhnn2E0t9BJ3SvB/SfLWhuOWNiNVZ+h28g4wlwUp00w95si43rZ3r6+fUIEdgOZbQAsyFRRvBR6dla8KCzRdslar7WS+a5HFb39peIAmG7uZTHVm17Czxju4m6bayz8e7J40DzqM0jr0bmv9PmPvk6y5z57HU8wdTDHeiUJvBMAM4+0CpoAZ4BPgJeAYEAHmgAUgAHiAj4AVAGORtwd4AVgC3gEmgBBwCPgMWANOAQ8AbwBHgHuAp4D3gLuARwoGmNUizF/j4yDC5BWM1kNvvlxFA8xikRrBxHIUhutFMBlgQoshhPphGAXe/OggKqqb2cibxwuEXjUcQjccxi5eFRL1fDSbKrUhy2CMb2aLyepkegDWsBwPlrVC0/kLHmeCBQ==')))with open('crackme.pyc', 'wb') as fc:
fc.write('\0\0\0\0')
py_compile.wr_long(fc, long(time.time()))
marshal.dump(code, fc)
fc.flush()
fc.seek(0, 0)
fc.write(py_compile.MAGIC)

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

root@kali:~/HITCON# python crackme.pyc
password:
Wrong password... Please try again. Do not brute force. =)
root@kali:~/HITCON#

На запрос пароля нажимаем Enter и видим сообщение о неверном пароле и совете не брутить его.

Теперь немного о структуре PYC файла:

  • Первые 8 байт это заголовок:
  • 4 байта MAGIC число символизирующее версию Python, для корректной десериализации и последуюшего испольнения кода
  • 4 байта timestamp
  • Далее следует сериализованный код

Приступим к декомпиляции, воспользуемся инструментом uncompyle6 и выполним команду

uncompyle6 -o . crackme.pyc

В итоге, получаем сообщение, что все ок! Декомпиляция удалась!

2199  CALL_FUNCTION_1       1 
2202 ROT_TWO
2203 BINARY_ADD
2204 ROT_TWO
2205 BINARY_ADD
2206 ROT_TWO
2207 BINARY_ADD
2208 BINARY_ADD
2209 BINARY_ADD
2210 BINARY_ADD
2211 BINARY_ADD
2212 PRINT_ITEM
<string>
Successfully decompiled file

Смотрим полученный в ходе декомпиляции файл crackme.py и видим, что что-то пошло не так, так как код не декомпилировался полностью и это не радует. Вот такой резульатат в итоге:

2202  ROT_TWO          
2203 BINARY_ADD
2204 ROT_TWO
2205 BINARY_ADD
2206 ROT_TWO
2207 BINARY_ADD
2208 BINARY_ADD
2209 BINARY_ADD
2210 BINARY_ADD
2211 BINARY_ADD
2212 PRINT_ITEM
2213 PRINT_NEWLINE_CONT
Parse error at or near `ROT_TWO' instruction at offset 36
if __name__ == '__main__':
main()

Следующая ошибка встречает наc Parse error at or near `ROT_TWO' instruction at offset 36 . Сравним, полученные при выводе опкоды, воспользовавшись стандартным пакетом, получаем следующий вывод:

1           0 LOAD_CONST               1 (<code object main at 0x7f08a0a0f830, file "<string>", line 1>)
3 MAKE_FUNCTION 0
6 STORE_NAME 0 (main)
4 9 LOAD_NAME 1 (__name__)
12 LOAD_CONST 2 ('__main__')
15 COMPARE_OP 2 (==)
18 POP_JUMP_IF_FALSE 31
5 21 LOAD_NAME 0 (main)
24 CALL_FUNCTION 0
27 POP_TOP
28 JUMP_FORWARD 0 (to 31)
>> 31 LOAD_CONST 0 (None)
34 RETURN_VALUE
None

Видим, что у нас первая инструкция - это функция main, которая содержит, судя по всему, основной код. Эта функция, вызывается при запуске модуля. Для закрепления проделанных выводов, используем конструкцию print dis.dis(code), где code - переменная содержащая результат marshal.loads()

Теперь, нам нужно посмотреть опкоды функции main, для этого, также воспользуемся стандартным пакетом dis.dis(code.co_consts[1]) и индекс тут 1, потому что, если вывести значение co_consts, то получится:

(None, <code object main at 0x7fb3e7ff6830, file "<string>", line 1>, '__main__')

Код абсолютно идентичен, значит проблема в инструменте декомпиляции. Начинается самая интересная часть — чтение опкодов.

Находим в распечатанных опкодах следующее место:

2212 PRINT_ITEM                            47
2213 PRINT_NEWLINE 48
2214 LOAD_CONST 0 (None) 64 00 00
2217 RETURN_VALUE 53

Справа от инструкций, я вставил их шестнадцатеричные коды, то есть коды, которое различны, для разных версий питона. В частности, у меня версия 2.7.12. Все объявления, расположены по следующему пути /usr/include/python2.7/opcode.h . Слева от называний опкодов, указано их смещение. Заметим, что из 4 опкодов самый длинный LOAD_CONST #define LOAD_CONST 100 /* Index in const list */ и занимает — 3 байта. Первый байт 64 - это сам опкод инструкции, а следующие два байта это аргументы, т.е. индекс константы находящейся в листе констант. По индексу там будет находиться строка с предупреждением о том, что пароль не верен.

Теперь таким образом, нам нужно вставить инструкцию, в позицию 2212, перед опкодом PRINT_ITEM. Python — это язык, выполнение которого основано на стеке, то мы сможем вывести при помощи опкода LOAD_FAST, который тоже содержит 3 байта define LOAD_FAST 124 /* Local variable number */, значение локальной переменной password . Она находится под индексом 0.

>>> main_fn = code.co_consts[1]
>>> dir(main_fn)
['__class__', '__cmp__', '__delattr__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames']
>>> main_fn.co_varnames
('password',)
>>> main_fn.co_nlocals
1
>>>

Но вот просто взять и пропатчить объект code, в Python - нельзя, так как объект этого класса, является immutable (неизменяемым), т.е. не может быть изменен. Но есть идея - это прежде всего, обернуть в новый класс существующий code объект, c копированием всех значений свойств:

['co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames']

Для этого создадим класс:

class MutableCodeObject(object):
args_name = ('co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames')
def __init__(self, initial_code):
self.initial_code = initial_code
for attr_name in self.args_name:
attr = getattr(self.initial_code, attr_name)
setattr(self, attr_name, attr)
def get_code(self):
return self.initial_code.__class__(self.co_argcount, self.co_nlocals, self.co_stacksize, self.co_flags, self.co_code, self.co_consts, self.co_names, self.co_varnames, self.co_filename, self.co_name, self.co_firstlineno, self.co_lnotab, self.co_freevars, self.co_cellvars)

Теперь, мы можем в конструктор этого класса MutableCodeObject, передать в качестве параметра initial_code, существующий объект кода нашей функции main. Далее, после модификации значения в co_code, мы можем получить новый объект code, вызывав метод get_code(). Метод вернет новый объект code, через передачу параметров в конструктор класса.

Но для того, чтобы вставить новую инструкцию в код, нам нужно найти место, можно воспользоваться смещением, но раз мы взялись за чтение опкодов, то можем составить последовательность байт, перед которыми нам нужно вставить инструкцию.

2212 PRINT_ITEM                            47
2213 PRINT_NEWLINE 48
2214 LOAD_CONST 0 (None) 64 00 00
2217 RETURN_VALUE 53

Cоставляем шестнадцатеричную строку “474864000053” для поиска. Получается следующий код:

bcode = zlib.decompress(base64.b64decode('eJyNVktv00AQXm/eL0igiaFA01IO4cIVCUGFBBJwqRAckLhEIQmtRfPwI0QIeio/hRO/hJ/CiStH2M/prj07diGRP43Hs9+MZ2fWMxbnP6mux+oK9xVMHPFViLdCTB0xkeKDFEFfTIU4E8KZq8dCvB4UlN3hGEsdddXU9QTLv1eFiGKGM4cKUgsFCNLFH7dFrS9poayFYmIZm1b0gyqxMOwJaU3r6xs9sW1ooakXuRv+un7Q0sIlLVzOCZq/XtsK2oTSYaZlStogXi1HV0iazoN2CV2HZeXqRQ54TlJRb7FUlKyUatISsdzo+P7UU1Gb1POdMruckepGwk9tIXQTftz2yBaT5JQovWvpSa6poJPuqgao+b9l5Aj/R+mLQIP4f6Q8Vb3g/5TB/TJxWGdZr9EQrmn99fwKtTvAZGU7wzS7GNpZpDm2JgCrr8wrmPoo54UqGampFIeS9ojXjc4E2yI06bq/4DRoUAc0nVnng4k6p7Ks0+j/S8z9V+NZ5dhmrJUM/y7JTJeRtnJ2TSYJvsFq3CQt/vnfqmQXt5KlpuRcIvDAmhnn2E0t9BJ3SvB/SfLWhuOWNiNVZ+h28g4wlwUp00w95si43rZ3r6+fUIEdgOZbQAsyFRRvBR6dla8KCzRdslar7WS+a5HFb39peIAmG7uZTHVm17Czxju4m6bayz8e7J40DzqM0jr0bmv9PmPvk6y5z57HU8wdTDHeiUJvBMAM4+0CpoAZ4BPgJeAYEAHmgAUgAHiAj4AVAGORtwd4AVgC3gEmgBBwCPgMWANOAQ8AbwBHgHuAp4D3gLuARwoGmNUizF/j4yDC5BWM1kNvvlxFA8xikRrBxHIUhutFMBlgQoshhPphGAXe/OggKqqb2cibxwuEXjUcQjccxi5eFRL1fDSbKrUhy2CMb2aLyepkegDWsBwPlrVC0/kLHmeCBQ=='))mut_code = MutableCodeObject(marshal.loads(bcode))
hex_bcode = mut_code.co_code.encode('hex')
main_func = MutableCodeObject(mut_code.co_consts[1])
main_func_hex = main_func.co_code.encode('hex')
idx1 = main_func_hex.index("474864000053")
print idx1

Выше видим код, для получения индекса начала последовательности инструкций. Теперь нужно вставить инструкцию LOAD_FAST 0 (password) 7C 00 00, чтобы отобразить значение локальной переменной password, по индексу 0, получается три байта 7C 00 00 . Теперь вставляем эту инструкцию:

mycode = ""
left = main_func_hex[:idx1]
'''
--------------------------------------------------------
LOAD_FAST 0 (password) 7C 00 00
--------------------------------------------------------
'''
load_fast = "7C0000"
right = main_func_hex[idx1:]
mycode = left + load_fast + right
main_func.co_code = mycode.decode('hex')
exec(main_func.get_code())

Запускаем наш получившийся файл на исполнение и после нажатия Enter без ввода пароля, получаем пароль:

root@kali:~/HITCON/TODAY# python crackme_advanced.py
password:
Call me a Python virtual machine! I can interpret Python bytecodes!!!

Имея пароль, можно запустить оригинальный файл и после ввода, получить флаг.

Но есть еще один способ. Можно заменить инструкцию условного перехода на противоположную, (по принципу Криса Касперского). Если посмотреть на код, то видим:

736 BINARY_ADD          
737 LOAD_CONST 0 (None)
740 NOP
741 JUMP_ABSOLUTE 759
>> 744 LOAD_GLOBAL 1 (raw_input)
747 JUMP_ABSOLUTE 1480
>> 750 LOAD_FAST 0 (password)
753 COMPARE_OP 2 (==)
756 JUMP_ABSOLUTE 767
>> 759 ROT_TWO
760 STORE_FAST 0 (password)
763 POP_TOP
764 JUMP_ABSOLUTE 744
>> 767 POP_JUMP_IF_FALSE 1591 72 37 06
770 LOAD_GLOBAL 0 (chr) 74 00 00
773 LOAD_CONST 17 (99) 64 11 00
776 CALL_FUNCTION 1

Нас интересуют следующие инструкции:

767 POP_JUMP_IF_FALSE     1591             72 37 06
770 LOAD_GLOBAL 0 (chr) 74 00 00
773 LOAD_CONST 17 (99) 64 11 00

Видим по смещению 767 инструкцию POP_JUMP_IF_FALSE, при помощи которой, если пароли не совпадают, то производится переход на вывод сообщения о не правильном пароле.

Тут стоит немного уточнить, как формируются байты команды POP_JUMP_IF_FALSE. Из файла с объявлениями опкодов, видим define POP_JUMP_IF_FALSE 114 /* "" */ 114 - это в десятичном исчислении а в hex — это 72. В качестве параметра, передается число 1591 , в hex это — 637 , но учитывая Little Indian нотацию, расположения байт, у нас hex число 637 превращается в 37 06 (старший байт, располагается по младшему адресу). И так, у нас получается строка для поиска 723706740000641100 из трех инструкций с их параметрами, изображенными выше. Заменяем инструкцию POP_JUMP_IF_FALSE на POP_JUMP_IF_TRUE, код этой инструкции define POP_JUMP_IF_TRUE 115 /* "" */ 115 в hex это "73". Перед поиском последовательности байт и редактированием, удобно воспользоваться функцией encode('hex') для co_code содержимого.

Получается следующий кусочек кода:

idx2 = main_func_hex.index("723706740000641100")
left = main_func_hex[:idx2]
pop_jump_if_true = "73"
right = main_func_hex[idx2+2:]
mycode = left + pop_jump_if_true + right
main_func.co_code = mycode.decode('hex')
exec(main_func.get_code())

После запуска кода и нажатия Enter - сразу отобразится флаг.

Эта задача, на HITCON 2016 — дала возможность заглянуть под капот Python виртуальной машины и получить опыт чтения опкодов. Очень интересно :) Спасибо @orange за классные задачи на этом CTF.

Привожу полный код, для двух способов получения пароля и флага.

!/usr/bin/env python
# -*- coding: utf-8 -*-
import marshal, dis, zlib, base64, imp, time
import py_compile
class MutableCodeObject(object):
args_name = ('co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_stacksize', 'co_varnames')
def __init__(self, initial_code):
self.initial_code = initial_code
for attr_name in self.args_name:
attr = getattr(self.initial_code, attr_name)
setattr(self, attr_name, attr)
def get_code(self):
return self.initial_code.__class__(self.co_argcount, self.co_nlocals, self.co_stacksize, self.co_flags, self.co_code, self.co_consts, self.co_names, self.co_varnames, self.co_filename, self.co_name, self.co_firstlineno, self.co_lnotab, self.co_freevars, self.co_cellvars)
bcode = zlib.decompress(base64.b64decode('eJyNVktv00AQXm/eL0igiaFA01IO4cIVCUGFBBJwqRAckLhEIQmtRfPwI0QIeio/hRO/hJ/CiStH2M/prj07diGRP43Hs9+MZ2fWMxbnP6mux+oK9xVMHPFViLdCTB0xkeKDFEFfTIU4E8KZq8dCvB4UlN3hGEsdddXU9QTLv1eFiGKGM4cKUgsFCNLFH7dFrS9poayFYmIZm1b0gyqxMOwJaU3r6xs9sW1ooakXuRv+un7Q0sIlLVzOCZq/XtsK2oTSYaZlStogXi1HV0iazoN2CV2HZeXqRQ54TlJRb7FUlKyUatISsdzo+P7UU1Gb1POdMruckepGwk9tIXQTftz2yBaT5JQovWvpSa6poJPuqgao+b9l5Aj/R+mLQIP4f6Q8Vb3g/5TB/TJxWGdZr9EQrmn99fwKtTvAZGU7wzS7GNpZpDm2JgCrr8wrmPoo54UqGampFIeS9ojXjc4E2yI06bq/4DRoUAc0nVnng4k6p7Ks0+j/S8z9V+NZ5dhmrJUM/y7JTJeRtnJ2TSYJvsFq3CQt/vnfqmQXt5KlpuRcIvDAmhnn2E0t9BJ3SvB/SfLWhuOWNiNVZ+h28g4wlwUp00w95si43rZ3r6+fUIEdgOZbQAsyFRRvBR6dla8KCzRdslar7WS+a5HFb39peIAmG7uZTHVm17Czxju4m6bayz8e7J40DzqM0jr0bmv9PmPvk6y5z57HU8wdTDHeiUJvBMAM4+0CpoAZ4BPgJeAYEAHmgAUgAHiAj4AVAGORtwd4AVgC3gEmgBBwCPgMWANOAQ8AbwBHgHuAp4D3gLuARwoGmNUizF/j4yDC5BWM1kNvvlxFA8xikRrBxHIUhutFMBlgQoshhPphGAXe/OggKqqb2cibxwuEXjUcQjccxi5eFRL1fDSbKrUhy2CMb2aLyepkegDWsBwPlrVC0/kLHmeCBQ=='))mut_code = MutableCodeObject(marshal.loads(bcode))
hex_bcode = mut_code.co_code.encode('hex')
main_func = MutableCodeObject(mut_code.co_consts[1])
main_func_hex = main_func.co_code.encode('hex')
'''
--------------------------------------------------------
2212 PRINT_ITEM 47
2213 PRINT_NEWLINE 48
2214 LOAD_CONST 0 (None) 64 00 00
2217 RETURN_VALUE 53
--------------------------------------------------------
'''
idx1 = main_func_hex.index("474864000053")
mycode = ""
left = main_func_hex[:idx1]
'''
--------------------------------------------------------
LOAD_FAST 0 (password) 7C 00 00
--------------------------------------------------------
'''
load_fast = "7C0000"
right = main_func_hex[idx1:]
mycode = left + load_fast + right
main_func.co_code = mycode.decode('hex')
exec(main_func.get_code())
'''
--------------------------------------------------------
767 POP_JUMP_IF_FALSE 1591 72 37 06
770 LOAD_GLOBAL 0 (chr) 74 00 00
773 LOAD_CONST 17 (99) 64 11 00
--------------------------------------------------------
'''
idx2 = main_func_hex.index("723706740000641100")
left = main_func_hex[:idx2]
pop_jump_if_true = "73"
right = main_func_hex[idx2+2:]
mycode = left + pop_jump_if_true + right
main_func.co_code = mycode.decode('hex')
exec(main_func.get_code())

--

--

«Переписывание с нуля гарантирует лишь одно — ноль!» — Мартин Фаулер