Skip to content

React2Shell

约 3297 字大约 11 分钟

JS

2025-12-09

未完待续,更新学习中

上周四漏洞刚出现时候扫了一眼,看了下周围消息基本都指向这只是一个针对开启了特定服务的 next.js 应用,用处不是很大,(毕竟都是五位数的编号),而且第一天貌似是一个假的 poc

image-20251209144733870

等周五睡醒发现 dify 已经被这个打烂掉进入应急阶段了,荒谬的转变。不过今年 next.js 已经爆过不少洞了(封面灵感来源),借着这个机会完善下对 Js 理解以及学下现代的这些前端框架原理(太好了我的博客是 vue)

那么,在现在前后端分离占主导的背景下,如何 React2Shell,是 SSR 机制的问题吗?

JavaScript 发展历程

回调地狱

JavaScript 作为一个异步语言,在远古时期面对执行耗时操作时,会选择使用回调函数,当执行完毕时调用这个回调函数,在面对较多调用时就会嵌套进无数的函数内,称为回调地狱,丑陋不堪

// 模拟:购买商品的复杂流程
const buyProduct = (userId, productId, finalCallback) => {
    
    // 第一步:检查库存
    checkStock(productId, (err, stock) => {
        if (err) {
            // 痛苦点1:每一步都要手动处理错误
            console.error("查询库存失败", err);
            return;
        }
        if (stock <= 0) {
            console.error("库存不足");
            return;
        }

        // 第二步:锁定库存(需要依赖上一步成功)
        lockStock(productId, (err, lockId) => {
            if (err) {
                console.error("锁定库存失败", err);
                return; // 必须记得 return,不然代码继续往下跑
            }

            // 第三步:创建订单(需要依赖 lockId)
            createOrder(userId, productId, lockId, (err, orderId) => {
                if (err) {
                    console.error("创建订单失败", err);
                    // 还要记得去解锁库存...逻辑极其复杂
                    unlockStock(lockId); 
                    return;
                }

                // 第四步:扣款(需要依赖 orderId)
                payment(orderId, (err, payResult) => {
                    if (err) {
                        console.error("扣款失败", err);
                        return;
                    }
                    
                    // 终于成功了...
                    finalCallback("下单成功!");
                });
            });
        });
    });
};

不禁让我想起我之前 vibe coding 出来的 flutter 桌面应用,谁拉这了?

image-20251209170739119

Promise

在 ES6 中引入了 Promise 对象,一定程度上缓解了之前回调的问题,从多层嵌套变成在连续的一个上下文中,可读性有所增加,通过 then() 和 catch() 实现一些类似于函数式编程的思想

function buyProductPromise(userId, productId) {
    let currentLockId = null; // 用于存储 lockId,以便在失败时解锁

    // 1. 检查库存
    return checkStock(productId)
        .then(stock => {
            console.log(`✅ Stock available: ${stock}`);
            
            // 2. 锁定库存 (返回一个新的 Promise,进行链式连接)
            return lockStock(productId);
        })
        .then(lockId => {
            currentLockId = lockId; // 存储 lockId
            console.log(`✅ Stock locked: ${lockId}`);
            
            // 3. 创建订单
            return createOrder(userId, productId, lockId);
        })
        .then(orderId => {
            console.log(`✅ Order created: ${orderId}`);
            
            // 4. 扣款
            return payment(orderId);
        })
        .then(payResult => {
            // 最终成功的回调
            console.log(`🎉 购买成功!`);
            return payResult; // 将最终结果传递下去
        })
        // 5. 集中错误处理
        .catch(error => {
            console.error(`❌ 交易流程中发生错误: ${error.message}`);
            
            // 无论哪一步出错,都可以在这里统一处理
            if (currentLockId) {
                // 只有在锁定成功后,但在支付或订单创建失败时,才需要解锁
                unlockStock(currentLockId); 
            }
            // 将错误重新抛出,以便外部调用者也能捕获到
            throw error;
        });
}

