Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

Node2Shell (CVE-2026-111497): lỗi Brotli trong Node.js và bài học về runtime

Tung Bui Thanh (TungDaDev)16 mins read

Javascript code on dark screen

Giả sử bạn có một service Express 5 nhận JSON từ app mobile, chạy trong container node:24 từ hồi tháng 8. Lockfile sạch, npm audit báo 0 vulnerabilities, Dependabot im re. Sáng thứ Tư 21/10/2026, một thông báo bảo mật của Node.js xuất hiện, và service của bạn nằm trong diện bị ảnh hưởng, dù không có dòng nào trong package.json liên quan tới Brotli.

Chuyện này không hiếm. Lỗi nằm trong một thư viện C mà bản thân runtime đã nhét sẵn vào binary. Quét dependency của project thì không thấy nó, vì nó đâu có nằm trong node_modules.

Cái tên "Node2Shell" nghe giật gân, nhưng cơ chế bên dưới lại khá nhỏ và dễ hiểu. Hiểu được nó thì cũng biết luôn mình có dính hay không.

# dòng thời gian

Các mốc dưới đây mình ghi theo nguồn gốc (advisory của Node.js, bản tin CISA, commit trên GitHub). Chỗ nào chỉ có nguồn thứ cấp thì mình nói rõ.

ngày (2026)sự kiện
27/02Google commit bản sửa vào nhánh chính của google/brotli (commit 0ed4268, tên commit là "Detect and report invalid stream earlier"). Không có release hay advisory đi kèm, nên không ai coi đây là bản vá bảo mật
21/10Node.js công bố bản tin security release, phát hành 22.23.4, 24.22.1, 26.12.1. Google ra Brotli 1.0.10, 1.1.1, 1.2.1
22/10Các gói Python Brotli và brotlicffi có bản sửa. Imperva công bố phân tích kèm thử nghiệm trên Node.js 24.22.0 (Linux arm64)
23/10Bun, Envoy, Istio có bản vá (theo Aikido)
25/10Microsoft phát hành bản cập nhật cho .NET, Visual Studio, PowerShell (theo SecurityWeek)
27/10CISA thêm CVE-2026-111497 vào danh mục KEV, ghi là "Google Brotli Unchecked Return Value Vulnerability"

Hai điều mình cần đính chính so với những gì bạn có thể đọc ở chỗ khác. Thứ nhất, ngày 27/02 là ngày commit bản sửa, không phải ngày lỗi xuất hiện. Theo Aikido và SecurityWeek, dải bị ảnh hưởng là Brotli 1.0.3 đến 1.2.0, nên lỗi tồn tại từ lâu trước đó (ELLIO ghi là từ tháng 3/2018, nhưng đây là một nguồn thứ cấp). Thứ hai, SecurityWeek không dùng chữ "out-of-band" cho bản cập nhật của Microsoft, nên mình cũng không khẳng định như vậy.

Người báo cáo là nhóm OpenAI Codex Security (Khai Tran và Sylvie Mayer), người sửa phía Node.js là mcollina và aduh95. Một vài bài phân tích ghi nhầm vai trò của hai người sau, nên nếu bạn thấy khác thì cứ đối chiếu với trang advisory.

Còn mức độ nghiêm trọng thì các bên chấm khác nhau. Node.js xếp loại High. Akamai ghi CVSS 9.2 (Critical). Microsoft chấm 8.1 theo CVSS v3.1 và đánh giá "Exploitation More Likely". Mình không có bản NVD đọc được trực tiếp để chốt điểm của CNA, nên đừng lấy một con số làm chuẩn.

# chuyện gì đang xảy ra bên trong decoder

Brotli là thuật toán nén mà trình duyệt và server dùng khá nhiều (Content-Encoding: br). Dữ liệu nén được chia thành các block, mỗi loại block (literal, command, distance) có bảng "block type" riêng và một bộ đếm còn bao nhiêu symbol trong block hiện tại. Đếm về 0 thì decoder đọc tiếp một lệnh "chuyển block".

