Phân tích kỹ thuật khai thác lỗ hổng React2Shell (CVE-2025-55182)

hdtb00000

Super Moderator
Thành viên BQT
26/12/2025
2
1 bài viết
Phân tích kỹ thuật khai thác lỗ hổng React2Shell (CVE-2025-55182)
Lỗ hổng React2Shell (CVE-2025-55182) là một lỗ hổng Remote Code Execution nghiêm trọng trong React Server Components, được coi là tâm điểm chú ý trong cộng đồng bảo mật cuối năm nay. Lỗ hổng này thu hút sự quan tâm lớn từ cộng đồng bảo mật kể từ khi các PoC khai thác được công bố.

1766975176030.png

1. Tổng quan lỗ hổng
React2Shell (CVE-2025-55182) là một lỗ hổng Remote Code Execution (RCE) nghiêm trọng xuất hiện trong React Server Actions, ảnh hưởng trực tiếp đến các ứng dụng sử dụng React Server Components (RSC), tiêu biểu là Next.js. Lỗ hổng này xuất phát từ cách React Flight Protocol xử lý dữ liệu và hành vi mặc định của JavaScript runtime. Thông qua việc thao túng dữ liệu deserialize, attacker có thể kiểm soát cấu trúc object nội bộ của React, từ đó điều khiển luồng thực thi và dẫn đến thực thi mã tùy ý trên server.
Lỗ hổng CVE-2025-55182 ảnh hưởng đến các gói React Server Components cụ thể, bao gồm:​
  • react-server-dom-webpack
  • react-server-dom-parcel​
  • react-server-dom-turbopack
Các phiên bản bị ảnh hưởng của các gói này là từ 19.0.0 đến 19.2.0.
Nhiều framework phổ biến phụ thuộc vào các gói này và do đó cũng bị ảnh hưởng. Các framework này bao gồm:​
  • Next.js: Các phiên bản từ 14.3.0-canary.77 trở lên khi sử dụng App Router.​
  • React Router RSC preview​
  • Waku​
  • Vite RSC Plugin​
  • Parcel RSC Plugin​
  • RedwoodSDK​

2. Tìm hiểu về React Server Components và React Flight
Để hiểu được React2Shell, ta cần nắm rõ được cơ chế React Server Actions và giao thức React Flight họat động.
Server Actions là một tính năng trong Next.js cho phép định nghĩa và chạy các hàm trên server một cách trực tiếp từ React component – thay vì cần tạo các endpoint API riêng biệt. Cơ chế hoạt động của Server Actions như sau:

Trong đó Request gọi Server Action chứa các header đặc biệt Next-Action, header này xác định hàm server-side cần thực thi, Body chứa dữ liệu được serialize bằng React Flight protocol.
React Flight là giao thức serialize/deserialize được React sử dụng để truyền dữ liệu giữa server và client trong mô hình RSC. Khác với JSON truyền thống chỉ biểu diễn dữ liệu tĩnh, React Flight có khả năng biểu diễn các kiểu dữ liệu đặc biệt thông qua chuỗi bắt đầu bằng kí tự $:​
  • Promise / Chunk: $@​
  • Tham chiếu giữa các chunk : $0-9a-f​
  • Server function reference: $F​
Để hỗ trợ streaming UI, React Flight chia dữ liệu thành các chunk, mỗi chunk có một ID riêng và có thể tham chiếu đến chunk khác.
Khi client gọi một Server Action, request thường được gửi dưới dạng multipart/form-data.
Đây là payload tối giản nhất bao gồm những trường dữ liệu cần thiết để khai thác thành công, thường được sử dụng tính đến thời điểm hiện tại:​

1766975568918.png
Trong đó:
Chúng ta đã biết header Next-action/rsc-action-id cần thiết để cho server nhận biết đây không phải 1 request tải trọng bình thường mà là yêu cầu xử lí phía server. Nhưng ta không cần chỉ định rõ action cần xử lí, chỉ cần có header này để server chịu xử lí phần body gói tin này là được.
Header content-type: multipart/form-data, cho server nhận biết loại dữ liệu cần xử lí.
Trong Next.js, request này được xử lý bởi hàm: decodeReplyFromBusboy(req, ...)
Hàm này sẽ có nhiệm vụ:​
  • Phân tích từng field trong form-data​
  • Lưu trữ dữ liệu vào các biến nội bộ​
  • Chuẩn bị tham số cho việc gọi Server Action​