// --- 调用示例 ---
const USER_ID = "U123";
const PRODUCT_ID = "P456";

buyProductPromise(USER_ID, PRODUCT_ID)
    .then(result => {
        console.log("最终结果:", result);
    })
    .catch(finalError => {
        console.log("交易结束 (失败):", finalError.message);
    });

Promise 代表一个异步操作最终完成或失败及其结果值的对象,Promise 的构造函数需要传入一个被称为 executor 的执行函数。这个执行函数接收两个参数:resolvereject

const myFirstPromise = new Promise((resolve, reject) => {
    // 异步操作代码...
    
    // 模拟一个异步操作,例如 1 秒后成功
    setTimeout(() => {
        const successData = "数据已成功获取!";
        // 成功时,调用 resolve() 并传递结果值
        resolve(successData); 
    }, 1000);

    // 假设发生错误:
    // if (errorCondition) {
    //     reject(new Error("发生了一个错误!"));
    // }
});

如果走到 resolve 就会把 Promise 的状态从 pending 改成 fulfilled ,然后把 value 传递出去,如果 reject 了就传一个失败的原因出去,最后被 catch 到

async & await

Promise 虽然解决了一些问题但是和我们现在常见的同步语言还是差距较大,不够直观简洁美观,所以在 ES2017 中又加入了新的语法糖,await/async ,任何被 async 修饰的函数,其返回值都会被自动包装成一个 Promise 对象,await 去执行一个异步函数,就是把这一行代码后面的内容放进了这个 Promise 的 then() 中

代码简化如下:

async function buyProductAsync(userId, productId) {
    let currentLockId = null; 

    try {
        console.log("Step 1: 检查库存...");
        const stock = await checkStock(productId); 
       
        if (stock <= 0) {
            throw new Error("库存不足");
        }
        console.log("Step 2: 锁定库存...");
        const lockId = await lockStock(productId);
        currentLockId = lockId;
        
        console.log("Step 3: 创建订单...");
        const orderId = await createOrder(userId, productId, lockId);
        
        console.log("Step 4: 扣款...");
        const payResult = await payment(orderId);
        
        console.log("🎉 购买流程成功!");
        return payResult;

    } catch (error) {
        console.error(`❌ 流程失败: ${error.message}`);
        if (currentLockId) {
            console.warn(`[Cleanup] 解锁库存 ${currentLockId}`);
        }
        throw error;
    }
}

// 外部调用
buyProductAsync("U123", "P456")
    .then(result => {
        // ... 成功处理
    })
    .catch(error => {
        // ... 最终错误处理
    });

Thenable

在 ES6 标准化 Promise 之前,社区中已经存在多种 Promise 的实现库(如 jQuery 的 Deferred 对象、Q、Bluebird 等)。这些库虽然都实现了异步操作的链式调用和错误处理,但它们的具体 API 和实现细节各不相同。

为了实现兼容,即允许一个 Promise 库返回的对象能被另一个 Promise 库或原生的 Promise 机制所接受和处理,社区制定了 Promise/A+ 规范,只要一个对象有 .then(onFulfilled, onRejected) 这个结构,它就被视为 Thenable,也就可以当作一个 Promise 去传入到 async/await 中

function delay(ms) {
    return {
        then(resolve, reject) {
            console.log(`Thenable 开始计时:${ms}ms`);
            setTimeout(() => {
                resolve(`完成:${ms}ms`);
            }, ms);
        }
    };
}

async function demo() {
    console.log("开始 await");

    const result = await delay(1000);

    console.log("await 结果:", result);
}

demo();

用这种 Thenable 的写法就能兼容一些老的 js 引擎,但是如果一个对象只要包含一个 then 函数,就会变成一个可被 await 调用的对象,如果一个对象的key被用户所 控制,那么就可以伪造出一个Thenable对象,这也是React2Shell CVE-2025-55182这个漏洞能够运行的原因之一

React & Next.js

