
warning
This article is intended for security specialists operating under a contract; all information provided in it is for educational purposes only. Neither the author nor the Editorial Board can be held liable for any damages caused by improper usage of this publication. Distribution of unlicensed software can be prosecuted by law.
As an example, let’s test a graphical app: to register it, you have to enter the correct serial number in response to the hardware ID displayed by the program. If you enter the serial number incorrectly, the app will respond: “No valid license code”. Detect It Easy confirms that this program is a suitable test object.
Let’s use the standard algorithm to examine the app. Searches for accompanying text strings in the exe file bring no results: the executable code and data are either packed or encrypted. Loading the app to IDA indirectly confirms this: the exe file is a loader for a large self-extracting file archive.
The next step is to load the program to the x64dbg debugger. Fortunately, the app doesn’t resist this procedure: it loads normally and can be interrupted upon request. The application of tracer makes it possible to quickly locate a code section that strongly resembles an interpreter of threaded P-code:
07FF9AE401274 | 49:8BC7 | mov rax,r15
07FF9AE401277 | 49:2BC1 | sub rax,r9
07FF9AE40127A | 48:D1F8 | sar rax,1
07FF9AE40127D | 03C0 | add eax,eax
07FF9AE40127F | 41:8945 68 | mov dword ptr ds:[r13+68],eax
07FF9AE401283 | 837A 44 00 | cmp dword ptr ds:[rdx+44],0
07FF9AE401287 | 0F85 85A71200 | jne python39.7FF9AE52BA12
07FF9AE40128D ; edi - bytecode of current command
07FF9AE40128D | 41:0FB73F | movzx edi,word ptr ds:[r15]
07FF9AE401291 | 4D:8BF4 | mov r14,r12
07FF9AE401294 | 40:0FB6F7 | movzx esi,dil
07FF9AE401298 | C1EF 08 | shr edi,8
07FF9AE40129B | 49:83C7 02 | add r15,2
07FF9AE40129F | 4C:8965 C8 | mov qword ptr ss:[rbp-38],r12
07FF9AE4012A3 | 4C:897D B0 | mov qword ptr ss:[rbp-50],r15
07FF9AE4012A7 | 66:0F1F8400 00000000| nop word ptr ds:[rax+rax],ax
07FF9AE4012B0 | 8D46 FF | lea eax,qword ptr ds:[rsi-1]
07FF9AE4012B3 | 3D A4000000 | cmp eax,A4
07FF9AE4012B8 | 0F87 85E21200 | ja python39.7FF9AE52F543
07FF9AE4012BE | 48:98 | cdqe
07FF9AE4012C0 ; В rcx - offset address of current command's handler
07FF9AE4012C0 | 41:8B8C83 D8C80600 | mov ecx,dword ptr ds:[r11+rax*4+6C8D8]
07FF9AE4012C0 ; rcx - pointer to current command's handler
07FF9AE4012C8 | 49:03CB | add rcx,r11
07FF9AE4012CB ; jump to current command's handler
07FF9AE4012CB | FFE1 | jmp rcx
07FF9AE4012CD | 48:63D7 | movsxd rdx,edi
07FF9AE4012D0 | 49:8B84D5 68010000 | mov rax,qword ptr ds:[r13+rdx*8+168]
07FF9AE4012D8 | 48:85C0 | test rax,rax
07FF9AE4012DB | 0F84 B3E01200 | je python39.7FF9AE52F394
07FF9AE4012E1 | 48:FF00 | inc qword ptr ds:[rax]
07FF9AE4012E4 | 48:8B55 90 | mov rdx,qword ptr ss:[rbp-70]
07FF9AE4012E8 | 49:890424 | mov qword ptr ds:[r12],rax
07FF9AE4012EC | 49:83C4 08 | add r12,8
As you can see, the command handler table is located at the address 6C8D8
; while the pointer to the current command is located in the R15
register.
At this point, I suggest to put the debugger aside for a while and refresh the theory. But first, let’s note an interesting fact: most dynamic libraries displayed in the “Debug modules” tab are physically located in the \
subfolder within the system folder for temporary files. Apparently, this is the directory (or one of the directories) the assembly is unpacked to while the app is running.
For better understanding, let’s remember what kind of beast Python is. Many people know it as a language used to write simple scripts (similar to JavaScript) and distinguished by a somewhat extravagant concept: code blocks are separated by indents. The project was created and subsequently developed in the best traditions of black English humor (as you know, its name refers to Monty Python, a British comedy group). In the course of its evolution, the highly specialized scripting language acquired multiple libraries (similar to Fortran).
Demand breeds supply; so, to make it easier for developers to create fully-functional commercial apps within the framework of the familiar Python concept, compilers were created for various implementations. Some people tried to create native compilers; while others attached JIT (just-in-time compilation) to Python.
Accordingly, the Jython (translation into JVM bytecode) and IronPython (translation into .NET IL) projects were created. But, unfortunately, as you could see from the above fragment of interpreter code, the reference implementation lacks real advantages: this just an ordinary P-code interpretation not distinguished by high optimization.
A seemingly obvious solution is to disassemble PyInstaller into individual files using PyInstaller Extractor.
But I suggest to extract files from the project using a more advanced tool: pydumpck. Of course, it’s not omnipotent either and has certain shortcomings. For instance, on my system, it only runs normally on Python 3.9, but, generally speaking, the code compatibility issue (even between adjacent subversions) is common and not even the most severe problem plaguing this programming language. Now let’s get back to harsh technical details of the reference implementation.
The minimal unit of compiled Python bytecode is a .
file (also, there are .
files compiled with optimization, but they are beyond the scope of this study). Such files are generated from text script code by calling the py_compile.
method or by calling the import
directive in the course of script execution (to avoid compiling the imported module again). This is how the developers tried to compensate the absence of JIT in the reference implementation. This file contains the bytecode of the compiled module, constants, references, etc. Its format depends on the Python version; even though it’s not documented officially, you can find detailed descriptions on the Internet, for instance, on Nedbatchelder. By the way, this article provides the text of the simplest pyc
disassembler written in Python:
import dis, marshal, struct, sys, time, typesdef show_file(fname): f = open(fname, "rb") magic = f.read(4) moddate = f.read(4) modtime = time.asctime(time.localtime(struct.unpack('L', moddate)[0])) print "magic %s" % (magic.encode('hex')) print "moddate %s (%s)" % (moddate.encode('hex'), modtime) code = marshal.load(f) show_code(code)def show_code(code, indent=''): print "%scode" % indent indent += ' ' print "%sargcount %d" % (indent, code.co_argcount) print "%snlocals %d" % (indent, code.co_nlocals) print "%sstacksize %d" % (indent, code.co_stacksize) print "%sflags %04x" % (indent, code.co_flags) show_hex("code", code.co_code, indent=indent) dis.disassemble(code) print "%sconsts" % indent for const in code.co_consts: if type(const) == types.CodeType: show_code(const, indent+' ') else: print " %s%r" % (indent, const) print "%snames %r" % (indent, code.co_names) print "%svarnames %r" % (indent, code.co_varnames) print "%sfreevars %r" % (indent, code.co_freevars) print "%scellvars %r" % (indent, code.co_cellvars) print "%sfilename %r" % (indent, code.co_filename) print "%sname %r" % (indent, code.co_name) print "%sfirstlineno %d" % (indent, code.co_firstlineno) show_hex("lnotab", code.co_lnotab, indent=indent)def show_hex(label, h, indent): h = h.encode('hex') if len(h) < 60: print "%s%s %s" % (indent, label, h) else: print "%s%s" % (indent, label) for i in range(0, len(h), 60): print "%s %s" % (indent, h[i:i+60])show_file(sys.argv[1])
As you can see, disassembling the bytecode of .
files isn’t a big deal. You can use, for example, the most widespread Python disassembler called pydasm. There is even a special plug-in Python library, dis
, created exclusively for disassembling. Even though the command system is version-dependent and not documented officially, it’s still simple and publicly available. Its description is also available online.
Too bad, decompiling pyc
into original Python code is problematic. Despite the large number of available decompilers (the most common are python-decompile3 and pycdc), currently, there are no absolutely correct decompilers for versions older than 3.8 in the public, not to mention obfuscation. Therefore, I must disappoint lamers novice hackers seeking a magic button: decompiled code (even version 3.9) requires significant refinement.
Let’s continue examining the Python package files. Remember the list of imported libraries displayed by the debugger and unpacked into the system’s temporary directory? This list includes modules with the pyd
extension. These are native binary libraries connected to the Python interpreter. As you can see, they are classic Windows dynamic libraries in the DLL format with a single exported function. Protections integrated into such native extensions are beyond the scope of this study since they are discussed in multiple publications.
Without digging too deep into the topic, let’s examine one more key file format with the .
extension. This is a Python archive making it possible to combine several modules, classes, and other components of your project into a single app. A kind of ‘archive in an archive’ inside an executable PyInstaller file.
All this makes searches for a certain module by string occurrence in an unpacked PyInstaller assembly very difficult. Fortunately, this hostile engineering marvel can be easily disassembled into its components using the above-mentioned pydumpck utility and reassembled (e.g. in the case of a patch) using the built-in zipapp utility.
Now that you possess the required knowledge, let’s put it to practice to dissect the test app. First, unpack it with pydumpck (Python 3.9 must be used). This is a virtually universal tool: not only does pydumpck unpack both the executable PyInstaller exe-module and pyz
Python archives contained in it, but also decompiles the unpacked pyc
files into the source code.
It has two built-in decompilation plugins: the above-mentioned uncompyle6 and pycdc. By default, pycdc is used, and it generally copes with the task; with regards to the uncompyle6 plugin, I was unable to use it on version 3.9.
Now let’s use WinHex to search the resulting set of files for the text string no
. Since this is the simplest case, the desired string is found both in pyc
and in the py
file automatically decompiled from it. Open the py
file restored by the decompiler. Too bad, version 3.9 isn’t native for pycdc, and it clearly suffers from code indigestion: the code isn’t restored in full. Warnings about unknown instructions and decompilation errors can be seen everywhere, and, of course, such source code cannot be recompiled. However, the desired location where the check occurs and the string is displayed is present in the file:
...def validate_serial(self): if not utils.validate_serial(): self.logger.log('no valid license code for the popup code: ' + utils.get_hardware_code() + '\n') return False…
Let’s try to disassemble this file using pycdasm. At first, it complains about the absence of a signature, but it’s not a big deal: you can manually add it (in this case, it’s 8 bytes: 610D0D0A0000000000000000000000000
) at the beginning of the file (e.g. using WinHex).
Looking for the required location in the disassembled code:
...[Disassembly]0 LOAD_GLOBAL 0: utils2 LOAD_METHOD 1: validate_serial4 CALL_METHOD 06 POP_JUMP_IF_TRUE 368 LOAD_FAST 0: self10 LOAD_ATTR 2: logger12 LOAD_METHOD 3: log14 LOAD_CONST 1: 'no valid license code for the popup code: '16 LOAD_GLOBAL 0: utils18 LOAD_METHOD 4: get_hardware_code20 CALL_METHOD 022 BINARY_ADD24 LOAD_CONST 2: '\n'26 BINARY_ADD28 CALL_METHOD 130 POP_TOP32 LOAD_CONST 3: False34 RETURN_VALUE36 LOAD_CONST 4: True…
The POP_JUMP_IF_TRUE
instruction is responsible for checking the condition and making a conditional jump. Of course, it could be changed to make an unconditional jump, but, in such a case, you would have to balance the stack somehow since there is no command that simultaneously removes a value from the stack and makes a jump. In addition, there is no guarantee that the serial number isn’t checked somewhere else. It’s better to find and adjust the utils.
method. To do this, find the utils
module and decompile it:
…def validate_serial(): stored_serial = get_data_file().strip() hardware_code = get_hardware_code() if hardware_code == '' or stored_serial != get_serial(hardware_code): return False…
In the bytecode form, it looks as follows:
[Disassembly]0 LOAD_GLOBAL 0: get_data_file2 CALL_FUNCTION 04 LOAD_METHOD 1: strip6 CALL_METHOD 08 STORE_FAST 0: stored_serial10 LOAD_GLOBAL 2: strip12 CALL_FUNCTION 014 STORE_FAST 1: hardware_code16 LOAD_FAST 1: hardware_code18 LOAD_CONST 1: ''20 COMPARE_OP 2 (==)22 POP_JUMP_IF_TRUE 3624 LOAD_FAST 0: stored_serial26 LOAD_GLOBAL 3: NULL + strip28 LOAD_FAST 1: hardware_code30 CALL_FUNCTION 132 COMPARE_OP 3 (!=)34 POP_JUMP_IF_FALSE 4036 LOAD_CONST 2: False38 RETURN_VALUE40 LOAD_CONST 3: True42 RETURN_VALUE
As you can see, all you have to do is replace the command at offset 36
with the command LOAD_CONST
, and the validate_serial(
method will always return True
. To verify this, let’s edit the bytecode directly in the debugger.
Press the Activate button – and voila! Now the program accepts any code. Now all you have to do is edit the bytecode in the respective pyc
module and neatly reassemble the executable using PyInstaller.
At first glance, it might seem that hacking such apps is a ridiculously easy job. But, as you understand, this article analyzes the simplest and most basic case without obfuscation, virtualization, cryptography, and other hassles. That’s all for now; more exotic variants will be discussed in future materials.
See you soon!