Có một luật ngầm: nếu stream khai báo chỉ có một block type, thì chuyển block là vô nghĩa, vì không còn type nào khác để chuyển sang. Stream nào ra lệnh chuyển block trong trường hợp này là stream hỏng, decoder phải từ chối.

Lỗi nằm đúng ở chỗ từ chối đó. Hàm xử lý chuyển block trả về một giá trị boolean. Giá trị false bị dùng cho hai ý nghĩa hoàn toàn khác nhau: "chưa đủ dữ liệu, đưa thêm đi" và "stream hỏng, dừng lại". Decoder streaming của Brotli rất hay gặp trường hợp thứ nhất, vì dữ liệu về từng mẩu qua mạng, nên caller được thiết kế để hiểu false là chờ thêm input.

Khi gặp lệnh chuyển block bất hợp lệ, hàm trả false, caller hiểu là "cần thêm input", và vòng lặp cứ nhận byte mới. Mỗi byte mới được ghi vào một bộ đệm nội bộ dài 8 byte, trong khi trạng thái không bao giờ tiến lên để làm rỗng bộ đệm đó. Kết quả là ghi lấn ra ngoài bộ đệm, ghi đè bộ nhớ liền kề. Về mặt phân loại thì đây là CWE-252 (bỏ qua giá trị trả về) dẫn tới CWE-787 (ghi ngoài vùng nhớ).

Cứ hình dung một nhân viên soát vé ở cổng. Quy ước của cổng là "chưa thấy vé thì cứ đứng chờ". Có người đưa ra một tờ giấy ăn. Nhân viên không biết đó là vé giả, chỉ biết là chưa thấy vé hợp lệ, nên cứ đứng chờ, còn khách phía sau thì tiếp tục nhét thêm giấy vào tay anh ta cho tới khi tay anh ta đầy tràn ra ngoài. Lỗi nằm ở chỗ quy ước chỉ có hai trạng thái là "đủ" và "chưa đủ", thiếu hẳn trạng thái "sai luật".

Bản sửa của Google khá gọn. Các hàm chuyển block giờ trả về BrotliDecoderErrorCode thay vì boolean, và trường hợp số block type không hợp lệ trả về mã lỗi mới BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH (mã -17, trước đó ghi chú trong decode.h là "reserved"). Có thêm macro BROTLI_SAFE_WITH_STATUS để lan truyền mã lỗi lên trên. Xem diff đầy đủ ở commit 0ed4268.

Đoạn minh hoạ dưới đây là bản đơn giản hoá do mình viết để thấy hình dạng của vấn đề, không phải code gốc của Brotli:

// minh hoạ đơn giản hoá, KHÔNG phải code gốc của Brotli
bool switch_block(State *s) {
  if (s->num_block_types <= 1) {
    return false;            // hỏng thật, nhưng trả y như "chưa đủ dữ liệu"
  }
  if (!have_enough_bits(s)) {
    return false;            // chưa đủ dữ liệu, chờ thêm
  }
  /* ... */
  return true;
}
 
// phía caller
while (!done) {
  if (!switch_block(s)) {
    feed_more_input(s);      // nhận thêm byte, trạng thái không tiến lên
    continue;
  }
}

Bản sửa đổi kiểu trả về thành ba trạng thái (thành công, cần thêm input, lỗi định dạng) để caller không còn đoán.

Còn chuyện từ ghi tràn thành chạy được lệnh shell thì mình dừng ở mức khái niệm. Imperva cho biết họ chứng minh được thực thi mã trên image Node.js 24.22.0 Linux arm64, nhưng không phải lần thử nào cũng thành công và họ không công bố exploit. SecurityWeek còn nhắc có ý kiến cho rằng lỗi này chỉ gây từ chối dịch vụ, và một PoC công khai trên GitHub nghe nói chỉ chạy trên bản build đã bị làm yếu. Hướng đánh giá thận trọng là coi nó như RCE tiềm năng và vá, vì CISA đã đưa vào KEV.