Và quá trình deserialize React Flight được thực hiện ngay tại giai đoạn này, trước khi bất kỳ cơ chế xác thực hay kiểm soát logic ứng dụng nào được áp dụng.
Điều này tạo ra một điều kiện lý tưởng cho attacker: chỉ cần gửi một request đúng định dạng Flight, attacker đã có thể tác động trực tiếp đến lỗi logic của React.

3. Khai thác lỗ hổng này thế nào
Server nhận và parse multipart payload từ client:

Request POST multipart/form-data với các field "0", "1".

3.1. Đầu tiên, Attacker sẽ giả mạo trạng thái dữ liệu nội bộ để tạo object runtime- Internal State Spoofing
Trong React Flight Protocol, mỗi đơn vị dữ liệu được truyền giữa client và server được biểu diễn dưới dạng một Chunk. Chunk không chỉ là dữ liệu tĩnh, mà còn đại diện cho trạng thái xử lý runtime trong quá trình stream, resolve và hydrate dữ liệu. Mỗi chunk trải qua một vòng đời với nhiều trạng thái rõ ràng, phản ánh mức độ sẵn sàng và mức độ an toàn của dữ liệu tại thời điểm đó. Việc React2Shell khai thác thành công xuất phát từ khả năng giả mạo và thao túng các trạng thái này.
Chuỗi trạng thái cơ bản sẽ như sau:
1766975743907.png

Trạng thái Pending – dữ liệu chưa đáng tin
Đây là trạng thái ban đầu khi React mới ghi nhận sự tồn tại của chunk nhưng chưa có dữ liệu hoàn chỉnh.
Có thể đại diện cho:​
  • Promise đang chờ resolve​
  • Dữ liệu đang được stream​
  • Component chưa hydrate xong​
Ở trạng thái pending, React chưa tin tưởng dữ liệu, và các thao tác nguy hiểm chưa được thực hiện.
Resolved_model – Dữ liệu đã được giải mã
Đây là trạng thái cực kỳ quan trọng và cũng là điểm khai thác trọng tâm của React2Shell.
Khi chunk đạt trạng thái này, React hiểu rằng:​
  • Dữ liệu đã được JSON.parse​
  • Cấu trúc dữ liệu là hợp lệ​
  • Chunk được tạo bởi logic nội bộ của framework​
Đây chính là sai lầm nghiêm trọng của React: không kiểm tra nguồn gốc của trạng thái resolved_model. Cụ thể:​
  • Không xác thực chunk này có thực sự do server tạo ra​
  • Không kiểm tra hasOwnProperty​
  • Không chặn các key nguy hiểm như: constructor, __proto__, then​
Điều này cho phép attacker gửi trực tiếp một chunk với status: "resolved_model" từ client. Khi đó React tin rằng dữ liệu này đã an toàn, bỏ qua toàn bộ cơ chế kiểm soát trung gian. Dữ liệu attacker kiểm soát được nâng cấp thành dữ liệu thuộc nội bộ.
Initialized – Object runtime đã được khởi tạo
Sau khi resolveModel xử lý xong, React gọi hàm reviveModel. Hàm này sẽ thực hiện:​
  • Duyệt đệ quy toàn bộ object​
  • Resolve các tham chiếu $...( nếu có)​
  • Khôi phục Promise, thenable, callback (nếu có)​
