Adobe Experience Manager là một CMS.
Khi nhóm tác giả thực hiện bug hunting ở trên AEM, họ phát hiện ứng dụng load JS từ path /.rum/@adobe/helix-rum-js@%5E2/dist/rum-standalone.js
. Path /.rum
được proxy trực tiếp đến một NPM CDN (chẳng hạn như JSDelivr hoặc Unpkg).
Do các package này có thể chứa các file HTML với payload XSS nên ý tưởng của các tác giả là tìm cách để poison các package mà ứng dụng load lên.
Setting the Stage
RUM viết tắt cho “Real User Monitoring” và là một công cụ để AEM theo dõi trải nghiệm người dùng. RUM theo dõi cử động chuột, thời gian load trang và các metrics hữu ích khác rồi gửi chúng đến endpoint /.rum
để thu thập. Những thông tin này được documented ở Operational Telemetry.
Path /.rum
được handle bởi JS worker của fastly@edge
. Source code của RUM collector được lưu trữ ở adobe/helix-rum-collector. Cách hoạt động của RUM proxy như sau:
- Source code đầu tiên sẽ check xem path có bắt đầu bằng
/.rum/web-vitals
hay/.rum/@adobe/helix-rum
hay không. Nếu không, nó sẽ quăng lỗi. Điều này là để đảm bảo chỉ có thể truy cập vào 3 package:web-vitals
,@adobe/helix-rum-js
, và@adobe/helix-rum-enhancer
. - Sau đó, code sẽ chọn JSDelivr hoặc Unpkg mà có host các NPM packages.
- Cuối cùng, source code sẽ bỏ
/.rum
từ path và sử dụngfetch
để fetch nội dung từ JSDelivr hoặc Unpkg. Source code cũng proxy toàn bộ các response headers.
Do nó proxy về các header, nhóm tác giả kiểm tra xem NPM CDN nào cho phép serve các file có MIME type mà thực thi được JS (chẳng hạn như text/html
). Để kiểm tra, nhóm tác giả upload các package có chứa file HTML lên JSDelivr và Unpkg. Kết quả cho thấy JSDelivr serve file HTML dưới dạng text/plain
còn Unpkg serve file HTML dưới dạng text/html
.
First Blood
Cả 2 loại CDN đều có cấu trúc đường dẫn tương tự nhau: /package/filepath
cho các NPM packages đơn lẻ và /@organization/package/filepath
cho các package của một tổ chức. Dựa trên cấu trúc này, việc kiểm tra prefix của path mà sẽ được proxy sẽ là một cơ chế bảo vệ quan trọng.
Source code implement như sau:
const {
pathname
} = new URL(req.url);
// Reject double-encoded URLs (which contain %25 as that is the percent sign)
// Also reject paths that contain '..' but decode the URL first as it might be encoded
if (pathname.includes('%25') || decodeURI(pathname).includes('..') ||
pathname.includes('%3A') || decodeURI(pathname).includes(':')) {
return respondError('Invalid path', 400, undefined, req);
}
try {
// .. snip ..
const isDirList = (pathname.endsWith('/'));
if (req.method === 'GET' && pathname.startsWith('/.rum/web-vitals')) {
if (isDirList) {
return respondError('Directory listing is not allowed', 404, undefined, req);
}
return respondPackage(req);
}
if (req.method === 'GET' && pathname.startsWith('/.rum/@adobe/helix-rum')) {
if (isDirList) {
return respondError('Directory listing is not allowed', 404, undefined, req);
}
return respondPackage(req);
}
Có thể thấy: nếu path có các ký tự chẳng hạn như %25
(ký tự %
, là dấu hiệu của việc double-encoding) hoặc ký tự %3A
(ký tự :
) hoặc khi decode ra có chứa ký tự ..
hoặc :
thì nó sẽ trả về 400 response. Điều này khiến cho việc sử dụng path traversal payload chẳng hạn như /.rum/web-vitals/%2E%2E/our-package/x.html
trở nên vô dụng.
Tuy nhiên, việc kiểm tra xem path có bắt đầu bằng /.rum/web-vitals
tạo ra một attack vector: bất kỳ package nào bắt đầu bằng /.rum/web-vitals
cũng được phép. Nói cách khác, các package chẳng hạn như /.rum/web-vitals-malicious
là hợp lệ. Nhóm tác giả đã tạo ra package https://www.npmjs.com/package/web-vitalsxyz mà có bao gồm file demo.html
chứa XSS payload để thực hiện tấn công.
Khi truy cập vào path chẳng hạn như https://our.target.site/.rum/web-vitalsxyz/demo.html
, payload XSS sẽ được thực thi.
Bởi vì proxy phân tán request ra 2 CDN là JSDelivr và Unpkg một cách ngẫu nhiên chỉ có 50% khả năng payload hoạt động được. Tuy nhiên, response của CDN được cache với thời gian expire rất dài nên nếu có một lần response được cache thì tất cả các lần sau đều có thể hoạt động.
Do Unpkg là case-insensitive nên path https://our.target.site/.rum/web-vitalsxyz/DEMO.html
vẫn sẽ trỏ đến file demo.html
có chứa payload XSS. Tuy nhiên, JSDelivr là case-sensitive nên nó sẽ trả về lỗi nên response của nó sẽ không được cache. Điều này giúp đảm bảo payload của chúng ta luôn được thực thi mặc dù request được phân tán qua 2 CDN khác nhau.
Second Blood
Bug được fix như sau:
- if (req.method === 'GET' && pathname.startsWith('/.rum/web-vitals')) {
+ if (req.method === 'GET' && pathname.match(/^\/\.rum\/web-vitals[@/]/)) {
Fix trên khiến cho path name chỉ có thể bắt đầu bằng /.rum/web-vitals
và kết thúc bằng @
hoặc /
. Do package name không thể chứa ký tự @
và attacker cũng không thuộc org adobe
nên không thể làm giả các package dưới tên org là adobe
. Điều này khiến cho payload ban đầu trở nên vô dụng.
Nhóm tác giả tìm thấy đoạn code khác như sau:
const redirectHeaders = [301, 302, 307, 308];
const rangeChars = ['^', '~'];
export async function respondUnpkg(req) {
const url = new URL(req.url);
const paths = url.pathname.split('/');
const beurl = new URL(paths.slice(2).join('/'), 'https://unpkg.com');
const bereq = new Request(beurl.href);
const beresp = await fetch(bereq, {
backend: 'unpkg.com',
});
// .. snip ..
if (redirectHeaders.includes(beresp.status)) {
const bereq2 = new Request(new URL(beresp.headers.get('location'), 'https://unpkg.com'));
const err2 = prohibitDirectoryRequest(bereq2);
if (err2) {
return cleanupResponse(err2);
}
const beresp2 = await fetch(bereq2, {
backend: 'unpkg.com',
});
if (redirectHeaders.includes(beresp2.status)) {
const bereq3 = new Request(new URL(beresp2.headers.get('location'), 'https://unpkg.com'));
const err3 = prohibitDirectoryRequest(bereq3);
if (err3) {
return cleanupResponse(err3);
}
const beresp3 = await fetch(bereq3, {
backend: 'unpkg.com',
});
return cleanupResponse(beresp3, req, ccMap);
}
return cleanupResponse(beresp2, req, ccMap);
}
return cleanupResponse(beresp, req, ccMap);
}
Đoạn code này về bản chất là sẽ thực hiện redirect 2 lần sau khi nhận được response từ CDN. Đoạn code trên trông có vẻ lạ vì hàm fetch
mặc định đã thực hiện redirect và backend
không phải là tham số hợp lệ của Node.js hay của Web Standard. Tuy nhiên, đoạn code trên được chạy bởi Fastly edge worker nên nó có các option khác code JS thông thường.
Có rất nhiều cách để khiến cho CDN gây ra một redirect. Một trong số đó là truy cập package mà không có version. Khi đó, nó sẽ thêm vào latest version và decode URL một lần:
sh$ curl 'https://unpkg.com/web-vitals/foo-%2562ar' -v
* Request completely sent off
< HTTP/2 302
< date: Mon, 30 Jun 2025 00:45:49 GMT
< content-type: text/plain;charset=UTF-8
< content-length: 42
< location: /web-vitals@5.0.3/foo-%62ar
< access-control-allow-origin: *
< cache-control: public, max-age=60, s-maxage=300
< cross-origin-resource-policy: cross-origin
< strict-transport-security: max-age=31536000; includeSubDomains; preload
< x-content-type-options: nosniff
< server: cloudflare
< cf-ray: 9579a49f69d229b3-MEL
< alt-svc: h3=":443"; ma=86400
Ví dụ, nó sẽ redirect /web-vitals/foo-%2562ar
về /web-vitals@5.0.3/foo-%62ar
. Chúng ta có thể cho rằng payload /web-vitals/%252e%252e/web-vitalsxyz/demo.html
sẽ hoạt động vì sau khi redirect nó sẽ trở thành /web-vitals/%2e%2e/web-vitalsxyz/demo.html
và path này sẽ thực hiện traverse ra khỏi whitelist directory. Tuy nhiên, đoạn code đầu tiên đã chặn %25
ở trong đường dẫn.
Nhóm tác giả sau đó đã nhìn vào source code của fetch
để xem cách nó normalize path. Ý tưởng của nhóm tác giả là thực hiện path traversal nhưng không dùng ..
.
Sau khi kiểm tra source code thì nhóm tác giả tìm thấy đoạn code sau:
template
result_type parse_url_impl(std::string_view user_input,
const result_type * base_url) {
// .. snip ..
if (unicode::has_tabs_or_newline(user_input))[[unlikely]] {
tmp_buffer = user_input;
// Optimization opportunity: Instead of copying and then pruning, we could
// just directly build the string from user_input.
helpers::remove_ascii_tab_or_newline(tmp_buffer);
url_data = tmp_buffer;
} else [
[likely]
] {
url_data = user_input;
}
// .. snip ..
}
ada_really_inline void remove_ascii_tab_or_newline(
std::string & input) noexcept {
// if this ever becomes a performance issue, we could use an approach similar
// to has_tabs_or_newline
std::erase_if(input, ada::unicode::is_ascii_tab_or_newline);
}
ada_really_inline constexpr bool is_ascii_tab_or_newline(
const char c) noexcept {
return c == '\t' || c == '\n' || c == '\r';
}
Đoạn code trên chỉ đơn giản là kiểm tra xem URL có các ký tự \t
, \n
hay \r
hay không. Nếu có, thì nó sẽ xóa các ký tự đó đi. Ví dụ, path https://exam\tple.com/
sẽ trở thành https://example.com/
. Behaviour này là bắt buộc bởi tiêu chuẩn parsing WHATWG URL và nó cho phép sử dụng payload như sau:
https://our.target.site/.rum/web-vitals/.%09./web-vitalsxyz/DEMO.html
Khi được redirect và decode, URL trên sẽ trở thành https://our.target.site/.rum/web-vitals@5.0.3/.\t./web-vitalsxyz/DEMO.html
, ký tự \t
khi được dùng với fetch
sẽ được bỏ đi và URL sẽ trở thành https://our.target.site/.rum/web-vitals/../web-vitalsxyz/DEMO.html
. Tất nhiên, URL này sẽ được chuẩn hóa thành https://our.target.site/.rum/web-vitalsxyz/DEMO.html
và là URL mà ta mong muốn.
Third Blood
Bug trên được fix như sau:
- // Reject double-encoded URLs (which contain %25 as that is the percent sign)
- // Also reject paths that contain '..' but decode the URL first as it might be encoded
- if (pathname.includes('%25') || decodeURI(pathname).includes('..')
- || pathname.includes('%3A') || decodeURI(pathname).includes(':')) {
+ // Reject all encoded characters except %5E (^) when used for semantic versioning
+ // i.e. allow patterns like @package@%5E2.0.0 but reject any other % encoding
+ const validVersionPattern = /%5[Ee](?:\d|$)/;
+ const hasInvalidEncoding = pathname.includes('%')
+ && !pathname.split('/').every((segment) => !segment.includes('%') || validVersionPattern.test(segment));
+
+ if (hasInvalidEncoding || decodeURI(pathname).includes('..') || decodeURI(pathname).includes(':')) {
return respondError('Invalid path', 400, undefined, req);
}
Fix trên đảm bảo rằng nếu path segment có chứa %5e
hoặc 5E
thì theo sau đó phải là một con số hoặc là không có gì. Như vậy, miễn là segment của chúng ta có %5e
thì tất cả các ký tự %
khác trong segment đó đều được phép.
Payload lúc này sẽ là:
https://our.target.site/.rum/web-vitals/.%09.%2fweb-vitalsxyz%2fDEMO.html%3f%5e
Khi được redirect lần đầu tiên và decode thì nó sẽ trở thành:
https://our.target.site/.rum/web-vitals@5.0.3/.\t./web-vitalsxyz/DEMO.html?^
Ký tự %3f
khi decode sẽ trở thành ?
và biến ký tự ^
thành query param.
URL trên sẽ được chuẩn hóa thành:
https://our.target.site/.rum/web-vitalsxyz/DEMO.html?^