# đường đi của một request độc hại

Phần này quan trọng nhất cho dev web, vì bạn không cần tự gọi zlib.brotliDecompress() mới dính. Theo Akamai và Aikido, có ba đường đưa dữ liệu của kẻ tấn công vào decoder:

Đường thứ nhất là nguy hiểm nhất với backend. Các parser json, raw, text, urlencoded của body-parser 2 (đi kèm Express 5) giải nén request body có Content-Encoding: br theo mặc định vì tuỳ chọn inflate là true. Bất kỳ ai gửi được request tới endpoint của bạn đều tới được decoder, không cần đăng nhập.

Đường thứ hai là phía client. fetch() tích hợp trong Node.js tự giải nén response Brotli, nên service của bạn khi gọi một API bên ngoài đã bị chiếm, hoặc một URL do người dùng nhập, có thể trở thành nạn nhân. Với app Next.js, server-side fetch() trong Server Component, route handler hay server action đều đi qua đường này.

Có một chi tiết phải nói cho đúng: một server HTTP thuần của Node.js (http.createServer) tự nó không giải nén request body. Imperva và Akamai đều nhấn mạnh điểm này. Nếu bạn chưa gắn middleware nào giải nén thì đường thứ nhất không mở, còn đường thứ hai vẫn còn.

# ai bị ảnh hưởng, vá ở đâu

dòng Node.jsbị ảnh hưởngbản đã vá
26.xtrước 26.12.126.12.1
24.xtrước 24.22.124.22.1
22.xtrước 22.23.422.23.4
25.x, 23.x, 20.x và cũ hơnbị ảnh hưởngkhông có bản vá (đã hết vòng đời)

Trang advisory của Node.js nói rõ các bản EOL cũng bị ảnh hưởng và không được vá trong đợt này. Node 20 hết hỗ trợ từ 30/04/2026 theo những gì các nguồn thứ cấp ghi, nên nếu bạn còn chạy nó thì câu trả lời duy nhất là nâng lên dòng còn hỗ trợ. Với những team còn bám Node 20 vì lý do nào đó, đợt này là một lý do rất cụ thể để lên kế hoạch.

Ngoài Node.js, danh sách còn khá dài:

  • Bun: sửa ở 1.4.4. Executable build bằng bun build --compile phải build lại.
  • Python: Brotli 1.2.1, brotlicffi 1.2.1.0. urllib3, requests, httpx (với extra brotli), aiohttp đều dính khi các gói này được cài, dù code của bạn không import Brotli.
  • .NET: System.IO.Compression (.NET Framework không bị). Microsoft vá ở 8.0.33, 9.0.22, 10.0.14, cùng Visual Studio và PowerShell.
  • Java: stack Netty dùng brotli4j. Envoy và Istio chỉ dính khi bật Brotli decompressor filter.

Phần ngoài Node.js mình lấy từ Aikido và SecurityWeek, chưa đối chiếu hết với từng advisory riêng của các dự án đó.

# kiểm tra mình có dính không

Tất cả lệnh dưới đây chỉ đọc, an toàn để chạy trên production.

# phiên bản runtime đang chạy trên máy này
node -v
 
# phiên bản thư viện brotli được đóng gói trong runtime
node -p "process.versions.brotli"

process.versions.brotli hữu ích vì nó cho biết phiên bản thư viện C nằm trong binary. Nhưng lưu ý: số hiệu này không nói bản đó đã được vá hay chưa theo cách tin được 100% (các bản Node có thể backport mà giữ nguyên chuỗi version). Hãy so với node -v và bảng bản vá ở trên, đừng chỉ dựa vào số của Brotli.