Và Chunk lúc này sẽ được chuyển sang trạng thái Initialized. Ở trạng thái này, Object sẽ trở thành 1 phần của runtime, mọi truy cập thuộc tính kích hoạt các hành vi JavaScript thực sự và Prototype chain bắt đầu có hiệu lực.
Và tiếp tục ở đây lại xuất hiện lỗ hổng đó là hàm reviveModel không chặn thao tác trên prototype chain, cho phép attacker cài cắm các thuộc tính nguy hiểm ngay trong quá trình revive.
Blocked và Cyclic – trạng thái dùng để điều khiển luồng
Blocked: chunk bị chặn do phụ thuộc Promise khác
Cyclic: phát hiện self-reference hoặc vòng tham chiếu
Hai trạng thái này không trực tiếp gây RCE, nhưng trong React2Shell, attacker lợi dụng chúng để thực hiện những hành vi cần thiết để khai thác lỗ hổng như buộc React resolve lại chunk, kích hoạt callback đã bị thao túng trước đó, điều khiển thời điểm thực thi payload.

Vậy lợi dụng trạng thái này như thế nào?
Lợi dụng bằng cách gán luôn giá trị status: "resolved_model" cho dữ liệu. Như vậy sau khi đọc dữ liệu từ form-data, React sử dụng resolveModelChunk để xử lý từng chunk, giá trị chunk là một chuỗi JSON do client kiểm soát hoàn toàn được đưa vào JSON.parse, có được ngay giá trị trạng thái của dữ liệu là status: "resolved_model" - Dữ liệu đã được giải mã và được tin cậy.
Sau đó, React gọi hàm reviveModel, reviveModel sẽ duyệt đệ quy toàn bộ object để tái tạo cấu trúc runtime theo React Flight Protocol.
Hàm này:​
  • Duyệt đệ quy object​
  • Resolve các tham chiếu $…​
  • Khôi phục Promise / thenable / callback​
Chunk lúc này chuyển sang trạng thái INITIALIZED – trở thành một phần của runtime thật sự.
Và ở đây React không chặn các key đặc biệt như __proto__, constructor, prototype, cũng không kiểm tra hasOwnProperty. Điều này cho phép attacker thao túng prototype chain của JavaScript ngay trong quá trình revive. Hệ quả là dữ liệu người dùng không còn chỉ là dữ liệu tĩnh, mà đã trở thành object runtime có khả năng ảnh hưởng đến hành vi mặc định của JavaScript engine.

3.2. Tiếp theo, lợi dụng cơ chế React áp dụng Duck Typing của JavaScript đối với Promise.
Theo cơ chế này, bất kỳ object nào có thuộc tính then đều có thể được coi là Promise hợp lệ.
Ví dụ:​
1766975917152.png

Như vậy, dù không phải new Promise, object này vẫn được xử lý như Promise. React tận dụng đặc điểm này để xử lý bất đồng bộ, nhưng lại tin tưởng quá mức nên đồng thời cũng vô tình mở ra khả năng thenable confusion.
“ Lưu ý Thenable confusion xảy ra khi framework hoặc thư viện tin tưởng rằng mọi object có then đều là Promise an toàn, nhưng thực tế attacker có thể:​
  • Tạo object giả mạo với then chứa logic độc hại.​
  • Buộc React (hoặc bất kỳ code nào) gọi obj.then trong runtime.​
Và kết quả là dữ liệu từ client không chỉ được parse, mà còn được thực thi như callback trong server context.”
Cụ thể như sau, trong quá trình revive, khi React gặp một chuỗi bắt đầu bằng $, nó sẽ chuyển sang xử lý đặc biệt thông qua hàm parseModelString().
Như vậy với payload: "$1:then:constructor" sẽ được React hiểu theo thứ tự như sau:​
  • Resolve chunk có ID = 1​
  • Truy cập thuộc tính then​
  • Truy cập tiếp constructor của then​
Trong JavaScript, then là một hàm, và then.constructor chính là Function constructor. Như vậy tại đây attacker đã có​

1766976078118.png

Và vì là trong JS nó tương đương với:
1766976129744.png

Tại thời điểm này, mặc dù hàm này vẫn chưa được thực thi, nhưng đã thành công trong việc đưa Function constructor vào luồng xử lý runtime của React.