由于我此前从未写过 react 和 next.js ,这里可能写的不够好

React

React 用一句话概括就是:

UI = f(state)

多年前的需要通过 id 找到并修改 dom 元素,2013年 FaceBook 推出了 React,从操作 dom 变成操作 JS 对象,虚拟 dom 和组件化一定程度简化了开发,这个时期的 React 是“面向对象”的。我们通过定义 Class 来创建组件,数据保存在 this.state 中。

2019年,React 发布了 Hooks,React 彻底转向了函数式编程,组件就是类似 html 式的 js

const planet = 'world'
const img = "./image.svg"
<div>Hello {planet}</div>
<img src = {img} />
import { useState } from 'react';

function Counter() {
  // 一行代码搞定状态,清晰明了
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>
        加1
      </button>
    </div>
  );
}

Next.js:React 开发框架

虽然 React Hooks 很好用,但纯 React 应用有一个致命弱点:它是个空壳子

  • CSR 的局限: 当你查看纯 React 网页的源代码时,往往只有一个 <div id="root"></div>。浏览器必须先下载巨大的 JS 文件,执行代码,然后再去请求数据,最后才渲染出内容。这导致首屏加载慢,且对 SEO(搜索引擎优化)很不友好。
  • Next.js 的登场: Next.js 提出,“为什么不在服务器先把 HTML 拼好,再发给浏览器呢?”

解决方案: RSC (React Server Components)。组件被分成了两类:

  1. 服务端组件 (Server Component): 直接在服务器运行,直接连数据库,只把渲染好的 HTML 发给浏览器。
  2. 客户端组件 (Client Component): 也就是上面提到的 Hooks 写法,负责浏览器里的交互(点击、滑动)。
// 1. app/page.jsx (默认是服务端组件)
// 这个组件在服务器运行,浏览器拿不到这段代码,只能拿到结果
import db from './database'; 

