├── LICENSE ├── README.md ├── analyze_crypted_code.py ├── bcc_info.py ├── bn ├── bn_annotate_bcc.py └── bn_getkey.py ├── decrypt_gcm.py ├── disassemble.py ├── ida ├── ida_annotate_bcc.py └── ida_getkey.py ├── libtomcrypt.sig ├── py311 ├── Dockerfile └── armor-marshal-311.patch ├── py312 ├── Dockerfile └── armor-marshal.patch └── screenshots └── pyarmor_runtime_key_func.png /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 G DATA Advanced Analytics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pyarmor v8+ tooling 2 | 3 | This repository contains some scripts for statically decrypting Python scripts protected with Pyarmor v8 or higher. 4 | 5 | Blog post for further reading: https://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/ 6 | 7 | Please also take note of the [Disclaimer](#disclaimer) section below before you continue. 8 | 9 | ## Usage 10 | 11 | Initial preparation: Go into `py312` and run `docker build -t pyarmor312 .`. Also create a virtual env where you install `pycryptodome`. 12 | 13 | 1. Open up the native Pyarmor module in IDA, find the MD5 key derivation function, adjust `ida_getkey.py` and run it in IDAPython. Adjust `decrypt_gcm.py` with the key you obtained. Alternatively, a Binary Ninja version of the script is available (`bn_getkey.py`). 14 | 1. Run `python decrypt_gcm.py /path/to/malware/malware.py` 15 | 1. Run `docker run --rm -u $(id -u):$(id -g) -v $(pwd)/analyze_crypted_code.py:/script.py:ro -v /path/to/malware:/data -it pyarmor312 /script.py /data/malware.py.dec` 16 | 1. Run `python decrypt_gcm.py /path/to/malware/malware.py.dec` - it will now use the json file generated by the step above to decrypt individual functions and generate a `dec2` file 17 | 1. Run `docker run --rm -u $(id -u):$(id -g) -v $(pwd)/disassemble.py:/script.py:ro -v /path/to/malware:/data -it pyarmor312 /script.py /data/malware.py.dec2`, which will fully disassemble the Python bytecode 18 | 19 | 20 | ## Finding the key derivation function 21 | 22 | Go to the PyInit export and scroll almost all the way down, until you see a place like this: 23 | 24 | ![Screenshot](screenshots/pyarmor_runtime_key_func.png) 25 | 26 | Inside that function you'll find the necessary details for `ida_getkey.py`. 27 | 28 | 29 | ## BCC mode 30 | 31 | If the script you're analyzing is protected with BCC mode, step 2 will additionally yield an ELF file (`.dec.elf`). 32 | This ELF contains Python functions that were compiled to native code. 33 | 34 | You can use the `bcc_info.py` script with the custom Python interpreter for obtaining further details about the BCC functions. 35 | That includes: 36 | * Function offsets in the ELF 37 | * Suggested function names 38 | * List of constants used by the native code 39 | 40 | This information is also written to a json file, which can be used with the IDAPython script `ida_annotate_bcc.py` in order to import the data into IDA. 41 | 42 | **Note:** The ELF format is only followed in the broadest strokes, and IDA may refuse to load the file as ELF. In that case, simply load it as binary file. For Windows targets, you should make sure to set the compiler to Visual C++ so that the correct ABI will be used. 43 | 44 | 45 | You may find the following reverse engineered definitions (Pyarmor v9, Python 3.11/3.12) helpful when looking at BCC code: 46 | ```c 47 | enum GLOBAL_OPS 48 | { 49 | GLOBAL_DELETE = 0x0, 50 | GLOBAL_GET = 0x1, 51 | GLOBAL_RETURN_GLOBALS = 0x2, 52 | GLOBAL_SPECIAL_ENTER = 0x4, /* __enter__ */ 53 | GLOBAL_SPECIAL_EXIT = 0x5, /* __exit__ */ 54 | GLOBAL_SET_MIN = 0x10, /* anything above means Set (pointer value instead of int) */ 55 | }; 56 | 57 | struct bcc_ftable 58 | { 59 | _QWORD p_stdin; 60 | void (__fastcall *memset)(void *, _QWORD, _QWORD); 61 | _QWORD p_stderr; 62 | _QWORD fprintf; 63 | void *(__fastcall *PyNumber_operator)(void *a, void *b, int operatortype); 64 | __int64 (*build_collection)(__int64 colltype, __int64 count, ...); 65 | __int64 *(__fastcall *call_python_func)(__int64 bDontCall, __int64 *pyCallable, int bArgsRequired, int bKwArgsRequired, __int64 *argsTuple, __int64 *kwargsDict); 66 | int (__fastcall *set_exception_if_none_was_raised)(int mode); 67 | void *(__fastcall *comparison)(void *unused, int operatortype, void *left, void *right); 68 | _QWORD qword48; 69 | _QWORD fetch_exception; 70 | _QWORD string_format; 71 | void *(__fastcall *globals_operation)(void *unused, void *key, GLOBAL_OPS modeOrValueForSet); 72 | _QWORD op_mkfunc_not_available; 73 | void *(__fastcall *iter_next)(void *); 74 | _QWORD qword78; 75 | _QWORD qword80; 76 | _QWORD update_exception_info; 77 | _QWORD qword90; 78 | __int64 (__fastcall *unpack_values)(void *unused, void *input, int maxCount, void **output); 79 | _QWORD qwordA0; 80 | _QWORD new_function; 81 | _QWORD qwordB0; 82 | _QWORD import_stuff; 83 | _BYTE gapC0[32]; 84 | _QWORD Py_NoneStruct; 85 | _QWORD Py_TrueStruct; 86 | _QWORD Py_FalseStruct; 87 | _QWORD gapF8; 88 | int (__fastcall *PyBytes_AsStringAndSize)(void *obj, char **buffer, _QWORD *length); 89 | void *(__fastcall *PyCell_Get)(void *cell); 90 | void *(__fastcall *PyCell_New)(void *ob); 91 | int (__fastcall *PyCell_Set)(void *cell, void *value); 92 | void (__fastcall *PyErr_Clear)(); 93 | void *(__fastcall *PyErr_Occurred)(); 94 | void (__fastcall *PyErr_SetObject)(void *type, void *value); 95 | void *(__fastcall *PyEval_GetGlobals)(); 96 | void *(__fastcall *PyImport_ImportModule)(const char *name); 97 | void *(__fastcall *PyImport_ImportModuleLevel)(const char *name, void *globals, void *locals, void *fromlist, int level); 98 | int (__fastcall *PyList_Append)(void *list, void *item); 99 | void *(__fastcall *PyList_New)(__int64 len); 100 | void *(*PyObject_CallFunction_SizeT)(void *callable, const char *format, ...); 101 | void *(*PyObject_CallFunctionObjArgs)(void *callable, ...); 102 | void *(*PyObject_CallMethod_SizeT)(void *obj, const char *name, const char *format, ...); 103 | int (__fastcall *PyObject_DelItem)(void *, void *key); 104 | void *(__fastcall *PyObject_GetAttr)(void *, void *attr_name); 105 | void *(__fastcall *PyObject_GetItem)(void *, void *key); 106 | void *(__fastcall *PyObject_GetIter)(void *); 107 | int (__fastcall *PyObject_IsTrue)(void *); 108 | int (__fastcall *PyObject_SetAttr)(void *, void *attr_name, void *v); 109 | int (__fastcall *PyObject_SetItem)(void *, void *key, void *v); 110 | int (__fastcall *PySet_Add)(void *, void *key); 111 | void *(__fastcall *PySet_New)(void *iterable); 112 | void *(__fastcall *PySlice_New)(void *start, void *stop, void *step); 113 | void *(__fastcall *PyTuple_GetItem)(void *p, __int64 pos); 114 | void (__fastcall *Py_DecRef)(void *); 115 | void (__fastcall *Py_IncRef)(void *); 116 | }; 117 | ``` 118 | 119 | 120 | # Custom Python notes 121 | 122 | The Docker image builds a custom Python version that is able to read objects serialized by Pyarmor. 123 | 124 | The difference to 'normal' Python is that code objects have an additional string/array at the end. 125 | Since the normal unmarshaler doesn't expect this, it runs into an "unknown type" error. 126 | 127 | The patch introduces an `armor` flag into `RFILE` so that we can only apply the changed logic for explicit calls to `marshal.load(file: SupportsRead[bytes])`. 128 | Otherwise, Python breaks because it cannot unmarshal its builtin objects. 129 | 130 | **If you have a protected version that utilizes a different Python version, you need to build that specific version and possibly adjust the patch.** 131 | 132 | 133 | # Disclaimer 134 | 135 | This repository contains tools developed by G DATA Advanced Analytics GmbH intended strictly for malware analysis and related security research. 136 | 137 | **Important Notice** 138 | - These tools are designed for legitimate purposes only, such as analyzing malicious software in controlled environments. 139 | - Use of these tools for any unauthorized or illegal activities, including the analysis of non-malicious software, is strictly prohibited. 140 | - Users are solely responsible for ensuring compliance with all applicable local, national, and international laws and regulations. 141 | 142 | **No Warranty** 143 | 144 | This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from, out of, or in connection with the software or the use or other dealings in the software. 145 | 146 | By using this software, you agree to these terms. 147 | -------------------------------------------------------------------------------- /analyze_crypted_code.py: -------------------------------------------------------------------------------- 1 | # To be run with customized python3! 2 | """ 3 | This script processes the decrypted pyarmor bytes string and outputs 4 | a json file that describes how to decrypt the individual code objects. 5 | """ 6 | 7 | import dis 8 | import json 9 | import marshal 10 | import opcode 11 | import sys 12 | from io import BytesIO 13 | 14 | 15 | def display_code(code_obj): 16 | """Prints all relevant attributes of the given code object.""" 17 | attributes = dir(code_obj) 18 | for attr in attributes: 19 | if attr == "co_code": 20 | continue 21 | if not attr.startswith("co") and not attr.startswith("_co"): 22 | continue 23 | try: 24 | value = getattr(code_obj, attr) 25 | vstr = str(value) 26 | if len(vstr) < 1000: 27 | print(f"{attr}: {vstr}") 28 | else: 29 | print(f"{attr}: {vstr[:500]} <<< SNIP >>> {vstr[-500:]}") 30 | except AttributeError: 31 | print(f"{attr}: [Attribute not accessible]") 32 | 33 | # try: 34 | # dis.dis(code_obj) 35 | # except Exception: 36 | # print(" --- code crypted after this offset ---") 37 | 38 | 39 | # The dis() call would print something like this: 40 | """ 41 | 42 | 0 0 NOP 43 | 44 | 1 2 NOP 45 | 4 PUSH_NULL 46 | 6 LOAD_CONST 1 ('__pyarmor_enter_60307__') 47 | 48 | 2 8 LOAD_CONST 2 (b'\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x1a@\x00\x00\x00\x00\x00\x00\x00') 49 | 10 BUILD_TUPLE 1 50 | 12 CALL_FUNCTION_EX 0 51 | 14 POP_TOP 52 | 16 RESUME 0 53 | 18 NOP 54 | 20 NOP 55 | 22 NOP 56 | 24 NOP 57 | 58 | """ 59 | 60 | 61 | def get_crypto_info(all_data: bytes, code_obj) -> dict: 62 | """Returns a dictionary with information about the ciphered region in the code object.""" 63 | 64 | # NOTES: 65 | # 1. co_code is sanitized before being given out to a script (invalid opcodes are zeroed), so it's useless for us 66 | # 2. Using _co_code_adaptive only works because we disable specialization in our custom Python build 67 | code: bytes = code_obj._co_code_adaptive 68 | code_offset_in_data = all_data.index(code) 69 | 70 | if code[8] != opcode.opmap["LOAD_CONST"] or code[12] != opcode.opmap["CALL_FUNCTION_EX"]: 71 | print("Method does not seem to be encrypted") 72 | return {} 73 | 74 | # Get the LOAD_CONST bytes that can be seen above at offset 8. 75 | crypto_info = code_obj.co_consts[code[9]] 76 | 77 | if not isinstance(crypto_info, bytes): 78 | raise Exception(f"Expected LOAD_CONST to load bytes, got {type(crypto_info)}") 79 | 80 | if crypto_info[8] & 4: 81 | raise Exception("Bit for mask 4 is set! Probably special nonce handling") 82 | 83 | ciphertext_offset = crypto_info[11] 84 | ciphertext_size = int.from_bytes(crypto_info[12:16], 'little') 85 | 86 | nonce_offset = crypto_info[9] 87 | if (crypto_info[8] & 2) == 0: 88 | nonce_offset += ciphertext_offset + ciphertext_size 89 | 90 | nonce = code[nonce_offset:nonce_offset+12] 91 | 92 | return { 93 | 'ciphertext_offset': code_offset_in_data + ciphertext_offset, 94 | 'ciphertext_size': ciphertext_size, 95 | 'nonce': nonce.hex() 96 | } 97 | 98 | 99 | def process_code_object(code_obj, filedata: bytes, crypted_regions: list[dict]) -> None: 100 | """ 101 | Recursively processes a code object and its nested code objects in constants 102 | in order to extract encryption information. 103 | 104 | Args: 105 | code_obj: The code object to process 106 | filedata: Entire contents of the Python module 107 | crypted_regions: List that is appended to 108 | """ 109 | for const in code_obj.co_consts: 110 | if isinstance(const, type((lambda: None).__code__)): 111 | print("Found nested code object: " + str(const)) 112 | display_code(const) 113 | if info := get_crypto_info(filedata, const): 114 | crypted_regions.append(info) 115 | 116 | process_code_object(const, filedata, crypted_regions) 117 | 118 | 119 | def main(filename: str) -> None: 120 | with open(filename, "rb") as fp: 121 | fp.seek(0x20) 122 | data = fp.read() 123 | 124 | obj = marshal.load(BytesIO(data)) 125 | display_code(obj) 126 | 127 | crypted_regions: list[dict] = [] 128 | process_code_object(obj, data, crypted_regions) 129 | crypted_regions.append(get_crypto_info(data, obj)) 130 | 131 | json.dump(crypted_regions, open(filename + ".json", "w")) 132 | 133 | print(f"Found {len(crypted_regions)} encrypted code objects. {filename}.json saved.") 134 | 135 | 136 | if __name__ == "__main__": 137 | main(sys.argv[1]) 138 | -------------------------------------------------------------------------------- /bcc_info.py: -------------------------------------------------------------------------------- 1 | # To be run with customized python3! 2 | """ 3 | This script analyzes BCC data. It works with the .dec2 and .dec.elf files generated 4 | by the other scripts. 5 | 6 | It prints some information about BCC functions and generates a JSON file for ida_annotate_bcc.py. 7 | """ 8 | 9 | import json 10 | import marshal 11 | import os 12 | import sys 13 | from io import BytesIO 14 | from typing import Optional 15 | 16 | 17 | def decode_compact_int(p: bytes) -> tuple[int, int]: 18 | """ 19 | Decodes a compact int from the beginning of the given bytes string. 20 | :return: Tuple of (value, number of bytes consumed). 21 | """ 22 | if len(p) < 2: 23 | raise ValueError("Input too short to decode") 24 | 25 | val = p[1] 26 | i = 2 27 | expected_len = (p[0] >> 6) + 2 28 | 29 | while i < len(p) and i < expected_len: 30 | val = p[i] | (val << 8) 31 | i += 1 32 | 33 | return val, i 34 | 35 | 36 | def parse_pyarmor_data(code_obj, bcc_list: list[tuple[int, str]]) -> Optional[tuple[int, str, list]]: 37 | """ 38 | Parses pyarmor data attached to code_obj. This data describes how the runtime should 39 | patch entries in co_consts. The runtime replaces strings with references to native functions. 40 | 41 | :param code_obj: The code object to inspect. 42 | :param bcc_list: List of all BCC functions in the ELF. 43 | :return: Tuple (offset, func name, constants) or None if code_obj does not use BCC. 44 | """ 45 | extradata = code_obj.co_pyarmor_data 46 | if len(extradata) <= 0: 47 | return None 48 | 49 | first = extradata[0] 50 | patch_count = first & 0x3 51 | method_count = (first >> 2) & 0x3 52 | has_bcc = bool(first & 0x10) 53 | has_locals_patch = bool(first & 0x20) 54 | 55 | print(f"Parsed extradata flags: patch_count={patch_count}, " 56 | f"method_count={method_count}, BCC={'yes' if has_bcc else 'no'}, " 57 | f"locals_patch={'yes' if has_locals_patch else 'no'}") 58 | 59 | offset = 4 60 | p = extradata[offset:] 61 | 62 | for i in range(patch_count): 63 | if len(p) < 1: 64 | print(" Insufficient data for patch") 65 | break 66 | 67 | function_id = p[0] & 0x3F 68 | compact_val, consumed = decode_compact_int(p) 69 | const_value = code_obj.co_consts[compact_val] 70 | 71 | # These are relatively boring, they map to the assert/enter/leave C functions in the Pyarmor runtime. 72 | print(f" Patch {i}: consts[{compact_val} : {const_value}] = method_table[{function_id}]") 73 | 74 | p = p[consumed:] 75 | offset += consumed 76 | 77 | if has_bcc and len(p) > 0: 78 | const_index = p[0] & 0x3F 79 | const_value = code_obj.co_consts[const_index] 80 | compact_val, _ = decode_compact_int(p) 81 | bcc_offset, bcc_name = bcc_list[compact_val] 82 | print(f" BCC: consts[{const_index} : {const_value}] = {bcc_name} at ELF offset {hex(bcc_offset)}") 83 | 84 | bcc_consts = [] 85 | if isinstance(code_obj.co_consts[const_index + 1], tuple): 86 | print(" Constants:") 87 | for i, c in enumerate(code_obj.co_consts[const_index + 1]): 88 | print(f" {i + 3}: {c}") 89 | if isinstance(c, type((lambda: None).__code__)): 90 | bcc_consts.append(str(c)) 91 | else: 92 | bcc_consts.append(c) 93 | 94 | return bcc_offset, f"{bcc_name}_{code_obj.co_name}", bcc_consts 95 | 96 | return None 97 | 98 | 99 | def read_null_term(data: bytes, offset: int) -> str: 100 | """Reads a null-terminated string from data at offset.""" 101 | length = 0 102 | while data[offset+length] != 0: 103 | length += 1 104 | 105 | return data[offset:offset+length].decode() 106 | 107 | 108 | def parse_custom_elf(elf: bytes) -> list[tuple[int, str]]: 109 | # These offsets can be found in the method that allocates the bcc code. 110 | shdr_off = int.from_bytes(elf[40:48], 'little') 111 | info_section_off = shdr_off + 64 * elf[62] 112 | func_table_off = info_section_off + 24 113 | 114 | i = int.from_bytes(elf[func_table_off:func_table_off+8], 'little') 115 | reader = BytesIO(elf[i:]) 116 | bcc_list = [] 117 | while True: 118 | name_off = int.from_bytes(reader.read(8), 'little') 119 | if name_off == 0: # table seems to end with an all zero entry 120 | break 121 | func_off = int.from_bytes(reader.read(8), 'little') 122 | reader.read(8) 123 | reader.read(8) 124 | bcc_list.append((func_off, read_null_term(elf, name_off))) 125 | 126 | return bcc_list 127 | 128 | 129 | if len(sys.argv) < 2 or not os.path.exists(sys.argv[1]): 130 | print(f"Usage: {sys.argv[0]} ") 131 | sys.exit(1) 132 | 133 | elf_name = sys.argv[1].replace(".dec2", ".dec.elf") 134 | if not os.path.exists(elf_name): 135 | print(f"{elf_name} does not exist! BCC mode is not in use or was not dumped.") 136 | sys.exit(1) 137 | 138 | # Parse ELF to get BCC func table. 139 | with open(elf_name, "rb") as fp: 140 | bcc_list = parse_custom_elf(fp.read()) 141 | 142 | # Unmarshal Python module containing the calls to BCC. 143 | with open(sys.argv[1], "rb") as fp: 144 | fp.seek(0x20) 145 | data = fp.read() 146 | 147 | obj = marshal.load(BytesIO(data)) 148 | 149 | # Process pyarmor_data bytes in code objects. 150 | output_list = [] 151 | print(str(obj)) 152 | if obj.co_pyarmor_data is not None: 153 | output_list.append(parse_pyarmor_data(obj, bcc_list)) 154 | 155 | for const in obj.co_consts: 156 | if isinstance(const, type((lambda: None).__code__)): 157 | print("\n\n" + str(const)) 158 | if const.co_pyarmor_data is not None: # type: ignore 159 | output_list.append(parse_pyarmor_data(const, bcc_list)) 160 | 161 | # Dump info to disk as json. 162 | output_list_f = list(filter(None, output_list)) 163 | output_list_named = [{"offset": offset, "name": name, "consts": consts} for offset, name, consts in output_list_f] 164 | json.dump(output_list_named, open(elf_name + ".json", "w")) 165 | 166 | print(elf_name + ".json saved for IDA script.") 167 | -------------------------------------------------------------------------------- /bn/bn_annotate_bcc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Minimum Python version: 3.9 3 | 4 | This Binary Ninja script adds comments in the decompiler view for all references to constants in 5 | BCC functions. 6 | 7 | IMPORTANT: For this to work, the pointer variable must be of type QWORD* in every function. 8 | Unlike the IDA one, the first dereference is not our target expressions, instead, it follows the pattern: 9 | r12 = *(arg1 + (sx.q(*(arg1 + 0x10)) << 3) + 0x10) 10 | 11 | Also unlike IDA, there are no v4[1], v4[2], v4[3]... Instead, Binary Ninja display those access as pointer arithmetics like: 12 | int64_t rsi = (*(rax_4 + 0x1d0))() 13 | """ 14 | 15 | import re 16 | import json 17 | from typing import Optional 18 | 19 | # This should be the path to your *.elf.json 20 | JSON_PATH = "" 21 | 22 | def wait_modification(): 23 | return bv.update_analysis_and_wait() 24 | 25 | def open_json_file(json_path: str): 26 | if not json_path: 27 | return 28 | with open(json_path, 'r') as fp: 29 | print(f"Opened file {json_path} with read permissions.") 30 | return json.load(fp) 31 | 32 | 33 | def create_and_rename_function(offset: int, name: str) -> None: 34 | """Create function at offset and rename it.""" 35 | existing_func = bv.get_function_at(offset) 36 | if existing_func: 37 | print(f"Function already exists at {hex(offset)}, renaming to '{name}'") 38 | existing_func.name = name 39 | return 40 | 41 | func = bv.create_user_function(offset) 42 | if not func: 43 | print(f"Failed to create function at {hex(offset)}") 44 | return 45 | 46 | wait_modification() 47 | 48 | func.name = name 49 | print(f"Created and named function '{name}' at {hex(offset)}") 50 | 51 | class ConstsAnnotator(): 52 | 53 | def __init__(self, func, consts: list) -> None: 54 | self.alias_map: dict[str, str] = {} 55 | self.comments_added = 0 56 | self.func = func 57 | self.consts = consts 58 | self.target_var = None 59 | 60 | def find_target_constant(self): 61 | """Find the target constant pointer and set consts_base.""" 62 | for block in self.func.hlil: 63 | for instr in block: 64 | if (type(instr) == HighLevelILVarInit and 65 | hasattr(instr, 'detailed_operands') and 66 | type(instr.detailed_operands[1][1]) == HighLevelILDeref): # HighLevelILDeref (arg1 + (sx.q(*(arg1 + 0x10)) << 3) + 0x10) 67 | 68 | target_var = instr.operands[0] 69 | 70 | print(f"Found consts pointer identifier {instr}") 71 | return target_var 72 | return None 73 | 74 | def map_constant_xrefs(self, target_var): 75 | refs = self.func.get_hlil_var_refs(target_var) 76 | print(f"Found XRefs: {refs}" if refs else "There are no XRefs") 77 | 78 | for ref in refs: 79 | ref_dest = ref.func.hlil[ref.expr_id].operands[0] 80 | if hasattr(ref_dest, 'identifier'): # Variable type 81 | ref_dest_id = ref_dest.identifier 82 | elif hasattr(ref_dest, 'var') and hasattr(ref_dest.var, 'identifier'): # HighLevelILVar 83 | ref_dest_id = ref_dest.var.identifier 84 | else: 85 | continue 86 | 87 | ref_src = ref.func.hlil[ref.expr_id].operands[1] 88 | # We will only track variable aliases, like v1012 = v4 89 | # Deref's won't be tracked 90 | if hasattr(ref_src, 'var') and hasattr(ref_src.var, 'identifier'): # HighLevelILVar 91 | ref_src_id = ref_src.var.identifier 92 | else: 93 | continue 94 | 95 | if ref_src_id == target_var.identifier: 96 | self.alias_map[ref_dest_id] = ref_src_id 97 | 98 | def find_constant(self): 99 | self.target_var = self.find_target_constant() 100 | if self.target_var: 101 | self.map_constant_xrefs(self.target_var) 102 | 103 | print("\nAlias Mapping Results:") 104 | if self.alias_map: 105 | for dest_var, src_var in self.alias_map.items(): 106 | print(f" {dest_var} -> {src_var}") 107 | else: 108 | print(" No aliases found") 109 | 110 | def place_comments(self): 111 | def visit_expr(expr): 112 | # Check if this is a dereference of our target variable + offset 113 | if (type(expr) == HighLevelILDeref and 114 | type(expr.src) == HighLevelILAdd and 115 | type(expr.src.left) == HighLevelILVar and 116 | type(expr.src.right) == HighLevelILConst): 117 | 118 | var_id = expr.src.left.var.identifier 119 | offset = expr.src.right.constant 120 | 121 | # Check if this is our target variable or an alias to it 122 | if (var_id == self.target_var.identifier or 123 | self.alias_map.get(var_id) == self.target_var.identifier): 124 | 125 | # Convert byte offset to array index (offset >= 24 means index >= 3) 126 | if offset >= 24 and (offset - 24) % 8 == 0: 127 | array_index = (offset - 24) // 8 + 3 128 | 129 | if 0 <= array_index - 3 < len(self.consts): 130 | comment = self.consts[array_index - 3] 131 | addr = getattr(expr, 'address', None) or getattr(expr.instr, 'address', None) 132 | if addr: 133 | try: 134 | self.func.set_comment_at(addr, comment) 135 | self.comments_added += 1 136 | print(f"Added comment '{comment}' at {hex(addr)}") 137 | except Exception as e: 138 | print(f"Failed to set comment: {e}") 139 | 140 | if hasattr(expr, 'operands'): 141 | for operand in expr.operands: 142 | if hasattr(operand, '__iter__') and not isinstance(operand, str): 143 | for sub_op in operand: 144 | visit_expr(sub_op) 145 | elif hasattr(operand, 'operands'): 146 | visit_expr(operand) 147 | 148 | for block in self.func.hlil: 149 | for instr in block: 150 | visit_expr(instr) 151 | 152 | 153 | def annotate_consts_in_decompilation(ea: int, consts: list) -> None: 154 | func = bv.get_function_at(ea) 155 | if not func: 156 | print(f"No function found at {hex(ea)}") 157 | return 158 | 159 | if not func.hlil: 160 | print(f"No HLIL for function at {hex(ea)}") 161 | return 162 | 163 | annotator = ConstsAnnotator(func, consts) 164 | annotator.find_constant() 165 | annotator.place_comments() 166 | 167 | print(f"Added {annotator.comments_added} comments to function at {hex(ea)}") 168 | 169 | wait_modification() 170 | 171 | def main() -> None: 172 | data = open_json_file(JSON_PATH) 173 | if data is None: 174 | print("Failed to load JSON data") 175 | return 176 | 177 | print(f"Processing {len(data)} entries...") 178 | 179 | for entry in data: 180 | offset = entry['offset'] 181 | name = entry['name'] 182 | consts = entry['consts'] 183 | 184 | print("-------------------------------------------------------") 185 | print(f"Offset: {hex(offset)}, Name: {name}, Constants: {len(consts)}") 186 | 187 | create_and_rename_function(offset, name) 188 | annotate_consts_in_decompilation(offset, consts) 189 | 190 | main() -------------------------------------------------------------------------------- /bn/bn_getkey.py: -------------------------------------------------------------------------------- 1 | """Binary Ninja implementation of "get_key_via_md5" for statically obtaining key.""" 2 | 3 | import hashlib 4 | 5 | # This string is a unique license ID of the person who obfuscated the script, in the free version it would always be 000000 and in paid versions its unique per person 6 | PYARMOR_STRING = b"pyarmor-vax-007106\x00\x00" 7 | 8 | # References to these are in the "get_key_via_md5" function 9 | """ 10 | get_key_via_md5(&var_1c8, sx.q(data_65642050) + &data_65642060, data_65642054) 11 | """ 12 | INFO_BLOB_ADDR = 0x65642060 13 | 14 | # First xmmword that is xored: 15 | # int128_t zmm0 = data_656479c0 16 | # data_65646140 ^= zmm0 17 | # data_65646150.o ^= zmm0 18 | # data_65646160 ^= zmm0 19 | RSA_KEY2_ADDR = 0x65646140 20 | 21 | # From a global dword passed to md5_process 22 | RSA_KEY2_SIZE = 0x10E 23 | 24 | # Byte value that RSA_KEY2 is xored with 25 | RSA_XOR_KEY = 0xF1 26 | 27 | 28 | md = hashlib.md5() 29 | md.update(PYARMOR_STRING) 30 | 31 | br = BinaryReader(bv, Endianness.BigEndian) 32 | 33 | rsakey_size = int.from_bytes(br.read(4, INFO_BLOB_ADDR - 0xC), 'little') 34 | rsakey = br.read(rsakey_size, INFO_BLOB_ADDR + 0x20) 35 | 36 | sig_offset = int.from_bytes(br.read(4, INFO_BLOB_ADDR - 0x8), 'little') 37 | hashed_area_size = int.from_bytes(br.read(4, INFO_BLOB_ADDR + sig_offset + 4), 'little') 38 | hashed_area = br.read(hashed_area_size, INFO_BLOB_ADDR + sig_offset + 0x20) 39 | 40 | rsakey2 = bytes([b ^ RSA_XOR_KEY for b in br.read(RSA_KEY2_SIZE, RSA_KEY2_ADDR)]) 41 | 42 | md.update(rsakey) 43 | md.update(hashed_area) 44 | md.update(rsakey2) 45 | print(md.hexdigest()) 46 | -------------------------------------------------------------------------------- /decrypt_gcm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This script has two purposes: 5 | 1. Extract and decrypt Pyarmor bytes string from protected file. 6 | 2. Utilize info generated by analyze_crypted_code.py to generate completely decrypted file. 7 | """ 8 | 9 | import ast 10 | import json 11 | import sys 12 | from Crypto.Cipher import AES 13 | from typing import Optional 14 | 15 | # Insert result of ida_getkey script here 16 | KEY = bytes.fromhex('5c961198c441be7baafa61ded3fb39ea') 17 | 18 | 19 | def get_third_argument(filepath: str) -> Optional[bytes]: 20 | """Parses an obfuscated script and attempts to extract the encoded module passed to __pyarmor__().""" 21 | with open(filepath, 'r') as file: 22 | content = file.read() 23 | 24 | tree = ast.parse(content) 25 | 26 | # Function visitor to find the first function call 27 | class FunctionCallVisitor(ast.NodeVisitor): 28 | def __init__(self): 29 | self.first_function_call = None 30 | 31 | def visit_Call(self, node): 32 | if isinstance(node.func, ast.Name): # Only consider named function calls 33 | func_name = node.func.id 34 | self.first_function_call = (func_name, node.args) 35 | return # Stop after the first function call 36 | self.generic_visit(node) 37 | 38 | visitor = FunctionCallVisitor() 39 | visitor.visit(tree) 40 | 41 | if visitor.first_function_call: 42 | func_name, args = visitor.first_function_call 43 | if len(args) >= 3: 44 | if not isinstance(args[2], ast.Constant) or not isinstance(args[2].value, bytes): 45 | raise Exception("3rd argument is not bytes constant") 46 | return args[2].value 47 | return None 48 | 49 | 50 | def get_bytes_from_pyc(filepath: str) -> bytes: 51 | with open(filename, "rb") as fp: 52 | module = fp.read() 53 | 54 | # This is somewhat dirty, might not work for all versions. 55 | bytes_pos = module.find(b"__pyarmor__\x73") 56 | if bytes_pos == -1: 57 | raise Exception("Unable to locate pyarmor data in compiled module") 58 | 59 | bytes_pos += len(b"__pyarmor__\x73") 60 | armor_len = int.from_bytes(module[bytes_pos:bytes_pos+4], 'little') 61 | if armor_len < 0x200 or armor_len > 10 * 1024 * 1024: 62 | raise Exception(f"String length implausible: {armor_len}") 63 | 64 | return module[bytes_pos+4:bytes_pos+4+armor_len] 65 | 66 | 67 | def decrypt_gcm_without_tag(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes: 68 | """Decrypts AES in GCM mode while ignoring the authentication tag.""" 69 | cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) 70 | return cipher.decrypt(ciphertext) 71 | 72 | 73 | filename = sys.argv[1] 74 | if filename.endswith(".py") or filename.endswith(".pyc"): 75 | if filename.endswith(".py"): 76 | armor_bytes = get_third_argument(filename) 77 | if armor_bytes is None: 78 | raise Exception("Unable to find third __pyarmor__ argument") 79 | else: 80 | armor_bytes = get_bytes_from_pyc(filename) 81 | 82 | if armor_bytes[20] == 9: 83 | # BCC mode has two consecutive blobs. The first contains an ELF. 84 | nonce = armor_bytes[36:40] + armor_bytes[44:52] 85 | bcc_start = int.from_bytes(armor_bytes[28:32], 'little') 86 | bcc_end = int.from_bytes(armor_bytes[56:60], 'little') 87 | ciphertext = armor_bytes[bcc_start:bcc_end] 88 | 89 | plaintext = decrypt_gcm_without_tag(KEY, nonce, ciphertext) 90 | 91 | with open(filename + ".dec.elf", "wb") as fpw: 92 | fpw.write(plaintext[16:]) 93 | 94 | print(f"[!] Detected BCC mode! ELF saved as {filename}.dec.elf.") 95 | 96 | # Second part should be a "normal" type 8 blob. 97 | armor_bytes = armor_bytes[bcc_end:] 98 | 99 | nonce = armor_bytes[36:40] + armor_bytes[44:52] 100 | ciphertext = armor_bytes[int.from_bytes(armor_bytes[28:32], 'little'):] 101 | 102 | plaintext = decrypt_gcm_without_tag(KEY, nonce, ciphertext) 103 | 104 | with open(filename + ".dec", "wb") as fpw: 105 | fpw.write(plaintext) 106 | 107 | print(f"{filename}.dec saved. Now run analyze_crypted_code using the special Python interpreter.") 108 | 109 | elif filename.endswith(".py.dec") or filename.endswith(".pyc.dec"): 110 | with open(filename, "rb") as fp: 111 | module = bytearray(fp.read()) 112 | 113 | crypted_regions = json.load(open(filename + ".json")) 114 | 115 | for region in crypted_regions: 116 | start = region["ciphertext_offset"] + 0x20 # account for pyarmor module header 117 | size = region["ciphertext_size"] 118 | ciphertext = module[start:start+size] 119 | nonce = bytes.fromhex(region["nonce"]) 120 | 121 | plaintext = decrypt_gcm_without_tag(KEY, nonce, ciphertext) 122 | module[start:start+size] = plaintext 123 | 124 | with open(filename + "2", "wb") as fpw: 125 | fpw.write(module) 126 | 127 | else: 128 | print("Don't know what to do with this file type.") 129 | -------------------------------------------------------------------------------- /disassemble.py: -------------------------------------------------------------------------------- 1 | # To be run with customized python3! 2 | """ 3 | Small helper script that simply unmarshals and disassembles the input file. 4 | """ 5 | 6 | import dis 7 | import marshal 8 | import sys 9 | from io import BytesIO 10 | 11 | 12 | with open(sys.argv[1], "rb") as fp: 13 | fp.seek(0x20) 14 | data = fp.read() 15 | 16 | obj = marshal.load(BytesIO(data)) 17 | dis.dis(obj) 18 | -------------------------------------------------------------------------------- /ida/ida_annotate_bcc.py: -------------------------------------------------------------------------------- 1 | """ 2 | Minimum Python version: 3.9 3 | 4 | This IDA script adds comments in the decompiler view for all references to constants in 5 | BCC functions. 6 | 7 | IMPORTANT: For this to work, the pointer variable must be of type QWORD* in every function. 8 | The pointer in question is usually assigned at the top of the function: 9 | v4 = *(_QWORD **)(a1 + 8LL * *(int *)(a1 + 16) + 16); 10 | 11 | Then you'll see lots of references like v4[3], v4[4], v4[5], etc., which are targeted 12 | by this script. 13 | """ 14 | 15 | import idaapi 16 | import ida_funcs 17 | import ida_kernwin 18 | import ida_hexrays 19 | import idc 20 | import json 21 | from typing import Optional 22 | 23 | 24 | def open_json_file() -> Optional[list[dict]]: 25 | """Simple file selector function. Returns loaded list.""" 26 | path = ida_kernwin.ask_file(0, "*.json", "Select .elf.json file") 27 | if not path: 28 | print("No file selected.") 29 | return None 30 | with open(path, 'r') as f: 31 | return json.load(f) 32 | 33 | 34 | def create_and_rename_function(offset: int, name: str) -> None: 35 | if not ida_funcs.get_func(offset): 36 | if not ida_funcs.add_func(offset): 37 | print(f"Failed to create function at {hex(offset)}") 38 | return 39 | idc.set_name(offset, name, idc.SN_CHECK) 40 | 41 | 42 | class ConstsAnnotator(ida_hexrays.ctree_visitor_t): 43 | """ 44 | Tree visitor class that adds comments to statements that reference the constants. 45 | """ 46 | 47 | def __init__(self, cfunc, consts: list) -> None: 48 | super().__init__(ida_hexrays.CV_FAST) 49 | self.alias_map: dict[str, str] = {} 50 | self.consts_base = "" 51 | self.comments_added = 0 52 | self.cfunc = cfunc 53 | self.consts = consts 54 | 55 | def visit_insn(self, i) -> int: 56 | return 0 57 | 58 | def visit_expr(self, expr) -> int: 59 | if expr.op == ida_hexrays.cot_asg and expr.x and expr.y: 60 | # Look for v4 = *(...) 61 | if self.consts_base == "" and expr.y.op == ida_hexrays.cot_ptr and expr.y.x: 62 | # We could check if expr.y.x matches (a1 + 8 * *(int *)(a1 + 16) + 16), 63 | # but we're just going to assume the first ptr deref is the one we need. 64 | assigned_var = expr.x.v.idx if expr.x.op == ida_hexrays.cot_var else None 65 | if assigned_var: 66 | self.consts_base = assigned_var 67 | print(f"Found consts pointer v{assigned_var}") 68 | 69 | # Track alias like v1012 = v4 70 | if expr.x.op == ida_hexrays.cot_var and expr.y.op == ida_hexrays.cot_var: 71 | if expr.y.v.idx == self.consts_base: 72 | self.alias_map[expr.x.v.idx] = expr.y.v.idx 73 | 74 | # Look for pointer index accesses... 75 | if expr.op == ida_hexrays.cot_idx: 76 | if expr.y and expr.x.op == ida_hexrays.cot_var and expr.y.op == ida_hexrays.cot_num: 77 | varname = expr.x.v.idx 78 | index = expr.y.numval() 79 | # ...check if base is our pointer of interest 80 | base_var = self.alias_map.get(varname, varname) 81 | if base_var == self.consts_base and index >= 3 and (index - 3) < len(self.consts): 82 | # ...add comment 83 | comment = self.consts[index - 3] 84 | if expr.ea != idaapi.BADADDR: 85 | loc = ida_hexrays.treeloc_t() 86 | loc.ea = expr.ea 87 | # Add after semicolon; if the line doesn't have one, the comment will be orphaned :/ 88 | loc.itp = ida_hexrays.ITP_SEMI 89 | self.cfunc.set_user_cmt(loc, comment) 90 | self.comments_added += 1 91 | else: 92 | print(f"[!] ea not available for {expr.dstr()}, unable add comment {comment}") 93 | 94 | return 0 # Continue traversal 95 | 96 | 97 | def annotate_consts_in_decompilation(ea: int, consts: list) -> None: 98 | """Annotates the given function with the given constants.""" 99 | cfunc = ida_hexrays.decompile(ea) 100 | if not cfunc: 101 | print(f"Failed to decompile function at {hex(ea)}") 102 | return 103 | 104 | annotator = ConstsAnnotator(cfunc, consts) 105 | 106 | cfunc.get_eamap() # Ensure mapping is available 107 | annotator.apply_to(cfunc.body, None) 108 | 109 | cfunc.save_user_cmts() 110 | print(f"Added {annotator.comments_added} comments to function at {hex(ea)}") 111 | 112 | 113 | def main() -> None: 114 | hexrays_avail = ida_hexrays.init_hexrays_plugin() 115 | if not hexrays_avail: 116 | print("Hex-Rays plugin not available - won't annotate") 117 | 118 | data = open_json_file() 119 | if data is None: 120 | return 121 | 122 | for entry in data: 123 | offset = entry['offset'] 124 | name = entry['name'] 125 | consts = entry['consts'] 126 | create_and_rename_function(offset, name) 127 | if hexrays_avail: 128 | annotate_consts_in_decompilation(offset, consts) 129 | 130 | 131 | main() 132 | -------------------------------------------------------------------------------- /ida/ida_getkey.py: -------------------------------------------------------------------------------- 1 | """IDAPython implementation of "get_key_via_md5" for statically obtaining key.""" 2 | 3 | import ida_bytes 4 | import hashlib 5 | 6 | # This string is a unique license ID of the person who obfuscated the script, in the free version it would always be 000000 and in paid versions its unique per person 7 | PYARMOR_STRING = b"pyarmor-vax-007106\x00\x00" 8 | 9 | # References to these are in the "get_key_via_md5" function 10 | """ 11 | md5_process( 12 | v6, 13 | (char *)&unk_64944060 + g_dword_64944050_0x20_rsaoffset, 14 | (unsigned int)g_dword_64944054_0x10E_rsakeylen);// rsa key 15 | """ 16 | INFO_BLOB_ADDR = 0x64944060 17 | # First xmmword that is xored: 18 | # xmmword_64948140 = (__int128)_mm_xor_si128(_mm_load_si128((const __m128i *)&xmmword_64948140), si128); 19 | RSA_KEY2_ADDR = 0x64948140 20 | # From a global dword passed to md5_process 21 | RSA_KEY2_SIZE = 0x10E 22 | # Byte value that RSA_KEY2 is xored with 23 | RSA_XOR_KEY = 0xF1 24 | 25 | 26 | md = hashlib.md5() 27 | md.update(PYARMOR_STRING) 28 | 29 | rsakey_size = int.from_bytes(ida_bytes.get_bytes(INFO_BLOB_ADDR - 0xC, 4), 'little') 30 | rsakey = ida_bytes.get_bytes(INFO_BLOB_ADDR + 0x20, rsakey_size) 31 | 32 | sig_offset = int.from_bytes(ida_bytes.get_bytes(INFO_BLOB_ADDR - 0x8, 4), 'little') 33 | hashed_area_size = int.from_bytes(ida_bytes.get_bytes(INFO_BLOB_ADDR + sig_offset + 4, 4), 'little') 34 | hashed_area = ida_bytes.get_bytes(INFO_BLOB_ADDR + sig_offset + 0x20, hashed_area_size) 35 | 36 | rsakey2 = bytes([b ^ RSA_XOR_KEY for b in ida_bytes.get_bytes(RSA_KEY2_ADDR, RSA_KEY2_SIZE)]) 37 | 38 | md.update(rsakey) 39 | md.update(hashed_area) 40 | md.update(rsakey2) 41 | 42 | print(md.hexdigest()) 43 | -------------------------------------------------------------------------------- /libtomcrypt.sig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GDATAAdvancedAnalytics/Pyarmor-Tooling/9bd40a4c88d857cc508d809bee2bc6e1a3bf16ac/libtomcrypt.sig -------------------------------------------------------------------------------- /py311/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:12 2 | 3 | RUN apt-get update && apt-get install -y build-essential gdb lcov pkg-config \ 4 | libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \ 5 | libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \ 6 | lzma lzma-dev tk-dev uuid-dev zlib1g-dev git python3 7 | 8 | ADD armor-marshal-311.patch /armor-marshal-311.patch 9 | 10 | RUN git clone --depth 1 --branch 3.11 https://github.com/python/cpython.git && \ 11 | cd cpython && \ 12 | patch -p1 -i ../armor-marshal-311.patch && \ 13 | ./configure && \ 14 | make regen-all 15 | 16 | ENTRYPOINT [ "/cpython/python" ] 17 | -------------------------------------------------------------------------------- /py311/armor-marshal-311.patch: -------------------------------------------------------------------------------- 1 | diff --git a/Include/cpython/code.h b/Include/cpython/code.h 2 | index 7006060..f9ac29c 100644 3 | --- a/Include/cpython/code.h 4 | +++ b/Include/cpython/code.h 5 | @@ -84,6 +84,7 @@ typedef uint16_t _Py_CODEUNIT; 6 | PyObject *co_localsplusnames; /* tuple mapping offsets to names */ \ 7 | PyObject *co_localspluskinds; /* Bytes mapping to local kinds (one byte \ 8 | per variable) */ \ 9 | + PyObject *co_pyarmor_data; /* packed co_consts patch mapping */ \ 10 | PyObject *co_filename; /* unicode (where it was loaded from) */ \ 11 | PyObject *co_name; /* unicode (name, for reference) */ \ 12 | PyObject *co_qualname; /* unicode (qualname, for reference) */ \ 13 | diff --git a/Include/internal/pycore_code.h b/Include/internal/pycore_code.h 14 | index 3a24a65..9552958 100644 15 | --- a/Include/internal/pycore_code.h 16 | +++ b/Include/internal/pycore_code.h 17 | @@ -181,6 +181,8 @@ struct _PyCodeConstructor { 18 | PyObject *consts; 19 | PyObject *names; 20 | 21 | + PyObject *pyarmor_data; 22 | + 23 | /* mapping frame offsets to information */ 24 | PyObject *localsplusnames; // Tuple of strings 25 | PyObject *localspluskinds; // Bytes object, one byte per variable 26 | diff --git a/Objects/codeobject.c b/Objects/codeobject.c 27 | index c4a0d9a..d15e416 100644 28 | --- a/Objects/codeobject.c 29 | +++ b/Objects/codeobject.c 30 | @@ -315,6 +315,9 @@ init_code(PyCodeObject *co, struct _PyCodeConstructor *con) 31 | Py_INCREF(con->localspluskinds); 32 | co->co_localspluskinds = con->localspluskinds; 33 | 34 | + Py_XINCREF(con->pyarmor_data); 35 | + co->co_pyarmor_data = con->pyarmor_data; 36 | + 37 | co->co_argcount = con->argcount; 38 | co->co_posonlyargcount = con->posonlyargcount; 39 | co->co_kwonlyargcount = con->kwonlyargcount; 40 | @@ -1624,6 +1627,7 @@ code_dealloc(PyCodeObject *co) 41 | 42 | Py_XDECREF(co->co_consts); 43 | Py_XDECREF(co->co_names); 44 | + Py_XDECREF(co->co_pyarmor_data); 45 | Py_XDECREF(co->co_localsplusnames); 46 | Py_XDECREF(co->co_localspluskinds); 47 | Py_XDECREF(co->co_filename); 48 | @@ -1795,6 +1799,7 @@ static PyMemberDef code_memberlist[] = { 49 | {"co_nlocals", T_INT, OFF(co_nlocals), READONLY}, 50 | {"co_consts", T_OBJECT, OFF(co_consts), READONLY}, 51 | {"co_names", T_OBJECT, OFF(co_names), READONLY}, 52 | + {"co_pyarmor_data", T_OBJECT, OFF(co_pyarmor_data), READONLY}, 53 | {"co_filename", T_OBJECT, OFF(co_filename), READONLY}, 54 | {"co_name", T_OBJECT, OFF(co_name), READONLY}, 55 | {"co_qualname", T_OBJECT, OFF(co_qualname), READONLY}, 56 | diff --git a/Python/marshal.c b/Python/marshal.c 57 | index 29f3bab..8a867db 100644 58 | --- a/Python/marshal.c 59 | +++ b/Python/marshal.c 60 | @@ -1365,6 +1365,7 @@ r_object(RFILE *p) 61 | PyObject *code = NULL; 62 | PyObject *consts = NULL; 63 | PyObject *names = NULL; 64 | + PyObject *pyarmor_data = NULL; 65 | PyObject *localsplusnames = NULL; 66 | PyObject *localspluskinds = NULL; 67 | PyObject *filename = NULL; 68 | @@ -1431,6 +1432,15 @@ r_object(RFILE *p) 69 | if (exceptiontable == NULL) 70 | goto code_error; 71 | 72 | + if ((flags & 0x20000000) != 0) { 73 | + int armor_len = r_byte(p); 74 | + if (armor_len) { 75 | + const char *extradata = r_string(armor_len, p); 76 | + printf("Got pyarmor-specific data of length %d\n", armor_len); 77 | + pyarmor_data = PyBytes_FromStringAndSize(extradata, armor_len); 78 | + } 79 | + } 80 | + 81 | struct _PyCodeConstructor con = { 82 | .filename = filename, 83 | .name = name, 84 | @@ -1443,6 +1453,7 @@ r_object(RFILE *p) 85 | 86 | .consts = consts, 87 | .names = names, 88 | + .pyarmor_data = pyarmor_data, 89 | 90 | .localsplusnames = localsplusnames, 91 | .localspluskinds = localspluskinds, 92 | @@ -1475,6 +1486,7 @@ r_object(RFILE *p) 93 | Py_XDECREF(code); 94 | Py_XDECREF(consts); 95 | Py_XDECREF(names); 96 | + Py_XDECREF(pyarmor_data); 97 | Py_XDECREF(localsplusnames); 98 | Py_XDECREF(localspluskinds); 99 | Py_XDECREF(filename); 100 | -------------------------------------------------------------------------------- /py312/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:12 2 | 3 | RUN apt-get update && apt-get install -y build-essential gdb lcov pkg-config \ 4 | libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \ 5 | libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \ 6 | lzma lzma-dev tk-dev uuid-dev zlib1g-dev git python3 7 | 8 | ADD armor-marshal.patch /armor-marshal.patch 9 | 10 | RUN git clone --depth 1 --branch 3.12 https://github.com/python/cpython.git && \ 11 | cd cpython && \ 12 | patch -p1 -i ../armor-marshal.patch && \ 13 | ./configure && \ 14 | make regen-all 15 | 16 | ENTRYPOINT [ "/cpython/python" ] 17 | -------------------------------------------------------------------------------- /py312/armor-marshal.patch: -------------------------------------------------------------------------------- 1 | diff --git a/Include/cpython/code.h b/Include/cpython/code.h 2 | index 81741c5..ce049bc 100644 3 | --- a/Include/cpython/code.h 4 | +++ b/Include/cpython/code.h 5 | @@ -155,6 +155,7 @@ typedef struct { 6 | PyObject *co_localsplusnames; /* tuple mapping offsets to names */ \ 7 | PyObject *co_localspluskinds; /* Bytes mapping to local kinds (one byte \ 8 | per variable) */ \ 9 | + PyObject *co_pyarmor_data; /* packed co_consts patch mapping */ \ 10 | PyObject *co_filename; /* unicode (where it was loaded from) */ \ 11 | PyObject *co_name; /* unicode (name, for reference) */ \ 12 | PyObject *co_qualname; /* unicode (qualname, for reference) */ \ 13 | diff --git a/Include/internal/pycore_code.h b/Include/internal/pycore_code.h 14 | index 92e0a8b..9e47ac9 100644 15 | --- a/Include/internal/pycore_code.h 16 | +++ b/Include/internal/pycore_code.h 17 | @@ -170,6 +170,8 @@ struct _PyCodeConstructor { 18 | PyObject *consts; 19 | PyObject *names; 20 | 21 | + PyObject *pyarmor_data; 22 | + 23 | /* mapping frame offsets to information */ 24 | PyObject *localsplusnames; // Tuple of strings 25 | PyObject *localspluskinds; // Bytes object, one byte per variable 26 | diff --git a/Include/opcode.h b/Include/opcode.h 27 | index 9806511..c8f9fb8 100644 28 | --- a/Include/opcode.h 29 | +++ b/Include/opcode.h 30 | @@ -261,7 +261,7 @@ extern "C" { 31 | #define NB_INPLACE_XOR 25 32 | 33 | /* Defined in Lib/opcode.py */ 34 | -#define ENABLE_SPECIALIZATION 1 35 | +#define ENABLE_SPECIALIZATION 0 36 | 37 | #define IS_PSEUDO_OPCODE(op) (((op) >= MIN_PSEUDO_OPCODE) && ((op) <= MAX_PSEUDO_OPCODE)) 38 | 39 | diff --git a/Lib/opcode.py b/Lib/opcode.py 40 | index 6bb2f1c..bf330ac 100644 41 | --- a/Lib/opcode.py 42 | +++ b/Lib/opcode.py 43 | @@ -34,7 +34,7 @@ 44 | hasexc = [] 45 | 46 | 47 | -ENABLE_SPECIALIZATION = True 48 | +ENABLE_SPECIALIZATION = False 49 | 50 | def is_pseudo(op): 51 | return op >= MIN_PSEUDO_OPCODE and op <= MAX_PSEUDO_OPCODE 52 | diff --git a/Objects/codeobject.c b/Objects/codeobject.c 53 | index 6f7b8b5..6adab97 100644 54 | --- a/Objects/codeobject.c 55 | +++ b/Objects/codeobject.c 56 | @@ -415,6 +415,9 @@ init_code(PyCodeObject *co, struct _PyCodeConstructor *con) 57 | co->co_localsplusnames = Py_NewRef(con->localsplusnames); 58 | co->co_localspluskinds = Py_NewRef(con->localspluskinds); 59 | 60 | + Py_XINCREF(con->pyarmor_data); 61 | + co->co_pyarmor_data = con->pyarmor_data; 62 | + 63 | co->co_argcount = con->argcount; 64 | co->co_posonlyargcount = con->posonlyargcount; 65 | co->co_kwonlyargcount = con->kwonlyargcount; 66 | @@ -1726,6 +1729,7 @@ code_dealloc(PyCodeObject *co) 67 | 68 | Py_XDECREF(co->co_consts); 69 | Py_XDECREF(co->co_names); 70 | + Py_XDECREF(co->co_pyarmor_data); 71 | Py_XDECREF(co->co_localsplusnames); 72 | Py_XDECREF(co->co_localspluskinds); 73 | Py_XDECREF(co->co_filename); 74 | @@ -1910,6 +1914,7 @@ static PyMemberDef code_memberlist[] = { 75 | {"co_nlocals", T_INT, OFF(co_nlocals), READONLY}, 76 | {"co_consts", T_OBJECT, OFF(co_consts), READONLY}, 77 | {"co_names", T_OBJECT, OFF(co_names), READONLY}, 78 | + {"co_pyarmor_data", T_OBJECT, OFF(co_pyarmor_data), READONLY}, 79 | {"co_filename", T_OBJECT, OFF(co_filename), READONLY}, 80 | {"co_name", T_OBJECT, OFF(co_name), READONLY}, 81 | {"co_qualname", T_OBJECT, OFF(co_qualname), READONLY}, 82 | diff --git a/Python/marshal.c b/Python/marshal.c 83 | index 3fc3f89..0c59abe 100644 84 | --- a/Python/marshal.c 85 | +++ b/Python/marshal.c 86 | @@ -1365,6 +1365,7 @@ r_object(RFILE *p) 87 | PyObject *code = NULL; 88 | PyObject *consts = NULL; 89 | PyObject *names = NULL; 90 | + PyObject *pyarmor_data = NULL; 91 | PyObject *localsplusnames = NULL; 92 | PyObject *localspluskinds = NULL; 93 | PyObject *filename = NULL; 94 | @@ -1431,6 +1432,15 @@ r_object(RFILE *p) 95 | if (exceptiontable == NULL) 96 | goto code_error; 97 | 98 | + if ((flags & 0x20000000) != 0) { 99 | + int armor_len = r_byte(p); 100 | + if (armor_len) { 101 | + const char *extradata = r_string(armor_len, p); 102 | + printf("Got pyarmor-specific data of length %d\n", armor_len); 103 | + pyarmor_data = PyBytes_FromStringAndSize(extradata, armor_len); 104 | + } 105 | + } 106 | + 107 | struct _PyCodeConstructor con = { 108 | .filename = filename, 109 | .name = name, 110 | @@ -1443,6 +1453,7 @@ r_object(RFILE *p) 111 | 112 | .consts = consts, 113 | .names = names, 114 | + .pyarmor_data = pyarmor_data, 115 | 116 | .localsplusnames = localsplusnames, 117 | .localspluskinds = localspluskinds, 118 | @@ -1475,6 +1486,7 @@ r_object(RFILE *p) 119 | Py_XDECREF(code); 120 | Py_XDECREF(consts); 121 | Py_XDECREF(names); 122 | + Py_XDECREF(pyarmor_data); 123 | Py_XDECREF(localsplusnames); 124 | Py_XDECREF(localspluskinds); 125 | Py_XDECREF(filename); 126 | -------------------------------------------------------------------------------- /screenshots/pyarmor_runtime_key_func.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GDATAAdvancedAnalytics/Pyarmor-Tooling/9bd40a4c88d857cc508d809bee2bc6e1a3bf16ac/screenshots/pyarmor_runtime_key_func.png --------------------------------------------------------------------------------