3.3. Cuối cùng, kích hoạt Promise giả mạo và leo thang qua gadget Blob
Sau khi attacker đưa được Function constructor vào runtime bằng chuỗi $1:then:constructor, bước tiếp theo là ép React tự động gọi nó trong luồng hợp lệ.
Payload $@0: báo cho React rằng chunk 0 là Promise và cần await. Khi await, JS sẽ gọi obj.then(...).
Thay thế then: attacker gán trong payload then: "$1:then", khiến React resolve tham chiếu này và vô tình truy cập prototype chain nội bộ, dẫn tới obj.then bị resolve thành Function constructor.
Sử dụng chuỗi Gadget dẫn tới Blob:
1766976252894.png

Trong quá trình xử lý Blob, React gọi response._formData.get(blobKey) nhưng Attacker đã ghi đè hàm .get này bằng "$1:then:constructor". (xem lại payload ban đầu nhé).
Kết quả là mỗi lần React gọi .get(), nó thực chất chạy new Function(blobKey). Payload độc hại được đặt trong _prefix (ví dụ ở đây là console.log("PWN")//), nên khi React xử lý Blob, đoạn mã này được biên dịch và thực thi ngay trên server là new Function(“console.log(‘PWN’)//”)(“argument_rác_của_hàm_get”).
Dấu // được dùng để comment phần dư phía sau, đảm bảo đoạn code JS hợp lệ.​

4. Luồng xử lý thực tế với payload cụ thể
Dựa vào mục 4 ta đã biết được những vị trí thiếu bảo mật và bị lợi dụng trong quá trình khai thác. Để hiểu rõ và có logic hơn ta sẽ đi vào luồng xử lý thực tế với payload cụ thể của chúng ta:
1766989149852.png

Giai đoạn 1 – Nhận request & deserialize
Bước 1: Next.js nhận multipart/form-data
Request đi vào: decodeReplyFromBusboy(req, ...)
Tại đây:
  • Mỗi field (0, 1) được đọc
  • Lưu vào store nội bộ của React Flight
  • Chưa có auth, chưa có logic ứng dụng
Bước 2: React bắt đầu đọc chunk 0
Field 0 được xử lý đầu tiên:
1766989251691.png

Điểm quan trọng là React tin trạng thái này và không kiểm tra chunk có do server tạo không.
=> Chunk 0 được coi là đã an toàn
Giai đoạn 2 – resolveModelChunk (tin tưởng dữ liệu giả mạo)
Bước 3: resolveModelChunk(0)
React thực hiện:
1766989289313.png

Và vì status === "resolved_model" => React bỏ qua mọi kiểm tra trung gian.
Lúc này các field sau được giữ nguyên trong runtime:
  • _response
  • _formData
  • _prefix
  • then
Giai đoạn 3 – reviveModel (đưa payload vào runtime thật)
Bước 4: Gọi reviveModel(parsedValue, ...)
React bắt đầu:
  • Duyệt đệ quy object
  • Xử lý chuỗi đặc biệt $...
Bước 5: revive field then: "$1:then"
React thấy chuỗi: "$1:then"
Parse như sau:
  • Resolve chunk ID = 1
  • Truy cập thuộc tính then
Nhưng chưa thực thi, chỉ tạo reference.
Bước 6: revive value: {"then":"$B"}
React gặp: "$B" → Nhánh xử lý Blob gadget được ghi nhớ
Chưa chạy, chỉ đánh dấu
Giai đoạn 4 –Chuyển sang xử lí Chunk 1, Chunk 1 được dùng để ép Promise
Bước 7: React xử lý chunk 1
Chunk 1: "$@0"
Ý nghĩa:
  • @ → đây là Promise
  • 0 → Promise trỏ tới chunk 0
React hiểu: chunk0 là Promise → cần await
Giai đoạn 5 – Promise bị await (thenable confusion kích hoạt)
Bước 8: React gọi await chunk0
Khi await: await obj
JS runtime bắt buộc gọi: obj.then(resolve, reject)
Đây là lần đầu field then được dùng. Và then của chunk 0 là: then = "$1:then"
React resolve tiếp: chunk1 → then → constructor
Giai đoạn 6 – constructor bị đưa vào runtime
Bước 9: Resolve chuỗi $1:then:constructor
Chuỗi này được phân tích lần lượt:
  • chunk 1
  • .then → một function
  • .constructor → Function
Kết quả: Function
React vô tình lấy được Function constructor
Giai đoạn 7 – Blob gadget kích hoạt RCE
Bước 10: React xử lý $B (Blob)
React vào nhánh:
1766989473521.png
Bước 11: Hàm .get() bị gọi
Nhưng attacker đã ghi đè ở trước đó _formData.get = "$1:then:constructor" → React resolve chuỗi này thành Function
Và gọi nó như hàm get: Function(prefix)(blobKey)
Bước 12: Payload thực thi
Vì: _prefix = 'console.log("PWN")//'
JS thực thi: new Function('console.log("PWN")//')(blobKey)
RCE xảy ra tại đây.
Như vậy, attacker đã có thể thực thi mã tùy ý trên server nạn nhân rồi. Muốn thực thi lệnh gì, chỉ cần thay vào trường _prefix là được.

Một số các payload khai thác:
- HTTP in-memory webshell:​
Ghi đè http.Server.prototype.emit để chèn backdoor thực thi lệnh thông qua HTTP request. Cho phép thực thi lệnh hệ điều hành từ xa

1766990043140.png

- RCE + leak output qua exception (NEXT_REDIRECT)​
Thực thi lệnh hệ điều hành và đưa output vào thuộc tính digest của lỗi NEXT_REDIRECT.

1766990101574.png
- Out-of-band data exfiltration
Đọc file hệ thống nhạy cảm và gửi nội dung ra ngoài qua HTTPS POST request. Sử dụng kênh out-of-band để xác nhận RCE và đánh cắp dữ liệu.
1766990177866.png


5. Tổng kết
React2Shell (CVE-2025-55182) là một trong những lỗ hổng nghiêm trọng nhất từng xuất hiện trong hệ sinh thái React. Đây là một case study điển hình cho các kỹ sư bảo mật, nhấn mạnh rằng: mọi dữ liệu đến từ client, dù được bọc trong giao thức phức tạp đến đâu, đều phải được coi là không tin cậy.
React2Shell không phải là lỗi cú pháp hay sơ suất đơn lẻ, mà là hệ quả của:​
  • Deserialize dữ liệu phức tạp từ client​
  • Tin tưởng vào duck typing của JavaScript​
  • Không kiểm soát prototype chain​
  • Thiếu threat model cho dữ liệu runtime​
React team đã vá lỗi bằng cách:​
  • Kiểm tra hasOwnProperty​
  • Chặn truy cập prototype chain​
  • Không cho client kiểm soát trạng thái nội bộ​
Để đảm bảo an toàn cho hệ thống, cần cập nhật sớm nhất có thể.
Cập nhật cho React, người dùng React Server Components nên cập nhật ngay lên một trong các phiên bản sau:​
  • React 19.0.1​
  • React 19.1.2​
  • React 19.2.1​
Cập nhật cho Next.js, người dùng Next.js nên nâng cấp lên các phiên bản tương ứng dựa trên phiên bản hiện tại của họ:​
  • Next.js 15.0.5​
  • Next.js 15.1.9​
  • Next.js 15.2.6​
  • Next.js 15.3.6​
  • Next.js 15.4.8​
  • Next.js 15.5.7​
  • Next.js 16.0.7​
Mặc dù các nhà cung cấp WAF (Web Application Firewall) như Cloudflare và AWS đã triển khai các bộ quy tắc bảo vệ, nhưng tính đến thời điểm ngày hôm nay đã xuất hiện một số mã khai thác PoC bypass đươjc qua các rule này. Do đó, việc áp dụng bản vá bảo mật vẫn là chiến lược giảm thiểu đáng tin cậy và hiệu quả nhất.
Với tình trạng khai thác tích cực, điểm nghiêm trọng tối đa và mức độ sử dụng rộng rãi của các framework bị ảnh hưởng, các tổ chức đang chạy React Server Components nên ưu tiên vá lỗi khẩn cấp để đảm bảo an toàn thông tin cho hệ thống của mình.​
 
Chỉnh sửa lần cuối:
Bên trên