Container mới là chỗ dễ sót nhất, vì Node nằm trong image chứ không phải trên máy bạn:

# kiểm tra Node bên trong image đang dùng (đổi tên image cho đúng)
docker run --rm --entrypoint node your-registry/your-app:tag -v
 
# nếu image dùng entrypoint khác, cách này vẫn chạy được
docker run --rm --entrypoint sh your-registry/your-app:tag -c "node -v"

Với Express, kiểm tra bản body-parser và xem có endpoint nào bật giải nén không:

npm ls express body-parser
 
# tìm chỗ cấu hình parser trong code
grep -rnE "express\.(json|raw|text|urlencoded)|bodyParser" src/

Nếu inflate không bị đặt thành false thì parser đó đang giải nén Brotli. Và vì Imperva có nhắc image Node.js chính thức, mình nghĩ nên rà cả những chỗ khó thấy hơn: image base trong CI, runner build, Lambda custom runtime, binary đóng gói sẵn (pkg, bun --compile, Electron).

# vá và giảm thiểu tạm thời

Cách vá duy nhất thật sự là nâng runtime lên 22.23.4, 24.22.1 hoặc 26.12.1 (hoặc mới hơn trong dòng đó), rồi build lại image và deploy lại. Advisory của Node.js không đưa ra workaround nào. Nên tất cả những gì dưới đây chỉ là chặn đường vào trong lúc chờ nâng cấp, và bạn nên coi chúng là băng dán.

Chặn ở reverse proxy là hợp lý nhất nếu API của bạn không cần nhận body nén. Ví dụ với nginx:

# từ chối request mang body nén Brotli
if ($http_content_encoding ~* "(^|[ ,])br($|[ ,])") {
    return 415;
}

Biến $http_content_encoding đọc header Content-Encoding của request. Hãy thử trên staging trước, vì một số client SDK hiếm hoi có gửi body nén thật, và if trong nginx có vài cái bẫy riêng nếu bạn đặt nó trong location có proxy_pass phức tạp.

Ở tầng ứng dụng, tắt giải nén trong body-parser:

// Express 5: không giải nén request body, body nén sẽ nhận 415
app.use(express.json({ inflate: false, limit: '100kb' }))

Còn chuyện giới hạn kích thước thì phải hiểu cho đúng. Đặt limit là việc nên làm vì chống tốn tài nguyên, nhưng trong vụ này nó không là biện pháp giảm thiểu. Lỗi nằm trong decoder, trước khi cái giới hạn nào của lớp trên có cơ hội đánh giá, và một body Brotli độc hại không cần phải lớn. ELLIO có mô tả một request thử nghiệm dài khoảng 23 MB, nhưng đó là chi tiết của một mẫu cụ thể, không phải điều kiện cần. Nên giới hạn kích thước là tuyến phòng thủ phụ, không thay được bản vá hay việc chặn br.

Phía fetch() khó hơn: bạn không kiểm soát được server bên kia trả gì, và mình chưa kiểm chứng được việc đặt Accept-Encoding có đủ chặn mọi trường hợp hay không (giải nén dựa trên header của response chứ không phải header của request). Vì thế mình không dựa vào cách này. Việc nên làm ở đây là vá, và hạn chế fetch tới URL do người dùng cung cấp, nhất là qua một lớp egress proxy. Akamai cũng gợi ý microsegmentation: giới hạn kết nối vào và ra của service Node.js.

# dấu hiệu bị khai thác

CISA đã đưa lỗi này vào KEV ngày 27/10, và danh mục KEV chỉ chứa lỗi có bằng chứng khai thác thật. Tuy vậy, các nguồn không thống nhất. SecurityWeek (26/10) viết là chưa có báo cáo khai thác ngoài thực tế. ELLIO (nhà cung cấp dữ liệu từ hệ thống cảm biến) thì nói họ thấy traffic mang exploit công khai từ ngày 26/10, với một số lần thử cho kết quả 0 shell trong 50 lần thử của chính họ. Đây là một nguồn duy nhất cho phần quan sát, nên mình dùng thận trọng.

