I wanted to play Poppy Playtime in VR on a Meta Quest 3S. The game was already installed on my PC, but not through Steam, and the obvious route to VR was a third party mod installer I found on GitHub. What started as a simple question about feasibility turned into a full binary reverse engineering exercise, because the one thing the project did not have was any reason to trust it. This is a walkthrough of how a security-minded person approaches an untrusted binary, with the gaming context to make it concrete. The short version: the mod was clean, and the VR experience was underwhelming anyway.
The Gaming Malware Problem
Gaming is one of the largest malware vectors in existence, and it is worth saying why before getting into the analysis. Mods, plugins, cracks and cheats routinely instruct users to disable their antivirus or add folder exceptions, and the community has been trained to comply because so much legitimate tooling trips heuristic detection. The audience skews young and frequently runs on shared family machines. Years of installing unofficial software has desensitised an entire demographic to the warning signs that would make a corporate user stop and think. A binary that asks for administrator rights and downloads more executables at runtime is completely normal in this world, which is exactly what makes it such fertile ground.
None of that means a given mod is malicious. It means the cost of being wrong is high and the base rate of caution is low, so the analysis is worth doing.
The Mod and the Compatibility Question
The mod was a repository by a user called EllieWasteland (github.com/EllieWasteland/PoppyPlaytimeVR) offering an installer that adds VR support using UEVR, praydog's Universal Unreal Engine VR injector. Before worrying about safety, I needed to know whether it would even work against a non-Steam install.
The installer's published Python source validated installation directories by checking for specific folder names: WindowsNoEditor, Poppy_Playtime and Playtime_Prototype4. My game root contained _Redist, Chapter5, Playtime_Chapter4, PlaytimeLauncher, PoppyPlaytime_Chapter3, WindowsNoEditor and the uninstaller files. Inside WindowsNoEditor sat Engine, Playtime_Prototype4, Poppy_Playtime and a manifest.
That matched. The WindowsNoEditor folder alone was enough to pass validation, and the inner folder names lined up exactly. The installer's one meaningful operation, deleting WindowsNoEditor/Engine/Binaries/ThirdParty/OpenXR so UEVR can supply its own OpenXR runtime, had a target folder that existed. Full compatibility confirmed.
Trust Signals, or the Absence of Them
Compatibility was the easy part. The repository told a different story. Zero stars, zero forks, zero issues, zero pull requests. No community engagement of any kind. The same developer had an identical templated repository for FNAF Security Breach VR, which is the pattern you see from both prolific hobbyists and people spraying out lookalike projects.
Searching Reddit, YouTube, Discord and the usual VR communities turned up no independent references to this specific repository. The people actually playing Poppy Playtime in VR were using praydog's UEVR directly with game-specific profiles from the Flat2VR community Discord, not this wrapper. So the established, community-trusted route was clear: download UEVR from praydog's official GitHub, which has thousands of stars and an active community, pull a game profile from Flat2VR, connect the Quest over Air Link, launch the game normally and inject UEVR by hand.
That was the safer path, and I want to be honest that it was available the whole time. I chose to investigate the suspicious binary directly instead, partly out of curiosity and partly because the published source looking clean tells you nothing. Source code on GitHub can be a decoy. Only the compiled binary tells you what actually runs.
Stage 1: Ghidra via GhidraMCP, and Where It Hits a Wall
I had GhidraMCP running locally driving a Gemma 4 27B model on an RTX 3090. That setup is genuinely effective for triage. The initial pass did three things:
- Searched strings for URL patterns (
http,://,www,.com,.net,.org). The only hit was a literal.comat0x140036018. No full URLs in plaintext. - Reviewed imports. No direct imports from network libraries like
Wininet.dllorWs2_32.dll, but it did importLoadLibraryExWandGetProcAddress, which signals dynamic library loading. - Searched for PyInstaller signatures and found the
_MEIPASSstring at0x140030af0along with its associated error message. This was a PyInstaller-packed executable.
That last finding is where Ghidra stops being the right tool. A PyInstaller binary is a matryoshka doll. The outer layer is a small C bootloader that extracts a compressed archive to a temp directory, loads the bundled Python interpreter (python3x.dll) and calls Py_Main. The actual application logic is Python bytecode inside that compressed archive, which Ghidra sees as an opaque data blob. Ghidra analyses native x86/x64; it does not understand CPython bytecode, so it cannot decompile or even meaningfully display the payload. It is like using a metal detector to find a letter inside a locked safe. The detector found the safe. Reading the letter needs a different tool.
Stage 2: PyInstaller Extraction
Switching to direct Python extraction with pyinstxtractor:
python pyinstxtractor.py PoppyInstaller_VR.exe
This unpacked 1144+ files including the entry point PoppyInstaller_VR.pyc and every bundled dependency. The dependency list is where the first concerns appeared. The published source only imported webview, os, sys, shutil, json and pyshortcuts. The binary bundled considerably more.
The unexpected libraries:
cryptography(v46.0.5), surprising for a simple file-copying installergeventandgreenlet, async networking and concurrency frameworkstornado, a full async web frameworknumpy, scientific computingpsutil, process enumeration and system monitoring_socket.pydand_ssl.pyd, network socket and SSL/TLS capabilityPoppyLauncherVR.exe, a 31MB embedded executable
Crypto, async web frameworks, process enumeration and an embedded second binary is exactly the inventory you would expect from something doing more than copying files. It is also exactly the inventory PyInstaller produces when it hoovers up transitive dependencies from the build environment whether or not the script uses them. The dependency list is an indicator, not a verdict. Decompilation decides which.
Stage 3: Bytecode Decompilation
The first attempt with decompyle3 failed outright:
Unsupported Python version, 3.11, for decompilation
So I built pycdc from source, which handles newer bytecode:
git clone https://github.com/zrax/pycdc
cd pycdc
cmake .
make
./pycdc /path/to/PoppyInstaller_VR.pyc > PoppyInstaller_VR_decompiled.py
pycdc produced partial output with warnings (Unsupported opcode: MAKE_CELL, block stack is not empty) but recovered more than enough to read.
Analysing the Installer
The decompiled installer was functionally identical to the published repository source. Same imports, same InstallApi class structure, same operations: copy PoppyLauncherVR.exe into the game directory, write a ConfigPPCVRLauncher.json config, create a desktop shortcut via pyshortcuts, and delete the WindowsNoEditor/Engine/Binaries/ThirdParty/OpenXR folder.
No hidden imports, no network calls, no use of the crypto or process-enumeration libraries, nothing touching psutil. The messy nested try/except/None blocks in the output were pycdc artefacts from struggling with Python 3.11 exception-handling bytecode, not obfuscation.
The suspicious bundled libraries were exactly what they looked like once read in context: PyInstaller's aggressive dependency bundling, not functionality. The installer was clean.
Following the Chain to the Launcher
A clean installer is not the end of the analysis, because the installer's entire job is to drop PoppyLauncherVR.exe (31MB) into the game directory. That launcher is the component that runs persistently every time you play, so it needed the same treatment. Always follow the chain.
python pyinstxtractor.py PoppyLauncherVR.exe
Also PyInstaller, also Python 3.11, 1144 files, entry point PoppyLauncherVR.pyc. The notable additions over the installer:
- A
chapters/directory with ch1 to ch5 subdirectories holding VR profiles and.pakmods per chapter pyinjector, a DLL injection librarytailwind.js, a UI framework for the launcher interface- Chapter artwork (
ch1.jpgthroughch5.jpg) dbgcore.dllanddbghelp.dll, Windows debugging libraries
DLL injection plus process enumeration is the kind of combination that justifies the whole exercise, because it is also exactly what a malicious loader would carry. Decompiling with pycdc, the tool crashed partway through (Something TERRIBLE happened! followed by a C++ assertion failure) but recovered substantial output before dying.
The recovered source imported os, sys, time, ctypes, threading, subprocess, psutil, webview, json, shutil, zipfile, urllib.request, tempfile, ssl, webbrowser and pyinjector. Every one had a plausible explanation: pyinjector injects UEVR into the running game, psutil finds the game process by name before injection (function obtener_pid), urllib.request with ssl and zipfile downloads and extracts UEVR builds, subprocess launches the game, ctypes handles the Windows API calls for injection, and webview with the Tailwind and image assets is the launcher GUI.
A CONFIG_JUEGOS dictionary mapped chapters 1 to 5 to their exact executable paths, .pak file locations, VR profile names and zip paths, all matching the expected Unreal Engine structure. The Spanish identifier names, incidentally, were the first clue to why this project had zero engagement.
The Critical Check: Where Does It Phone Home?
The functions that mattered most for trust were check_updates and _download_and_extract, and pycdc had crashed before fully decompiling them. For URL extraction, a full decompile is unnecessary. The raw bytecode disassembler pycdas surfaces the string constants directly:
../pycdc/pycdas ./PoppyLauncherVR.pyc | grep -A 5 -i "github\|http\|url\|download"
Three URLs came back, and the thing to check is the org and path, not just the domain. Anyone can publish a release on github.com, so a typosquat like praydogs/UEVR is the real danger sign. These were not that:
https://api.github.com/repos/EllieWasteland/PoppyPlaytimeVR/releases, the launcher checking its own repo for updateshttps://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip, praydog's official UEVR, pinned to release 1.05https://github.com/praydog/UEVR-nightly/releases/download/nightly-01127-6f66affc.../uevr.zip, the official nightly, pinned to a specific tag carrying its full commit hash
The pinning is what makes the nightly acceptable rather than alarming. A floating reference to the latest nightly would be a moving target that the author, or anyone who compromised the repo, could swap out later. A tag with a commit hash baked into it is a fixed, identifiable artefact. So there are no unknown domains, no C2 infrastructure and no exfiltration endpoints: the launcher fetches official, version-pinned UEVR builds and nothing else.
The surrounding human-readable strings reinforced the picture rather than complicating it. The launcher's error messages were plain, descriptive and bilingual, an English line paired with a Spanish one throughout:
'No se pudo detectar el proceso del juego.'
'Could not detect the game process.'
'Error de inyección: '
'Injection error: '
'Verifica que tu antivirus no bloquee pyinjector.'
'Verify your antivirus is not blocking pyinjector.'
'Delete the LauncherVRFiles folder to force redownload.'
Nothing here is obfuscated or misleading. The strings describe exactly the workflow the imports implied: find the game process, inject UEVR through pyinjector, and redownload the launcher's files if something is corrupt. The bilingual pairing also explains the project's invisibility. This is a Spanish-language hobby tool that happened to ship English fallbacks, not a polished release chasing an audience.
One line deserves a second look: verify your antivirus is not blocking pyinjector. In isolation that is the exact phrasing that should make you suspicious, because "turn off your antivirus" is the oldest instruction in the malware-distribution playbook and the gaming scene is saturated with it. Here it has an innocent explanation. DLL injection is genuinely what UEVR does to render the game in VR, and DLL injection genuinely trips antivirus heuristics whether the injector is malicious or not. The message is being honest about a real friction point rather than social-engineering the user into lowering their defences. That distinction, a transparent explanation of legitimate behaviour versus a blanket demand to disable protection, is exactly the judgement call this whole exercise is about.
Verdict
Both binaries were clean. The installer and launcher did exactly what the published source claimed: wrap praydog's UEVR with a friendly GUI, pull official UEVR builds from GitHub, and automate the injection. There is one honest boundary worth naming. Clearing the launcher means it only fetches official, version-pinned UEVR builds and phones home nowhere unexpected; it does not mean I reversed the UEVR payload itself. At that point the residual trust is the same trust the entire Flat2VR community already places in praydog's UEVR, which is a reasonable place to stop. The zero community engagement was down to this being a niche, Spanish-language tool that never found a wider audience, not malice. Confirming that something is safe is a real result. Security analysis is not only about finding malware; it is about earning confidence in the tools you run.
A Note on the Payoff
The reversing was the interesting part, and it ended where it should: the mod was clean, so I ran it. For completeness, the VR experience itself was underwhelming, and the reason is structural rather than a flaw in the mod. Poppy Playtime's GrabPack mechanic is built around mouse aim. UEVR can inject VR rendering and feed in controller input, but it cannot rewrite the game's aiming system, so the game wants to aim where a mouse cursor points while your hands point somewhere else. That mismatch makes it effectively unplayable as VR, and I finished the game in 2D. This is the fundamental limit of flat-to-VR modding: visual immersion works, interaction does not. Native VR development is the only real fix, and the official Flat2VR Studios adaptation announced in August 2025 is the project to watch for that.
Takeaways
On the analysis itself:
- A project with zero community engagement that packages privileged executables warrants investigation no matter how clean the published source reads. The source can be a decoy; the binary is the truth.
- Ghidra is the wrong tool for PyInstaller binaries beyond identifying them. The native bootloader is a thin shell; the payload lives in compressed Python bytecode that native disassemblers cannot parse.
- GhidraMCP with a local model (Gemma 4 27B on an RTX 3090) was effective for triage: string searches, import review and PyInstaller identification.
- The pyinstxtractor then pycdc/pycdas pipeline is the right approach. When pycdc crashes on awkward 3.11 bytecode, pycdas still surfaces strings, URLs and function names from the raw disassembly.
- Check the extracted dependency list before decompiling. Unexpected libraries are an early indicator, but PyInstaller's dependency hoovering produces plenty of false alarms. Decompilation tells you which.
- Always follow the chain. The installer was clean, but its job was to deploy a launcher, and the launcher was the persistent component that actually needed verifying.
- Getting the pipeline working is the hard part the first time. The tools are straightforward once you have been through them, and the commands lock in after a couple of runs.
- Confirming something is safe is valuable in itself.
Tools
- Ghidra (NSA): binary analysis framework, used for initial identification
- GhidraMCP: MCP server exposing Ghidra to an LLM
- Gemma 4 27B (Google): local model driving the Ghidra triage via GhidraMCP
- pyinstxtractor (extremecoders-re): PyInstaller archive extractor
- pycdc / pycdas (zrax): Python bytecode decompiler and disassembler
- decompyle3: Python decompiler, failed on 3.11 bytecode but part of the process
- UEVR (praydog): Universal Unreal Engine VR injector, the thing the mod wraps
- Meta Quest 3S: the headset the mod targets
> End of output.