export default async function Page() {
  // 直接查库!这在以前的 React 里是不可能的
  const data = await db.query('SELECT * FROM items');
  
  return (
    <div>
      <h1>商品列表</h1>
      <ul>
        {data.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      {/* 2. 嵌入一个客户端组件负责交互 */}
      <LikeButton />
    </div>
  );
}
// 3. app/LikeButton.jsx (客户端组件)
'use client'; // 显式标记为客户端组件

import { useState } from 'react';

export default function LikeButton() {
  const [likes, setLikes] = useState(0);
  return <button onClick={() => setLikes(likes + 1)}>点赞 {likes}</button>;
}

这种跨越式的发展是否带来了很多不必要的麻烦?赋予了 React 直接交互后端的功能,前端真的知道他在写什么吗,可谓是框架能力越大攻击面越大,可能这也就是为什么今年已经见过多起 Next.js 的通报了

image-20251215172736579

漏洞环境搭建/调试

说了这么多终于进入到漏洞相关环节了,这次漏洞影响范围为

React Server 19.0.0、19.1.0、19.1.1、19.2.0

15.1.0 <= Next.js < 15.1.9
15.2.0 <= Next.js < 15.2.6
15.3.0 <= Next.js < 15.3.6
15.4.0 <= Next.js < 15.4.8
15.5.0 <= Next.js < 15.5.7
16.0.0 <= Next.js < 16.0.7

我们直接搭建一个 Next.js 15.5.6,其默认使用的打包工具是Turbopack(Webpack),内置的reactserver-dom-turbopack版本是19.2.0

npx create-next-app@15.5.6 nextjs-cve-2025-55182 --yes

cd nextjs-cve-2025-55182
npm install
npm run build
npm run start

那么如何调试 Next.js ?

要调试这个漏洞,世界上最简单的方法就是直接使用 --inspect 并配合Chrome浏览器,你甚至连编译器都不需要配置。

进入项目目录后,设置环境变量,然后启动 dev 模式

$env:NODE_OPTIONS="--inspect=0.0.0.0:9229" #powershell
export NODE_OPTIONS="--inspect"  #linux
 
npm run dev #注意不是 start

此时控制台会有debug相关的日志,可见 node.js 监听了一共三个端口:

  • 3000:这是Web应用的端口
  • 9229:这是dev服务器守护进程的调试端口
  • 9230:这是应用进程的调试端口
C:\CVE\nextjs-cve-2025-55182> npm run dev                                   
Debugger listening on ws://0.0.0.0:9229/57021497-90a0-47b3-bc21-42238ae858cc
For help, see: https://nodejs.org/en/docs/inspector
Debugger listening on ws://0.0.0.0:9229/0ec886d5-bfb5-49de-a837-475749dc5010
For help, see: https://nodejs.org/en/docs/inspector

> nextjs-cve-2025-55182@0.1.0 dev
> next dev --turbopack

Starting inspector on 0.0.0.0:9229 failed: address already in use
Debugger listening on ws://0.0.0.0:9230/9bf4f092-5ee7-4de8-b7da-6f319ef226af
For help, see: https://nodejs.org/en/docs/inspector
   the --inspect option was detected, the Next.js router server should be inspected at 0.0.0.0:9230.      
   ▲ Next.js 15.5.6 (Turbopack)
   - Local:        http://localhost:3000
   - Network:      http://192.168.31.191:3000

 ✓ Starting...
 ✓ Ready in 1331ms

理想状态下在 chrome 地址栏中输入 chrome://inspect/就会看到如下的内容,如果没有9230就点 Configure 手动加一个

inspect 进去

image-20251215163325797

image-20251215164020715

默认情况下,Chrome 调试的时候不会加载 node_modules 中的 source map,这会导致代码无法阅读。我们需要点右上角的配置

image-20251215164206531

image-20251215164144378

然后重启一下

如何寻找调试的位置?

在这个漏洞中,如果填入一些非 json 的字符串,服务端会返回 json 解析错误

image-20251215164733846

然后我们勾选,不过也可以在后面已知具体漏洞点再去直接下断点,如果不知道再这样一步步跟进

image-20251215173137885

React2Shell

终于进入主题,先简单讲几个概念

  • SSR (Server-Side Rendering): React 原本是一个前端框架,但为了 SEO 优化和提升首次渲染速度,Next.js 使用 SSR 技术将首次渲染放在后端执行,并将渲染后的 HTML 代码返回给浏览器,虽然提升了首屏速度,但为了让页面变得“可交互”,服务端必须将初始状态数据(Initial State)序列化后随 HTML 一同下发。

  • RSC (React Server Components): React 18 引入并在 Next.js 13+ 中成为默认。与传统 SSR 不同,他默认所有组件都是 Server Components(服务器组件):这些组件只在服务器上渲染,可以直接访问数据库、文件系统、环境变量等服务器资源,支持 async/await 直接 fetch 数据。不发送任何 JS 到客户端(零 bundle 开销),渲染结果通过一种特殊的格式(RSC Payload)发送给客户端,客户端只用它来构建 DOM。

  • Server Actions: React 19以后引入,是 RSC 生态的配套功能,用于处理数据 mutation(如表单提交、更新数据库),可以从客户端组件直接调用,像调用普通函数,但实际执行在服务器上,无需单独写 API 路由,不像传统 REST/GraphQL,直接在组件中处理 mutation,代码更简洁。

React 借助 Flight 协议去处理前后端通信,也就是漏洞的一个重要利用部分,虽然看起来是 RSC 的问题,但是实际上是在利用 Server Actions 的功能

后记

最近效率低下导致很多想做的都没实现,这样比较复杂的漏洞学起来还是有点耗时,写起来就像便秘 非常羡慕 p 神或者其他佬写的文章,有一种艺术感,自己写的东西索然无味,希望多年之后有-更多积累,能写出更好的东西

参考文章

(https://t.zsxq.com/tZtE3)

(https://t.zsxq.com/GHYVV)

(https://t.zsxq.com/woZm1)