Theo ELLIO, các dấu hiệu đáng để tìm trong log proxy:

  • Request POST có Content-Encoding: br, đặc biệt là body hỏng không giải nén được.
  • User-Agent Mozilla/5.0 (compatible; node2shell-check/1.0), một công cụ dò xem host nào có giải nén Brotli.

Còn phía process, Imperva nói rõ rằng việc process thoát bất thường khi đang giải nén hoặc lỗi từ decoder chỉ giúp xếp ưu tiên điều tra, tự nó không phải bằng chứng. ELLIO còn ghi thêm một chi tiết: lần thử thất bại sẽ làm crash worker, rồi supervisor khởi động lại. Nghĩa là nếu bạn thấy một loạt restart kỳ lạ của pod Node.js trùng khớp với request br thì đáng đào sâu. Không có danh sách IoC chính thức từ Node.js hay CISA.

# bài học: dependency nằm trong runtime

npm audit nhìn vào cây package. Dependabot nhìn vào lockfile. Cả hai đều mù với thứ nằm bên trong binary node. Node.js đóng gói sẵn V8, OpenSSL, zlib, undici, Brotli, nghĩa là mỗi lần bạn kéo một tag image là bạn kế thừa luôn cả đống thư viện C mà SBOM của ứng dụng không liệt kê. Chuyện này không mới (Log4Shell nằm trong JAR, OpenSSL nằm trong mọi thứ), chỉ là lần này nó chạm đúng vào hệ sinh thái JavaScript.

Theo mình thấy có mấy việc đáng làm, và không việc nào cần công cụ mới:

  • Coi phiên bản runtime là một dependency, ghi nó vào chỗ có người nhìn thấy (dashboard, SBOM, trường engines). Quét image container bằng công cụ biết đọc binary, đừng chỉ quét package.json.
  • Có một quy trình nâng patch version của base image thường xuyên, chạy tự động. Một service mà không build lại image trong 3 tháng thì chắc chắn đang mang theo vài lỗ hổng chưa ai biết.
  • Biết service nào dùng dòng Node nào. Vụ này tách các team làm ba nhóm rất rõ: nhóm vá trong một buổi chiều, nhóm phải nâng major, nhóm EOL không có đường vá nào.
  • Cân nhắc chặn những tính năng mà bạn không dùng. Phần lớn API JSON không cần nhận body nén, và inflate: false hay một dòng cấu hình nginx là cái giá rất rẻ cho việc thu hẹp bề mặt tấn công.

Có trade-off thật: nâng runtime thường xuyên nghĩa là nhận về cả rủi ro hồi quy, và tắt giải nén có thể làm vỡ một client hiếm hoi nào đó. Mình vẫn thiên về hướng nâng đều đặn, vì cái giá của một lần nâng patch nhỏ rẻ hơn rất nhiều so với một lần nâng gấp ba dòng Node trong cùng đêm, và chắc chắn rẻ hơn một vụ chiếm quyền điều khiển server.

Một điểm nữa đáng suy nghĩ là bản sửa đã nằm trên nhánh chính của Brotli từ 27/02, tám tháng trước khi có CVE. Nó chỉ là một commit "phát hiện stream hỏng sớm hơn", không ai gắn nhãn bảo mật. Bài học ở đây hơi khó chịu: ngay cả khi bạn theo dõi sát upstream, bạn cũng không thể dựa vào việc "có advisory chưa" để biết mình đã an toàn chưa.

Việc nên làm ngay hôm nay: chạy node -v trên mọi môi trường bạn quản lý, kể cả trong image, đối chiếu với bảng ở trên, rồi lên lịch nâng. Trong lúc chờ, chặn Content-Encoding: br ở proxy cho những API không cần nó.

# tham khảo


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.