{
	"id": "8ac01571-d4c8-43a3-9d7e-27049129aa5a",
	"created_at": "2026-04-06T00:09:36.312145Z",
	"updated_at": "2026-04-10T13:12:34.20354Z",
	"deleted_at": null,
	"sha1_hash": "459c15f5cac30cbac9dc88fa467b4b2305f7080f",
	"title": "Unpacking Pyarmor v8+ scripts | cyber.wtf",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 618766,
	"plain_text": "Unpacking Pyarmor v8+ scripts | cyber.wtf\r\nArchived: 2026-04-05 18:32:42 UTC\r\nIntro\r\nOn a rainy Friday around lunchtime, we received a phishing email saying we had an unpaid invoice, with an\r\nattached SVG file. We chose to analyze it as an exercise, with the goal to burn the attackers’ C2 IP addresses and\r\nmalware samples. But what was planned as a Friday afternoon exercise turned into a journey deep down the rabbit\r\nhole…\r\nMalware dropper\r\nWhen opening the .SVG file in a web browser, the contained JavaScript code is executed, which extracts a .HTM\r\nfile from a base64 blob and “downloads” it. The .HTM file shows the user a blurred document, roughly looking\r\nlike an invoice, overlaid with a message telling the user that the browser does not support the correct display of\r\nthe message and the user should click the “Open” button to display the file locally, as you can see in figure 1:\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 1 of 16\n\nFigure 1: Screenshot of the downloaded .HTM file\r\nThe “Open” button is linked to a JavaScript function that opens the Windows Search with a WebDAV path to the\r\nattackers’ server, as one can see in the code below.\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 2 of 16\n\nFigure 2: JavaScript code abusing Windows Search\r\nThus, we have found the first domain used by the attackers: binary-acceptance-hotel-difficult[.]trycloudflare[.]com .\r\nFollowing the WebDAV path, we found a simple file listing, showing a folder called ge , as well as the files\r\nlamoor.vbs and WSJ25F.bat . Unfortunately, we did not investigate the content of ge further at this point, and\r\nit was not available anymore when revisiting the server later. lamoor.vbs is a simple VBScript that downloads\r\nand executes a file also called WSJ25F.bat from the URL\r\nmsc4dfl1ed7eb485ad6ahelixpflanzen[.]de@5029\\DavWWWRoot\\WSJ25F.bat , which has the same content as the\r\nWSJ25F.bat on the server investigated here.\r\nSo inside lamoor.vbs we have found a second domain used by the attackers:\r\nmsc4dfl1ed7eb485ad6ahelixpflanzen[.]de .\r\nWSJ25F.bat is an obfuscated batch script, which begins as follows:\r\n:: ▄▄▄▄ ▄▄▄ ▄▄▄█████▓ ▄████▄ ██░ ██ ██████ ██░ ██ ██▓▓█\r\n:: ▓█████▄ ▒████▄ ▓ ██▒ ▓▒▒██▀ ▀█ ▓██░ ██▒▒██ ▒ ▓██░ ██▒▓██▒\r\n:: ▒██▒ ▄██▒██ ▀█▄ ▒ ▓██░ ▒░▒▓█ ▄ ▒██▀▀██░░ ▓██▄ ▒██▀▀██░▒██▒\r\n:: ▒██░█▀ ░██▄▄▄▄██░ ▓██▓ ░ ▒▓▓▄ ▄██▒░▓█ ░██ ▒ ██▒░▓█ ░██ ░█\r\n:: ░▓█ ▀█▓ ▓█ ▓██▒ ▒██▒ ░ ▒ ▓███▀ ░░▓█▒░██▓▒██████▒▒░▓█▒░██▓░\r\n:: ░▒▓███▀▒ ▒▒ ▓▒█░ ▒ ░░ ░ ░▒ ▒ ░ ▒ ░░▒░▒▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒░▓ ░░\r\n:: ▒░▒ ░ ▒ ▒▒ ░ ░ ░ ▒ ▒ ░▒░ ░░ ░▒ ░ ░ ▒ ░▒░ ░ ▒ ░ ░ ░ ░░ ░ ▒\r\n:: ░ ░ ░ ▒ ░ ░ ░ ░░ ░░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░\r\n:: ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░\r\n:: ░ ░ ░\r\n::\r\n:: !! Obfuscated By Batchsield !!\r\n@%RlreyKGxSD%e%KDuXrBAYVA%c%cr%h%fOG%o%dXTcycKBB% %jenTylt%o%svFuxrt%f%wJQUX%f%urSNrd%\r\ns%mEVT%e%Hujf%t%q%l%BFWpZo%o%bOtHT%c%I%a%GAUPbqqJAU%l%I%\r\n:: Function to search for and open a PDF file in the Downloads folder\r\n:%PxD%:%U% %iXXMVGf%F%ylMX%u%dJuIOerMzw%n%Vyn%c%gZAW%t%ueGJ%i%DoAK%o%Emx%n%OQeP% %sYsxJnvlz%t%jsTSJ%o%Ys% %oFjxC\r\nAs one can see, the obfuscation mainly consists of inserting multiple non-defined variables into the code (and yes,\r\nthey can’t even spell their own obfuscator’s name properly). Interestingly, the comments inside the script are not\r\nobfuscated at all. When searching for “Batchshield deobfuscator” on the internet, one can easily find tools that can\r\ndeobfuscate this script. However, some of those tools remove all the variables, including the legitimate ones!\r\nThe content of the deobfuscated script is only briefly described, as the focus of this blog post is on the later stages\r\nof the malware.\r\nOpen all PDF files inside the Downloads folder\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 3 of 16\n\nDownload JAAPW.zip and MSVP.zip from hxxp://qed245t3kreiscryoz-gueterslohewr33w[.]de[:]7719\r\nExtract the ZIP files to the paths \\Downloads\\Support and \\Downloads\\OneDrive\r\necho Running Python scripts...\r\ncd /d \"\\Downloads\\OneDrive\\Python\\Python312\"\r\npython.exe BArown.py\r\npython.exe CASrest.py\r\npython.exe DXreame.py\r\npython.exe ASTRILNOV1.py\r\nDownload NFC.bat from the same URL and move it to the user’s startup folder\r\nDelete temporary ZIP files\r\nThe startup file changes the directory to %Userprofile%\\Downloads\\Support\\Python312 and executes the Python\r\nscripts EAdate.py , FAScis.py , GXrop.py and HPUope.py , which probably contain the actual payload (?),\r\nusing the custom python interpreter.\r\nAnd here is a third domain used by the attackers: qed245t3kreiscryoz-gueterslohewr33w[.]de . All the domains\r\nand download URLs were reported to URLhaus. This led to malware dropper domains landing on several block\r\nlists within only a few hours after the malware distribution campaign started.\r\nBut what about the Python scripts, the actual payload of the malware campaign? Upon opening one of the scripts,\r\nwe were greeted by this monstrosity:\r\n# Pyarmor 9.0.7 (pro), 007106, non-profit, 2025-01-08T19:48:44.467478\r\nfrom pyarmor_runtime_007106 import __pyarmor__\r\n__pyarmor__(__name__, __file__, b'PY007106\\x00\\x03\\x0c\\x00\\xcb\\r\\r\\n\\x80\\x00\\x01\\x00\\x08\\x00\\x00\\x00\\x04\\x00\\x00\r\nThe bytes string goes on like that for the rest of the file.\r\nThis successfully nerd-sniped our malware analysis team ;)\r\nPyarmor is a product for protecting Python scripts from reverse engineering. It also offers licensing features, such\r\nas binding scripts to specific hardware or outfitting scripts with a kill date. Sadly, as is often the case with such\r\nproducts, it is also occasionally abused by malware in order to hide malicious code.\r\nThere are a couple tools out there for unpacking Pyarmor, such as PyArmor-Unpacker, but they’re not compatible\r\nwith the latest v8/v9 versions. Other tooling that does claim to be compatible with v8+ uses a rather simplistic\r\nmemory dumping technique, where it’s not guaranteed that all code (or any bytecode at all) will actually be\r\ndecrypted. The reason will become clear later in this post.\r\nIn the following we are going to provide insights into how Pyarmor works and offer some scripts that help make\r\nthe original code visible via static unpacking. It should also be noted that Pyarmor supports multiple protection\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 4 of 16\n\nmodes, including one called bcc mode where Python code is compiled into native code. This poses additional\r\nchallenges that are not covered here, but the same basic principles and crypto primitives should be used.\r\nPlease note that we will explicitly not provide an all-in-one unpacking solution - if that’s why you’ve come here,\r\nyou might as well stop reading right now.\r\nBasic functionality\r\nAs can be seen in the snippet shown earlier, all the script does is importing a function called __pyarmor__ and\r\ncalling it. pyarmor_runtime_007106 is a directory in the Python interpreter directory that was shipped with the\r\nmalware. It contains a native module written in C called pyarmor_runtime.pyd (essentially a 64-bit DLL) and a\r\nsimple __init__ script that again imports __pyarmor__ from .pyarmor_runtime . This is so that the main\r\nscript can import the function from pyarmor_runtime_007106 without a further indirection.\r\nThe native module exports a single function that is called by the Python interpreter for initialization purposes. It\r\ncreates a PyModule object by passing the following structure to PyModule_Create2 :\r\nFigure 3: Pyarmor PyModule struct, complete with helpful doc strings\r\nFrom this, we can glean several pieces of information:\r\n1. The user data portion of the module spans 0xC0 bytes\r\n2. The module exposes just a single method\r\n3. We get the function pointer for the native __pyarmor__ implementation\r\nA bit further down in the PyInit export function, the following code can be found:\r\n *(_DWORD *)(v21 + 48) = 8;\r\n *(_QWORD *)(v21 + 56) = 0LL;\r\n *(_QWORD *)(v21 + 32) = \"C_ASSERT_ARMORED_INDEX\";\r\n *(_QWORD *)(v21 + 40) = c_assert_armored; // func\r\n v23 = PyCMethod_New(v21 + 32, module, module, 0LL);\r\n if ( !v23 )\r\n goto LABEL_58;\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 5 of 16\n\nmd_state-\u003epMethods[1] = v23;\r\n *(_DWORD *)(v21 + 80) = 8;\r\n *(_QWORD *)(v21 + 88) = 0LL;\r\n *(_QWORD *)(v21 + 64) = \"C_ENTER_CO_OBJECT_INDEX\";\r\n *(_QWORD *)(v21 + 72) = c_enter_co_object; // func\r\n v24 = PyCMethod_New(v21 + 64, module, module, 0LL);\r\n if ( !v24\r\n || (md_state-\u003epMethods[2] = v24,\r\n *(_DWORD *)(v21 + 112) = 8,\r\n *(_QWORD *)(v21 + 120) = 0LL,\r\n *(_QWORD *)(v21 + 96) = \"C_LEAVE_CO_OBJECT_INDEX\",\r\n *(_QWORD *)(v21 + 104) = c_leave_co_object, // func\r\n (v25 = PyCMethod_New(v21 + 96, module, module, 0LL)) == 0) )\r\nThis registers three additional C functions that apparently work on code ( co ) objects. Code objects are low-level\r\nrepresentations of compiled Python bytecode, encompassing all details required for code execution. For example,\r\nthe main body of a script is a code object, as well as each respective function defined within the body. The enter\r\nand leave functions will become important later on.\r\nIn terms of strings, the library contains quite a few cryptography-related strings, including source file paths. A\r\nquick search revealed that we’re dealing with libtomcrypt, which was statically linked into the library. We created\r\na signature file for this library so that we can automatically name most functions belonging to libtomcrypt in the\r\nPyarmor module. For good results, it’s important to match the library version and compiler as good as possible\r\nwhen creating the signatures. According to strings in the .rdata section, both GCC 6.4.0 and 7.4.0 were used for\r\ncompilation. After some trial and error, we got a good match with libtomcrypt v1.18.2 and GCC 6.4.0. The\r\nresulting FLIRT signature is part of the GitHub repo we published as part of this work.\r\nQuick recap of what we have so far:\r\nWe know where calls to __pyarmor__ land in the native module\r\nWe found functions that deal with entering/leaving code objects\r\nWe can see all places where libtomcrypt is used for cryptographic operations in the module\r\nCryptography\r\nPyarmor uses libtomcrypt for the following purposes:\r\nVerifying some RSA signature (this is nothing we really care about)\r\nDeriving a key with MD5\r\nCiphering data with AES-GCM (Galois Counter Mode)\r\nKey derivation\r\nThe key derivation function is called towards the end of the PyInit export, after the RSA verification and some\r\nmore checks on the module filename.\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 6 of 16\n\nvoid get_key_via_md5(__int64 signature, __int64 digest)\r\n{\r\n __m128i si128; // xmm0\r\n char v6[456]; // [rsp+20h] [rbp-1C8h] BYREF\r\n md5_init(v6);\r\n md5_process(v6, aPyarmorVax, 20LL); // \"pyarmor-vax-007106\\x00\\x00\"\r\n md5_process(\r\n v6,\r\n (char *)\u0026unk_64944060 + g_dword_64944050_0x20_rsaoffset,\r\n (unsigned int)g_dword_64944054_0x10E_rsakeylen); // rsa key\r\n md5_process(v6, signature + 32, *(unsigned int *)(signature + 4));\r\n si128 = _mm_load_si128((const __m128i *)\u0026xmmword_649499C0); // vector with all bytes set to 0xF1\r\n xmmword_64948140 = (__int128)_mm_xor_si128(_mm_load_si128((const __m128i *)\u0026xmmword_64948140), si128);\r\n /* \u003csnip\u003e - more XORs with 0xF1 */\r\n byte_6494824A ^= 0xF1u;\r\n byte_6494824B ^= 0xF1u;\r\n LOBYTE(word_6494824C) = word_6494824C ^ 0xF1;\r\n HIBYTE(word_6494824C) ^= 0xF1u;\r\n md5_process(v6, \u0026xmmword_64948140, 0x10ELL);\r\n memset(\u0026xmmword_64948140, 0, 0x108uLL);\r\n *((_DWORD *)\u0026xmmword_64948140 + 66) = 0;\r\n word_6494824C = 0;\r\n md5_done(v6, digest);\r\n}\r\nEssentially all data that goes into this key computation is static. The only slightly “dynamic” part is the region of\r\n0x10E bytes that is XOR-decoded at runtime, and then cleared after being processed by MD5 - it seems to be yet\r\nanother RSA key, apart from the plain RSA key that is being hashed in the second call to md5_process . The\r\nsignature parameter, passed from the caller, is located in the same general unk_64944060 memory region in\r\nthe .data section.\r\nSo to obtain the key specific to your Pyarmor runtime, you can either attach a debugger to a Python interpreter and\r\nbreak after the derivation function has been called, or you can compute it statically. We wrote an IDAPython script\r\nthat follows the latter route. With some tinkering, the same could be achieved using pefile or similar libraries.\r\nThe resulting digest is then directly used as AES-128 key.\r\nGCM\r\nThe use of GCM in the native module is somewhat bizarre for multiple reasons.\r\n datasize = *(_DWORD *)(pData + 32);\r\n v5 = *(_DWORD *)(pData + 36);\r\n cipherdata = (int *)(pData + *(unsigned int *)(pData + 28));\r\n if ( (*(_BYTE *)(pData + 37) \u0026 7) != 0 )\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 7 of 16\n\n{\r\n codecrypto = a1-\u003ecodecrypto;\r\n *(_DWORD *)(pData + 40) = v5;\r\n gcmobj = (gcm *)(codecrypto + 24);\r\n cryptres = gcm_reset((gcm *)(codecrypto + 24));\r\n if ( cryptres\r\n || (cryptres = gcm_add_iv(gcmobj, pData + 40, 12u)) != 0\r\n || (cryptres = gcm_add_aad_0(gcmobj, 0LL, 0)) != 0\r\n || (cryptres = gcm_process(gcmobj, (__int64)cipherdata, datasize, (__int64)cipherdata, 1)) != 0 )\r\n {\r\n // handle error and return or exit process\r\n }\r\n }\r\nYou can see multiple GCM functions being used here that look like they should be from libtomcrypt, however that\r\nis not directly the case. These stem from a different compilation unit using a smaller gcm state structure than\r\nlibtomcrypt. The struct contains keys for different cipher types at its beginning, and some of them were omitted in\r\nthis variant. In the case of gcm_add_aad , the “normal” libtomcrypt function is in fact also present in the binary,\r\nwhich is why we have a _0 suffix here. The special functions do, however, make direct use of various primitives\r\nfrom the normal libtomcrypt, such as gcm_mult_h .\r\nAnother thing to note is the absence of authentication tag handling.\r\n\"I heard GCM is a good cipher mode to use\"\r\nThe point of using GCM is to prevent manipulation of the ciphertext, i.e., to ensure that decryption returns the\r\nexact data that was encrypted. Otherwise, it’s possible for someone to flip a couple bits in the ciphertext in the\r\nhopes of achieving interesting changes in the plaintext. Without storing and comparing the authentication tag, no\r\nguarantees about the output data are made, and one might as well have used any other cipher mode such as CTR.\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 8 of 16\n\nIt’s not entirely clear why Pyarmor chose GCM, although their choices do have a noteworthy consequence: Some\r\ntools and libraries outright refuse to decrypt anything in GCM mode if you don’t have an authentication tag. For\r\nexample, it’s not possible to use Cyberchef in this particular case.\r\nLastly, the nonce (or initialization vector) handling is slightly weird. While the size is the GCM default of 12\r\nbytes, it is not stored in one contiguous piece. You can see that the dword at + 40 is replaced with the dword at\r\n+ 36 .\r\nThe decryption snippet shown in this section is used in various places by the native module whenever it needs to\r\ndecrypt any amount of data, for example the huge bytes string we saw in the beginning is largely comprised of\r\nGCM-ciphertext.\r\nNow we can decrypt everything, right?\r\nYou can think of the huge bytes string passed to __pyarmor__ as an encrypted .pyc file with a custom header.\r\nThe header has the following structure:\r\nOffset Description Example\r\n0:8 Module magic (must match native module identifier) PY007106\r\n9 Python major version 3\r\n10 Python minor version 12\r\n12:16 .pyc magic for specific Python version CB 0D 0D 0A\r\n20 Protection type? 9 for bcc mode, otherwise 8 8\r\n28:32 Ciphertext offset 64\r\n32:36 Ciphertext size 496093\r\n36:40\r\nIV bytes [0:4]; individual bytes contain flags; also used as\r\n“validation” dword for decrypted data\r\n12 09 06 00\r\n37\r\nAny of the first 3 bits in this byte must be 1 for GCM to be\r\napplied\r\n9\r\n40:44 Fake IV bytes [0:4] 2C FE 35 B2\r\n44:52 IV bytes [4:12]\r\n83 6F 1C 69 1D 3F\r\nAB 73\r\ndynamic Ciphertext, at offset given above  \r\nFigure 4: Structure of bytes string passed to __pyarmor__()\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 9 of 16\n\nApplying GCM decryption yields the following:\r\nFigure 5: First decryption result\r\nNow this doesn’t look too shabby, we can see some strings and further down (outside the range shown here), we\r\neven get interesting ones like key and rc4_decrypt . One thing that immediately caught our eye is that there is\r\nstill some data that seems to have pretty high entropy, especially at the ranges 0x60..0x90 and after 0x140. Thus,\r\nthe next goal is going to be understanding what context the still-encrypted data appears in.\r\nThe decrypted data has another Pyarmor-specific header, which helpfully comes with a length prefix (0x20). We\r\ncan see a repetition of 12 09 06 00 , which is compared with the value from the outer header. Afterwards\r\n(starting from 0x20), we have data that is passed into PyMarshal_ReadObjectFromString() .\r\nPython marshaling\r\nThe Python interpreter uses the built-in marshal module whenever it needs to serialize or deserialize compiled\r\nscripts. It essentially implements a Python-specific binary format for basic types like integers, strings, floats,\r\ntuples, lists, and most importantly, code objects. The format is not stable and tends to vary with each Python\r\ninterpreter version. Thus, to have any chance at all, the data must be loaded with the exact Python version it was\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 10 of 16\n\nwritten with. When we tried this with python3.12, it failed with a “bad marshal data (unknown type code)” error.\r\nUh oh….\r\nIn the previous section, we mentioned a PyMarshal function that is used. We omitted the fact that this function is\r\nnot imported from the main Python library. Instead, the entire marshaling code was vendored into the Pyarmor\r\nruntime, and we identified it by searching for some of the error strings on GitHub. There’s pretty much only one\r\nreason one would do such a thing: in order to customize some logic in the code.\r\nSo we accepted our fate, built python3.12 from source, and stepped through the deserialization logic side by side\r\nto find the point of divergence. Somewhat unsurprisingly, the difference turned out to be in code objects,\r\nspecifically at the end of the object data. Pyarmor contains the following additional logic:\r\n if ( !rf-\u003ereadable )\r\n {\r\n v265 = getc((FILE *)rf-\u003efp);\r\n if ( v265 != -1 )\r\n goto LABEL_546;\r\n goto LABEL_479;\r\n }\r\n v320 = (unsigned __int8 *)r_byte(1LL, (__int64)rf);\r\n if ( !v320 )\r\n {\r\n PyErr_SetString(PyExc_EOFError, \"EOF read where object expected\");\r\n goto code_error;\r\n }\r\n v265 = *v320;\r\nLABEL_546:\r\n v304 = (_BYTE *)r_string(v265, (__int64)rf);\r\n v305 = (__int64 *)v304;\r\n if ( !v304 )\r\n goto error;\r\nThis logic reads an additional bytes string prefixed with a length byte. Its purpose is unknown - it didn’t seem to\r\nbe relevant for static analysis.\r\nSince we already had the Python source at hand anyway, we simply inserted similar logic into the marshal module.\r\nWhen doing so, you must take care not to disturb the normal loading activities of the Python runtime, since of\r\ncourse it also runs the unmarshaling code when loading built-in/standard modules. The patch we came up with for\r\npython3.12 is part of our GitHub repo, along with a docker image building a patched Python.\r\nWith the customized Python build, we were now able to successfully parse the binary data we decrypted!\r\n\u003e\u003e\u003e marshal.load(BytesIO(data[0x20:]))\r\nGot extra data of length 12\r\nGot extra data of length 12\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 11 of 16\n\nGot extra data of length 12\r\n\u003ccode object \u003cmodule\u003e at 0x7f9a3a6ce100, file \"\u003cfrozen JAN-X1\u003e\", line 1\u003e\r\nThe module we parsed contains three code objects in total, so we got three debug prints about the additional bytes\r\nthat were found. It appears the malware’s source file was originally called JAN-X1.py .\r\nSide note: There is one other reason that it is in Pyarmor’s interest to vendor the unmarshaling code. The official\r\nvariant of the code offers auditing hooks that allow you to be informed whenever the interpreter unmarshals data.\r\nThis was utilized by unpackers for older Pyarmor versions. In the vendored code, any auditing logic is\r\nconveniently missing.\r\nAnalyzing the actual bytecode\r\nWith our code object instance at the ready, we can finally disassemble some bytecode!\r\n\u003e\u003e\u003e dis.dis(thecode)\r\n 0 0 NOP\r\n 1 2 NOP\r\n 4 PUSH_NULL\r\n 6 LOAD_CONST 1 ('__pyarmor_enter_60307__')\r\n 2 8 LOAD_CONST 2 (b'\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x00\\x01\\x00\\x00\\x1a@\\x00\\x00\\x00\\x00\\\r\n 10 BUILD_TUPLE 1\r\n 12 CALL_FUNCTION_EX 0\r\n 14 POP_TOP\r\n 16 RESUME 0\r\n 18 NOP\r\n 20 NOP\r\n 22 NOP\r\n 24 NOP\r\nTraceback (most recent call last):\r\n...\r\n File \"/python312/Lib/dis.py\", line 401, in _get_name_info\r\n argval = get_name(name_index, **extrainfo)\r\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\r\nIndexError: tuple index out of range\r\nWell… it’s a start? There are a couple of things to note here:\r\nRemember the code enter/leave functions we noted in C earlier in the post? Here, they’re calling enter\r\nWhen looking at where the bytecode is defined in the decrypted binary data, the high entropy (encrypted)\r\narea happens to start directly after the chain of NOPs at the end\r\nThe first encrypted offset (26/0x1a) is also present in the conspicuous bytes string that is loaded at offset 8.\r\nFurthermore, the byte after \\x1a ( @ aka 0x40) is a good match for the size of the encrypted area\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 12 of 16\n\nLooking at the code for c_enter_co_object , we see the following:\r\n iv_func = (__int64 (__fastcall *)(char *, _QWORD))ret_zero;\r\n if ( (*(_BYTE *)(args + 40) \u0026 4) != 0 )\r\n iv_func = *(__int64 (__fastcall **)(char *, _QWORD))(args + 52);// some sort of iv mutator? not used in our\r\n iv_offset = *(unsigned __int8 *)(args + 41);\r\n v13 = (char *)codeptr + iv_offset;\r\n if ( (*(_BYTE *)(args + 40) \u0026 2) == 0 )\r\n v13 = (char *)codeptr + *(unsigned int *)(args + 44) + iv_offset + *(unsigned __int8 *)(args + 43);\r\n *(_QWORD *)iv = *(_QWORD *)v13;\r\n *(_DWORD *)\u0026iv[8] = *((_DWORD *)v13 + 2);\r\n if ( !iv_func(iv, 0LL) )\r\n {\r\n codecrypto = v2-\u003ecodecrypto;\r\n cryptsize = *(_DWORD *)(args + 44);\r\n cryptstart = *(_BYTE *)(args + 43);\r\n gcm = (gcm *)(codecrypto + 24);\r\n assume12 = *(_BYTE *)(codecrypto + 1);\r\n v19 = gcm_reset((gcm *)(codecrypto + 24));\r\n if ( !v19 )\r\n {\r\n v19 = gcm_add_iv(gcm, (__int64)iv, assume12);\r\n if ( !v19 )\r\n {\r\n v19 = gcm_add_aad_0(gcm, 0LL, 0);\r\n if ( !v19 )\r\n {\r\n v19 = gcm_process(gcm, (__int64)codeptr + cryptstart, cryptsize, (__int64)codeptr + cryptstart, 0);\r\n if ( !v19 )\r\nLooks similar enough to what we’ve seen before, right? It’s AES-GCM again with the same key.\r\nBased on how the parameters are used in the GCM functions and what we deduced earlier, we can tell that the\r\nbytes string we saw in the bytecode starts at args + 32 . The GCM IV location is obtained through a series of\r\noffsets computations. In our case, it was always located right after the ciphertext. Unlike earlier, the IV bytes are\r\nnot split up. However, there seems to be some capability to run the IV through an additional function for unknown\r\npurposes (possibly to mutate it?).\r\nEssentially, what we’re dealing with here is just-in-time decryption. The code is decrypted, executed, and then re-encrypted. This means that functions that are not currently being executed are not available in plaintext even if\r\nyou dump the process memory.\r\nWe decided to write a script that parses the code objects, extracts the bytes string to find the ciphertext and IV, and\r\ngenerates a file that basically describes where and how to apply GCM in the raw decrypted script. This description\r\ncan then be used by a normal Python installation in order to do the decryption (it seemed prudent to use our\r\nmodified Python as little as possible - in particular, we didn’t feel like attempting to run Pycryptodome on it).\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 13 of 16\n\nFinally decrypted\r\nHere’s the decrypted continuation of the bytecode we had earlier:\r\n 1 26 NOP\r\n 2 28 LOAD_CONST 3 (0)\r\n 30 LOAD_CONST 4 (None)\r\n 32 IMPORT_NAME 1 (ctypes)\r\n 34 STORE_NAME 1 (ctypes)\r\n 3 36 LOAD_CONST 3 (0)\r\n 38 LOAD_CONST 4 (None)\r\n 40 IMPORT_NAME 2 (base64)\r\n 42 STORE_NAME 2 (base64)\r\n 5 44 LOAD_CONST 5 (\u003ccode object rc4_decrypt at 0x5e6298eb3bd0, file \"\u003cfrozen JAN-X1\u003e\",\r\n 46 MAKE_FUNCTION 0\r\n 48 STORE_NAME 3 (rc4_decrypt)\r\n 27 50 LOAD_CONST 6 (\u003ccode object execute_shellcode at 0x5e6298ec0560, file \"\u003cfrozen JAN-\r\n 52 MAKE_FUNCTION 0\r\n 54 STORE_NAME 4 (execute_shellcode)\r\n 45 56 PUSH_NULL\r\n 58 LOAD_NAME 4 (execute_shellcode)\r\n 60 CALL 0\r\n 68 POP_TOP\r\n 70 LOAD_CONST 4 (None)\r\n 72 NOP\r\n 74 NOP\r\n 76 NOP\r\n 78 JUMP_FORWARD 19 (to 118)\r\n ... followed by pyarmor leave code ...\r\nThe function references that can be seen here do exactly what they say. The execute_shellcode function\r\ncontains a huge base64-encoded string, which is decrypted using RC4, allocated as executable memory using\r\nWindows APIs, and then jumped into.\r\nSo in the end, we don’t have “real” Python malware here, just a somewhat unusual malware packer.\r\nThe actual malware\r\nThe question remains - what malware did they try to infect us with?\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 14 of 16\n\nOne thing is for certain: the amount of layers the malware is packed in is slightly ridiculous. Whenever we\r\nunpacked one stage, we’d be faced with another packer and the payload got smaller and smaller, to a point where\r\nwe wondered if anything would actually be left. In the end, the chain turned out to be this:\r\n1. Pyarmor\r\n2. Shellcode (packer)\r\n3. Injector generated by laZzzy, injects into notepad.exe\r\n4. Shellcode (same packer as before)\r\n5. .NET malware (sometimes also packed with additional .NET packer).\r\nIf you count the initial dropper stages, the list is even longer.\r\nA comprehensive malware analysis would be out of scope here, and frankly, not that interesting, so we’re just\r\ngoing to leave you with some screenshots for code impressions.\r\nFigure 6: This specimen is a variant of the XWorm RAT\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 15 of 16\n\nFigure 7: PureHVNC RAT, stealing crypto wallets and browser data. It also collects basic info about\r\nyour system, including whether a camera is plugged in\r\nFilename Malware family\r\nEAdate.py DcRat\r\nFAScis.py AsyncRAT\r\nGXrop.py XWorm RAT\r\nHPUope.py PureHVNC\r\nFigure 8: Types of malware used in the campaign\r\nThe other set of files ( BArown.py , etc.) contains the same malware - the Python files have different hashes, but\r\nthe final unpacked binaries are identical.\r\nIn one of the next installments on this blog, we’re going to talk about .NET obfuscation, so stay tuned!\r\nGitHub repo with scripts developed for Pyarmor: https://github.com/GDATAAdvancedAnalytics/Pyarmor-Tooling\r\nUpdated on April 4: We’ve identified the previously denoted as unknown sample HPUope.py to be PureHVNC.\r\nSource: https://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nhttps://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/\r\nPage 16 of 16",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"Malpedia"
	],
	"origins": [
		"web"
	],
	"references": [
		"https://cyber.wtf/2025/02/12/unpacking-pyarmor-v8-scripts/"
	],
	"report_names": [
		"unpacking-pyarmor-v8-scripts"
	],
	"threat_actors": [],
	"ts_created_at": 1775434176,
	"ts_updated_at": 1775826754,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/459c15f5cac30cbac9dc88fa467b4b2305f7080f.pdf",
		"text": "https://archive.orkl.eu/459c15f5cac30cbac9dc88fa467b4b2305f7080f.txt",
		"img": "https://archive.orkl.eu/459c15f5cac30cbac9dc88fa467b4b2305f7080f.jpg"
	}
}