Dynamic Analysis of Windows Malware from Linux
- Dynamic Analysis of Windows Malware from Linux
- Why analyze Windows malware from Linux?
- Three ways to run a Windows binary on Linux
- Before you run anything
- A specimen to calibrate with
- Path 1: Wine, with the relay trace
- Path 2: Emulation — the sample never actually runs
- Path 3: A Windows VM under KVM, watched from outside
- Choosing a path
- What none of this gives you
- Key takeaways
- References
Why analyze Windows malware from Linux?
The obvious way to run a Windows sample is on Windows. The problem with the obvious way is that the sample and your analysis tooling then share an operating system. Every trick the malware has — its persistence, its privilege escalation, its EDR evasion, its process injection — is aimed squarely at the platform you are standing on. Your monitoring tools are Windows processes, and Windows processes can be found, hooked, crashed, or lied to.
Analyzing from Linux inverts that. The sample is foreign code: it does not know how to persist on your host, cannot tamper with your capture, and in the VM case cannot even see the tools watching it, because they are not inside the machine it is running on. That last point is the one that matters most, and it is the theme of this page. The best observation post is outside the system under observation.
This page is about the three ways to make a Windows PE actually execute when your host is Linux, what each one is good for, and — critically — how each one lies to you. It builds on Dynamic Analysis, which covers the general methodology, and Working with Wine, which covers Wine as a tool rather than as an analysis technique.
Three ways to run a Windows binary on Linux
These are not three flavors of the same thing. They differ in what actually executes your sample’s instructions, and that single difference determines what you are allowed to conclude from what you see.
| Wine | Emulation | Windows VM (KVM) | |
|---|---|---|---|
| Instructions executed by | Real CPU | Software CPU model | Real CPU (VT-x) |
| Windows API provided by | Wine’s reimplementation | A Python model of the API | Actual Windows |
| Isolation from your host | None | Strong — nothing really runs | Strong — hypervisor boundary |
| Setup cost | Minutes | Minutes | Hours |
| Speed | Native | 10–1000x slower | Near native |
Kernel drivers (.sys) |
No | Partially (Speakeasy) | Yes |
| Instrumentation | Moderate | Total | Via guest agents or the hypervisor |
| Fidelity | Medium | Low | Highest |
| Typical use | Explore behavior fast | Triage at scale, unpack | Confirm findings |
The right workflow uses all three, in order of increasing cost and increasing trust:
- Emulate for triage. Seconds per sample, no risk, machine-readable output.
- Run under Wine to explore. Real execution, full API trace, easy diffing.
- Detonate in a VM to confirm anything you intend to write down.
Each step up costs more and lies less. Never report a conclusion that only the first two steps support.
Before you run anything
Two Linux-specific hazards catch people who are careful on Windows and assume Linux is safer. Both are verifiable on your own machine in ten seconds.
Wine maps your entire filesystem as drive Z:
A Wine prefix looks like a container. It is not one. Look at what wineboot
creates:
$ ls -l ~/.wine-analysis/dosdevices/
lrwxrwxrwx 1 user user 10 Sep 15 13:47 c: -> ../drive_c
lrwxrwxrwx 1 user user 10 Sep 15 13:47 com1 -> /dev/ttyS0
lrwxrwxrwx 1 user user 1 Sep 15 13:47 z: -> /
Z: is the root of your Linux filesystem. Malware running under Wine can read
and write anything your user account can — ~/.ssh, your samples directory,
your notes, your browser profile — by opening Z:\home\you\.... It does not
need an exploit to do this, and it does not need to know it is on Linux. It just
needs to enumerate drives, which is ordinary behavior for a ransomware sample.
You can remove the mapping (rm ~/.wine-analysis/dosdevices/z:), and you should,
but do not mistake that for isolation: the prefix is still an ordinary directory
owned by your user, and a Wine process runs with your full privileges.
⚠️ Wine provides zero isolation. It is a compatibility layer, not a sandbox. Everything on this page that involves Wine assumes you are already inside a disposable REMnux VM with a snapshot to revert to. Running Wine on your daily-driver host is equivalent to double-clicking the sample.
Typing ./sample.exe runs it
Most Linux distributions register PE files with the kernel’s binfmt_misc
handler so that Windows executables “just work”. Check yours:
$ cat /proc/sys/fs/binfmt_misc/DOSWin
enabled
interpreter /usr/bin/wine
flags:
offset 0
magic 4d5a
The magic is 4d5a — the bytes MZ, the first two bytes of every PE file. Any
file starting with MZ that has the execute bit set will be handed to Wine when
you run it. That includes the sample you just downloaded and absent-mindedly
tab-completed. A CLR entry doing the same thing for /usr/bin/mono is
commonly present too.
Two defenses, both cheap:
# 1. Never let samples be executable. Do this at download time.
$ chmod -x sample.exe
# 2. Disable the handler entirely on your analysis VM.
$ echo 0 | sudo tee /proc/sys/fs/binfmt_misc/DOSWin
Keeping samples in a password-protected zip until the moment of use — standard practice covered in Malware Triage — solves this as a side effect.
A specimen to calibrate with
Before pointing any of this tooling at real malware, point it at something whose behavior you already know. You cannot tell whether a tool is lying to you if you do not have ground truth, and as the next section shows, one of these tools will lie to you in a way that looks completely plausible.
Build a benign stand-in. This does three things every dropper does — writes a
Run key, drops a file, resolves a C2-looking name — and nothing harmful. The
hostname uses the .test TLD, which RFC 2606
reserves precisely so it can never resolve to a real host.
/* specimen.c -- a deliberately benign stand-in for a dropper.
*
* Built without the C runtime so that an emulator sees our API calls
* immediately instead of several hundred CRT startup calls first.
*/
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#define C2_HOST "updates.example-c2.test"
#define RUN_KEY "Software\\Microsoft\\Windows\\CurrentVersion\\Run"
#define DROP "C:\\Users\\Public\\dropped.bin"
void __cdecl mainCRTStartup(void)
{
HKEY hKey;
HANDLE hFile;
DWORD written;
WSADATA wsa;
struct addrinfo *res = NULL;
static const char value[] = DROP;
static const char payload[] = "not actually malicious\r\n";
/* 1. Persistence */
if (RegCreateKeyExA(HKEY_CURRENT_USER, RUN_KEY, 0, NULL, 0,
KEY_WRITE, NULL, &hKey, NULL) == ERROR_SUCCESS) {
RegSetValueExA(hKey, "SecurityUpdate", 0, REG_SZ,
(const BYTE *)value, sizeof(value));
RegCloseKey(hKey);
}
/* 2. Drop a file */
hFile = CreateFileA(DROP, GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (hFile != INVALID_HANDLE_VALUE) {
WriteFile(hFile, payload, sizeof(payload) - 1, &written, NULL);
CloseHandle(hFile);
}
/* 3. Look up the C2 */
if (WSAStartup(MAKEWORD(2, 2), &wsa) == 0) {
if (getaddrinfo(C2_HOST, "80", NULL, &res) == 0 && res)
freeaddrinfo(res);
WSACleanup();
}
ExitProcess(0);
}
Cross-compile it on Linux with MinGW — no Windows machine involved:
$ sudo apt install gcc-mingw-w64-x86-64 # Debian/Ubuntu/REMnux
$ x86_64-w64-mingw32-gcc -O1 -nostartfiles -nostdlib \
-o specimen.exe specimen.c -lkernel32 -ladvapi32 -lws2_32
$ file specimen.exe
specimen.exe: PE32+ executable for MS Windows 5.02 (console), x86-64 (stripped to external PDB), 5 sections
Ground truth: three observable behaviors, in a 9.7 KB binary with exactly eleven imports. Remember the number three.
Path 1: Wine, with the relay trace
Wine’s value for analysis is not that it runs Windows programs. It is that Wine
is the Windows API, in source you can read, and it will narrate every call
a sample makes through it. That is the relay debug channel, and it is the
single most useful thing on this page.
Set up a throwaway prefix
$ export WINEPREFIX=~/.wine-$(date +%Y%m%d)-specimen
$ WINEDEBUG=-all wine wineboot --init
$ rm -f "$WINEPREFIX/dosdevices/z:" # unmap the host filesystem
One prefix per sample. They are disposable — rm -rf and start over — and
keeping them separate means a registry diff shows one sample’s changes rather
than the accumulated sludge of a dozen.
The relay channel, and why you must filter it
WINEDEBUG=+relay logs every API entry point crossed. Unfiltered, it is
unusable. On Wine 11.17, simply asking cmd to print its version produces:
$ WINEDEBUG=+relay,+tid wine cmd /c ver > relay.txt 2>&1
$ wc -l relay.txt
406631 relay.txt
Four hundred thousand lines of Windows starting up, before your sample has done
anything. The fix is RelayInclude, a registry value under
HKCU\Software\Wine\Debug that whitelists the calls you care about. Wildcards
work per-DLL:
$ wine reg add 'HKCU\Software\Wine\Debug' /v RelayInclude \
/d 'advapi32.Reg*;kernel32.CreateFile*;kernel32.WriteFile;ws2_32.*' /f
Same command, same Wine, with the filter in place:
$ WINEDEBUG=+relay,+tid wine cmd /c ver > relay.txt 2>&1
$ wc -l relay.txt
2609 relay.txt
406,631 lines to 2,609 — a 99.4% reduction, and what remains is readable.
RelayExclude works the same way for the inverse case (log everything except
a noisy DLL). Start with an include list built from the sample’s import table:
run rabin2 -i sample.exe first, and trace what it actually imports.
Reading the trace
Now run the specimen:
$ WINEDEBUG=+relay,+tid wine specimen.exe 2>&1 \
| grep -aiE '(Call|Ret) +(advapi32|kernel32|ws2_32)\.'
018c:Call advapi32.RegCreateKeyExA(ffffffff80000001,140002000 "Software\\Microsoft\\Windows\\CurrentVersion\\Run",00000000,00000000,00000000,00020006,00000000,7ffffe30ff68,00000000) ret=140001060
018c:Ret advapi32.RegCreateKeyExA() retval=00000000 ret=140001060
018c:Call advapi32.RegSetValueExA(00000034,14000202e "SecurityUpdate",00000000,00000001,1400020a0,0000001c) ret=14000112c
018c:Ret advapi32.RegSetValueExA() retval=00000000 ret=14000112c
018c:Call KERNEL32.CreateFileA(14000203d "C:\\Users\\Public\\dropped.bin",40000000,00000000,00000000,100000002,00000080,00000000) ret=14000109f
018c:Ret KERNEL32.CreateFileA() retval=00000034 ret=14000109f
018c:Call KERNEL32.WriteFile(00000034,140002080,00000018,7ffffe30ff64,00000000) ret=1400010cf
018c:Ret KERNEL32.WriteFile() retval=00000001 ret=1400010cf
018c:Call ws2_32.WSAStartup(00000202,7ffffe30fdc0) ret=1400010e8
018c:Ret ws2_32.WSAStartup() retval=00000000 ret=1400010e8
018c:Call ws2_32.getaddrinfo(14000205c "updates.example-c2.test",140002059 "80",00000000,7ffffe30fdb8) ret=14000115e
018c:Ret ws2_32.getaddrinfo() retval=00002af9 ret=14000115e
018c:Call ws2_32.WSACleanup() ret=140001178
018c:Ret ws2_32.WSACleanup() retval=00000000 ret=140001178
All three behaviors, with their arguments decoded. Note the format:
018c:— the thread ID, from+tid. Essential once a sample spawns threads.Call/Retpairs —Retlines carry the return value, which is how you tell a successful call from a failed one. Both registry calls returned00000000(ERROR_SUCCESS); remember that when you reach the emulator section.- String arguments are dereferenced and printed inline. This is what makes relay
traces better than
stracehere: you get"updates.example-c2.test", not a pointer. getaddrinforeturned00002af9— 11001,WSAHOST_NOT_FOUND. The.testTLD does not resolve, which is the point of using it, and it is also what a real C2 lookup looks like when you have not started a fake DNS server yet. Fixing that is what INetSim is for, below.- Wine is inconsistent about DLL name casing in the trace —
advapi32butKERNEL32— so always grep case-insensitively.
Wine also genuinely performed the actions, so you can verify them out-of-band:
$ wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Run'
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
SecurityUpdate REG_SZ C:\Users\Public\dropped.bin
$ ls -l "$WINEPREFIX/drive_c/users/Public/dropped.bin"
-rw-r--r-- 1 user user 24 Sep 15 13:47 dropped.bin
The dropped file is a normal Linux file. Feed it straight to file, strings,
YARA, or Ghidra with no extraction step.
Diffing the prefix
For samples that do more than three things, diff instead of reading:
$ cp -a "$WINEPREFIX" "$WINEPREFIX.before"
$ wine sample.exe
$ diff -r "$WINEPREFIX.before/drive_c" "$WINEPREFIX/drive_c" # dropped files
$ diff "$WINEPREFIX.before/user.reg" "$WINEPREFIX/user.reg" # registry
The registry hives are plain text, which makes this far more pleasant than the equivalent on Windows.
Debugging under Wine
Wine ships winedbg, which speaks two protocols:
$ winedbg specimen.exe # its own console debugger
$ winedbg --gdb specimen.exe # GDB remote protocol
The --gdb mode is the useful one: it exposes the Windows process to any GDB
frontend, so the pwndbg workflow from Advanced Dynamic
Analysis applies unchanged to a PE. You get breakpoints,
single-stepping, and memory inspection using the same muscle memory you use on
ELF binaries.
x64dbg under Wine is not a reliable option. It is the standard Windows
debugger for malware work, and people regularly try to run it under Wine; it has
a long history of crashing on unimplemented msvcp120.dll functions and of
loading with a blank CPU pane. Do not build a workflow on it. If you want
x64dbg, run it inside the Windows VM (Path 3) and drive it remotely — REMnux
ships an x64dbg Automate MCP server for exactly this, which fits the tooling
described in AI-Assisted Analysis.
How malware detects Wine
Wine is trivially detectable, and plenty of samples check. The canonical test is
one GetProcAddress call:
/* Non-NULL means Wine. Verified present in Wine 11.17's ntdll. */
FARPROC p = GetProcAddress(GetModuleHandleA("ntdll.dll"), "wine_get_version");
Wine’s ntdll.dll exports wine_get_version, wine_get_host_version, and
wine_get_build_id alongside its 1,479 real exports. There is also a
conspicuous HKCU\Software\Wine registry key. Neither is hidden, because Wine
is not trying to hide — it is a compatibility layer, and these exports exist so
Windows software can adapt to it.
The practical consequence: a sample that exits immediately under Wine has told you something. Do not record it as “does nothing.” Record it as “may detect Wine”, and escalate to Path 3. See Anti-Analysis Techniques for the broader catalog.
What Wine cannot do
- Kernel drivers. No
.sysloading, so rootkits and anything using a kernel callback are out of scope entirely. - Full API coverage. Unimplemented functions produce
fixme:messages and often an early exit. Run withWINEDEBUG=fixme+allto see which ones. - Faithful edge cases. Wine’s
RegCreateKeyExAis not Microsoft’s. Behavior around undocumented flags, error codes, and structure padding can diverge, and malware exercises exactly those corners.
Path 2: Emulation — the sample never actually runs
An emulator does not execute the sample on your CPU at all. It interprets the instructions in software and models the Windows API in Python. Nothing touches your filesystem, no syscall reaches your kernel, and a “dropped” file is a buffer in a report. For triage this is close to ideal: it is safe by construction, it is scriptable, and it produces JSON.
Speakeasy
Speakeasy is Mandiant’s Windows user-
and kernel-mode emulator. It handles PEs, DLLs, .sys drivers, and raw
shellcode, and it runs anywhere Python does.
⚠️ Install from git, not PyPI. As of September 2026 the PyPI release (
speakeasy-emulator1.5.11) pinsunicorn==1.0.2, which importsdistutils— removed from Python in 3.12. On any current distro the CLI dies at import withModuleNotFoundError: No module named 'distutils', and upgrading unicorn by hand fails differently (module 'unicorn.unicorn' has no attribute '_uc') because 2.x is API-incompatible. The git tree has been modernized — it requiresunicorn>=2.1.4and Python ≥3.10 — and installs cleanly.
$ python3 -m venv ~/venvs/speakeasy
$ ~/venvs/speakeasy/bin/pip install 'git+https://github.com/mandiant/speakeasy.git'
$ ~/venvs/speakeasy/bin/speakeasy -t specimen.exe --no-mp -o report.json
Useful flags:
| Flag | Purpose |
|---|---|
-t FILE |
Target binary |
-o FILE |
Write the JSON report |
--raw --arch x64 |
Treat input as shellcode rather than a PE |
--raw-offset |
Start shellcode execution at a hex offset |
--dropped-files-path DIR |
Save files the sample “wrote” |
-k |
Emulate child processes too |
--timeout |
Cap emulation time (default 60s) |
--gdb --gdb-port 1234 |
Expose a GDB stub and step the emulation |
--analysis-strings |
Recover strings decoded at runtime |
--no-mp |
Run in-process; makes tracebacks readable |
The report nests behavior under entry_points[].events[]:
$ jq -r '.entry_points[].events[]
| select(.event=="api")
| "\(.api_name) -> \(.ret_val)"' report.json
advapi32.RegCreateKeyExA -> 0x6
kernel32.CreateFileA -> 0x80
kernel32.WriteFile -> 0x1
kernel32.CloseHandle -> 0x1
ws2_32.WSAStartup -> 0x0
Non-API events carry extracted content — a file_write event records the path,
the size, and a SHA-256 of the data, so you can recover a dropped payload that
was never written to a real disk:
{"event": "file_write", "path": "C:\\Users\\Public\\dropped.bin",
"size": 24, "data_ref": "5317ae95596d23c0f86df8a8a718f0e28b600a7d22343e0195b599505ab5d56d"}
The lesson: count the behaviors
Go back to the ground truth. The specimen does three things. Read the Speakeasy trace again:
advapi32.RegCreateKeyExA -> 0x6 <-- 0x6 is ERROR_INVALID_HANDLE
kernel32.CreateFileA -> 0x80
kernel32.WriteFile -> 0x1
kernel32.CloseHandle -> 0x1
ws2_32.WSAStartup -> 0x0
RegSetValueExA is missing, and getaddrinfo is missing. Speakeasy’s
model of RegCreateKeyExA returned 0x6 where Wine — and Windows — returned
00000000. The specimen checks that return value, so it took the other branch
and skipped the persistence write entirely. Emulation then stopped after
WSAStartup without ever reaching the name resolution.
A tool reported two of three behaviors, with no error, no warning, and an
otherwise perfectly plausible trace. Had this been real malware, the write-up
would have said “no persistence observed” — and Wine’s trace on the same binary,
three sections up, shows RegSetValueExA writing the Run key and getaddrinfo
resolving the C2.
⚠️ An emulator’s API model is a guess about what Windows does. When the guess is wrong, the sample takes a branch it would never take on Windows and the emulator reports the resulting behavior with total confidence. Absence of evidence in an emulator is not evidence of absence. Confirm every negative finding somewhere with higher fidelity.
This is exactly why the specimen exists: it is the control that makes the failure visible. Run it whenever you install or upgrade this tooling.
A second, smaller caution from the same run: the report’s strings.static
block listed "Hello World!", MessageBoxA, and USER32.dll — none of which
appear anywhere in the specimen, which imports only ADVAPI32, KERNEL32, and
WS2_32. The report’s sha256 matched the file, so it was describing the right
binary with the wrong strings. Use strings, rabin2 -z, or FLOSS for static
strings and treat that block as unreliable until it is fixed upstream.
Qiling
Qiling (v1.4.11, September 2026) is the other mature option, built on the same Unicorn engine but designed as an instrumentable framework rather than a CLI. You write Python, and you can hook any address, any API, or any instruction:
from qiling import Qiling
from qiling.const import QL_VERBOSE
def hook_reg(ql, address, params):
print(f"[!] persistence attempt: {params}")
ql = Qiling(["specimen.exe"], "rootfs/x8664_windows", verbose=QL_VERBOSE.DEFAULT)
ql.os.set_api("RegSetValueExA", hook_reg)
ql.run()
Qiling needs a rootfs — a directory of real Windows DLLs for the emulated process to load. You must supply those yourself from a licensed Windows install; they are not redistributable, which is the main friction in getting started. Choose Qiling over Speakeasy when you need to intervene in execution — forcing a branch, faking an API result, dumping memory at a chosen instruction — rather than just observe it. The same ideas appear in Symbolic Execution with angr.
Path 3: A Windows VM under KVM, watched from outside
This is the high-fidelity option, and on Linux it has an advantage that is easy to undersell: the hypervisor is yours. Monitoring lives on the Linux side of the boundary, where the malware cannot reach it, cannot see it, and cannot disable it. On a Windows analysis host, ProcMon is a process the sample can find. Here it is not a process at all.
Snapshots are the whole safety model
$ virsh snapshot-create-as win10-analysis clean \
--description "post-install baseline, no samples" --atomic
$ virsh snapshot-revert win10-analysis clean --running
$ virsh snapshot-list win10-analysis
Revert before each sample, not after. “After” assumes the run completed the way you expected, which is the assumption malware exists to violate.
Capture traffic the sample cannot see
Find the host-side interface for the guest’s NIC, then capture on it:
$ virsh domiflist win10-analysis
Interface Type Source Model MAC
---------------------------------------------------------
vnet3 network isolated e1000e 52:54:00:1a:2b:3c
$ sudo tcpdump -i vnet3 -w sample.pcap -s 0
vnet3 is a host interface. There is no capture driver in the guest, nothing in
the guest’s process list, and nothing for the sample to tamper with. Compare
this with running Wireshark inside the VM, where a sample that enumerates
processes finds it instantly.
Give the guest an isolated libvirt network (no <forward> element) so it
cannot reach the internet, then answer its requests with a fake one. On REMnux:
$ inetsim # simulates DNS, HTTP, SMTP, FTP, IRC...
$ sudo fakenet # FakeNet-NG; alternative, same idea
$ accept-all-ips start # redirect any destination IP to local ports
accept-all-ips is the REMnux-specific piece worth knowing: malware often
connects to a hard-coded IP rather than a name, and without it those connections
land nowhere. See Malware Network Behavior for what to
do with the resulting pcap.
Acquire memory without touching the guest
This is the strongest argument for the Linux-host arrangement. libvirt can dump the guest’s RAM from outside, while it runs, with no agent inside:
$ virsh dump win10-analysis mem.dmp --memory-only --live --format win-dmp
The --format values accepted are kdump-zlib, kdump-lzo, kdump-snappy,
win-dmp, and elf. win-dmp is the one you want: it produces a Windows
crash dump, which WinDbg opens natively and which
Volatility 3 reads
without conversion. (elf is the default and needs a conversion step.)
$ vol -f mem.dmp windows.pslist
$ vol -f mem.dmp windows.malfind # injected/unbacked executable memory
$ vol -f mem.dmp windows.dlllist --pid 4242
Nothing ran inside the guest to produce that image — no WinPMEM, no DumpIt, no
driver load, no new process, no file written to the guest’s disk. The sample
cannot detect the acquisition because from inside the VM, nothing happened. For
what to do with the image, see Advanced Dynamic
Analysis, which covers malfind and injection detection
in depth.
Time the dump deliberately: pause the guest at an interesting moment
(virsh suspend), dump, then resume. Catching a packer just after it has
unpacked itself in memory but before it has wiped the buffer is the classic use.
Correlate the guest’s view with yours
Run Procmon inside the guest for the file and registry detail the hypervisor cannot see, export to CSV, and correlate it with your host-side pcap using ProcDOT on REMnux. That combination — guest-side process detail plus host-side ground-truth network capture — renders as a single graph of what the sample did, and neither half is trustworthy alone.
Hardening against VM detection
Out of the box, a KVM guest announces itself. The usual checks, and what to do:
| Detection | Default | Mitigation |
|---|---|---|
| CPUID leaf 1, ECX bit 31 | Set | <kvm><hidden state='on'/></kvm> |
| Hypervisor vendor string | KVMKVMKVM |
<hyperv mode='custom'><vendor_id state='on' value='GenuineIntel'/></hyperv> |
| MAC address OUI | 52:54:00 (QEMU) |
Set a MAC from a real vendor’s OUI |
| SMBIOS / DMI strings | QEMU, Bochs |
<smbios mode='sysinfo'/> plus a <sysinfo> block |
| CPU brand string | QEMU Virtual CPU |
<cpu mode='host-passthrough'/> |
| Disk/device model strings | QEMU HARDDISK |
Use virtio or override the product string |
<features>
<acpi/><apic/>
<kvm><hidden state='on'/></kvm>
<hyperv mode='custom'>
<vendor_id state='on' value='GenuineIntel'/>
</hyperv>
</features>
<cpu mode='host-passthrough' check='none'/>
<os>
<smbios mode='sysinfo'/>
</os>
<sysinfo type='smbios'>
<system>
<entry name='manufacturer'>Dell Inc.</entry>
<entry name='product'>OptiPlex 7090</entry>
</system>
</sysinfo>
Test your work with Pafish or al-khaser, which run the same checks malware does and report which ones fire.
⚠️ You will not win this arms race, and you should not plan to. Timing attacks against emulated instructions, and the simple absence of a plausible user — no browser history, two files on the desktop, uptime of four minutes, no mouse movement — remain detectable no matter how the XML is tuned. Hardening raises the cost of detection; it does not eliminate it. Budget more effort for making the guest look lived-in than for hiding CPUID.
Choosing a path
A decision procedure that holds up in practice:
- Is it shellcode or a
.sysdriver? Start with Speakeasy —--rawfor shellcode, direct for drivers. Wine cannot load drivers at all. - Do you have a hundred samples and one afternoon? Emulate all of them, script over the JSON, and triage by what the reports contain.
- Do you want to watch one sample’s API calls closely? Wine with a filtered relay trace, in a REMnux VM.
- Did it exit immediately, or do nothing? Assume detection. Go to the VM.
- Does it need real Windows — a service, COM, .NET, a driver, a GUI? VM.
- Are you about to write a conclusion down? Confirm it in the VM first.
The failure mode to avoid is stopping at step 2 or 3 because the output looked complete. The specimen above shows how convincing an incomplete trace can be.
What none of this gives you
Be explicit in your write-ups about what the method could not have seen:
- Time-delayed behavior. A sample that sleeps for 24 hours does nothing useful in a 60-second emulation or a 5-minute detonation.
- Human interaction. Samples that wait for a click, a document scroll, or a plausible mouse path. Interactive sandboxes such as Any.run exist for this; see Dynamic Analysis.
- Environmental keying. Malware that decrypts its payload only on a specific
domain, with a specific username, or against a specific
MachineGuidwill never reveal it to you. This is where symbolic execution earns its keep. - Kernel-mode behavior under Wine or emulation. Rootkits need the VM.
- Anything after it detected you. The most important limitation, and the hardest to notice, because its symptom is a clean, boring, uneventful report.
Key takeaways
- The three paths differ in what executes the instructions, and that decides what you may conclude. Emulate to triage, Wine to explore, VM to confirm.
- Wine gives you no isolation whatsoever — it maps
/as driveZ:, andbinfmt_miscwill run a PE the moment you type./sample.exe. Work inside a disposable VM and keep samples non-executable. WINEDEBUG=+relaywith aRelayIncludefilter is the best cheap API tracer available for Windows binaries: 406,631 lines becomes 2,609, with string arguments already dereferenced.- Emulators fail silently. A wrong API return sends the sample down a branch it would never take on Windows, and the report looks fine. Keep a specimen with known ground truth and re-run it whenever the tooling changes.
- A sample that exits instantly has told you something. “Does nothing” and “detected the analysis environment” produce identical output.
- The Linux host’s real advantage is observation from outside:
tcpdumponvnetNandvirsh dump --format win-dmpcollect full network and memory evidence with nothing running inside the guest to be found or fooled.
References
- Wine — Debug Channels, including the
relaychannel andRelayInclude/RelayExclude. https://wiki.winehq.org/Debug_Channels - Wine — Wine User Guide, “Running Wine” and debugging. https://gitlab.winehq.org/wine/wine/-/wikis/Wine-User%27s-Guide
- Wine — winedbg and the GDB proxy mode. https://gitlab.winehq.org/wine/wine/-/wikis/Debugging-Wine
- Mandiant Speakeasy — Windows user- and kernel-mode emulator. https://github.com/mandiant/speakeasy
- Mandiant — “Emulation of Malicious Shellcode With Speakeasy.” https://cloud.google.com/blog/topics/threat-intelligence/emulation-of-malicious-shellcode-with-speakeasy/
- Mandiant — “Using Speakeasy Emulation Framework Programmatically to Unpack Malware.” https://cloud.google.com/blog/topics/threat-intelligence/using-speakeasy-emulation-framework-programmatically-to-unpack-malware/
- Qiling Framework — instrumentable binary emulation. https://github.com/qilingframework/qiling
- Qiling — Windows emulation documentation and rootfs setup. https://docs.qiling.io/
- libvirt — Domain XML format, hypervisor features (
kvm hidden,hyperv vendor_id, SMBIOS). https://libvirt.org/formatdomain.html#hypervisor-features - libvirt —
virshmanual, includingdump,snapshot-create-as, anddomiflist. https://libvirt.org/manpages/virsh.html - Volatility 3 — memory forensics framework and Windows plugins. https://github.com/volatilityfoundation/volatility3
- Volatility 3 — creating and obtaining Windows symbol tables. https://volatility3.readthedocs.io/en/latest/symbol-tables.html
- REMnux — network service simulation (INetSim, FakeNet-NG,
accept-all-ips). https://docs.remnux.org/discover-the-tools/explore+network+interactions/services - REMnux — dynamically reverse-engineer code (Wine, Frida, x64dbg Automate MCP). https://docs.remnux.org/discover-the-tools/dynamically+reverse-engineer+code/general
- INetSim — Internet Services Simulation Suite. https://www.inetsim.org/features.html
- FakeNet-NG — dynamic network analysis tool with Linux support. https://github.com/mandiant/flare-fakenet-ng
- ProcDOT — correlate Process Monitor logs with packet captures. https://www.procdot.com/
- Pafish — sandbox and VM detection test tool. https://github.com/a0rtega/pafish
- al-khaser — anti-analysis technique test suite. https://github.com/LordNoteworthy/al-khaser
- x64dbg issue #1740 — crashes under Wine (
msvcp120.dll). https://github.com/x64dbg/x64dbg/issues/1740 - x64dbg issue #2535 — debugging not loaded under Wine. https://github.com/x64dbg/x64dbg/issues/2535
- RFC 2606 — Reserved Top Level DNS Names (
.test,.example,.invalid). https://www.rfc-editor.org/rfc/rfc2606 - MinGW-w64 — the cross-compiler used to build the specimen. https://www.mingw-w64.org/
Related course pages: Dynamic Analysis · Advanced Dynamic Analysis · Working with Wine · Anti-Analysis Techniques · Malware Network Behavior · Malware Triage · REMnux Installation · Symbolic Execution with angr
🛠️ Maintenance note: the tool-specific details here move faster than the concepts. The Speakeasy PyPI-versus-git problem is a snapshot of September 2026 — check whether a release past 1.5.11 has shipped with the
unicorn>=2.1.4dependency before telling students to install from git, and re-check thestrings.staticanomaly, which may be fixed upstream. Relay line counts (406,631 / 2,609) were measured on Wine 11.17 withcmd /c verand will drift with every Wine release; the ratio is the point, not the numbers.virsh dump --formatgainedwin-dmprelatively recently, so verify it against the libvirt shipped in whatever REMnux base release is current. Anti-VM hardening is an arms race and the table will rot — re-run Pafish each term rather than trusting it. The durable content is the three-path split, the emulator-fidelity failure, and observing from outside the guest.