Node.js url.parse() 취약점 컨트리뷰션

profile-img
표상영Security Researcher
2023. 5. 12

토스 보안기술팀(Security Tech)에서는 개발 서비스 외에도 서비스에서 사용하고 있는 프레임워크나 Third-party에 대한 취약점 연구도 수행하고 있습니다.

이번 아티클은 Node.js의 Built-in API 중 하나인 url.parse() 의 Hostname Spoofing 취약점을 발견하고 안전한 코드로 패치될 수 있도록 컨트리뷰션 했던 과정을 다뤄보려 합니다.

https://github.com/nodejs/node/pull/45011

url.parse() 취약점 발생 원인

Node.js의 url.parse()는 WHATWG URL API 가 아닌 자체적인 스펙으로 개발된 함수입니다.

WHATWG URL API 가 등장하기 전, URL 파싱 기능을 제공하기 위해 자체적으로 개발된 함수로 보이는데요. 표준 스펙이 아닌 자체적으로 해석하다보니 다른 파서(parser)들과 해석 결과가 달라지게 되고, 이 때문에 의도치 않은 코드흐름이 발생하게 됩니다.

url.parse()에서는 hostname을 잘못된 방식으로 파싱하여 취약점이 발생하게 되었는데요. Node.js의 url라이브러리는 여기에서 확인할 수 있고, 취약점이 발생했던 부분은 아래의 getHostname() 함수입니다.

/* comment
해당 취약점은 v19.1.0에서 패치되었습니다. 
아래 코드는 v19.1.0 이전 버전에서 확인할 수 있습니다.
*/
function getHostname(self, rest, hostname) {
  for (let i = 0; i < hostname.length; ++i) {
    const code = hostname.charCodeAt(i);
    const isValid = (code >= CHAR_LOWERCASE_A && code <= CHAR_LOWERCASE_Z) ||
                    code === CHAR_DOT ||
                    (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) ||
                    (code >= CHAR_0 && code <= CHAR_9) ||
                    code === CHAR_HYPHEN_MINUS ||
                    code === CHAR_PLUS ||
                    code === CHAR_UNDERSCORE ||
                    code > 127;

    // Invalid host character
    if (!isValid) {
      self.hostname = hostname.slice(0, i);
      return `/${hostname.slice(i)}${rest}`;
    }
  }
  return rest;
}

getHostname() 함수의 로직은 단순합니다. 반복문을 통해 전달된 값의 문자를 하나씩 가져온 뒤, 조건에 맞는 값을 구하는 로직인데요.

여기서 isValid 라는 특정 조건을 정의해두고, 현재 문자가 조건에 충족되지 않으면 해당 문자의 앞 인덱스까지 slice하여 그 문자열을 hostname으로 설정합니다. 그리고 그 뒤 문자들은 앞에 /를 붙여 경로(path)로 사용합니다.

const isValid = (code >= CHAR_LOWERCASE_A && code <= CHAR_LOWERCASE_Z) ||
                code === CHAR_DOT ||
                (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) ||
                (code >= CHAR_0 && code <= CHAR_9) ||
                code === CHAR_HYPHEN_MINUS ||
                code === CHAR_PLUS ||
                code === CHAR_UNDERSCORE ||
                code > 127;

isValid 의 조건을 정규식으로 표현해보면 /[a-zA-Z0-9\.\-\+_]/u 와 같은데요. *ECMAScript기준 “hostname으로는 저 범위의 문자들만 올 수 있어!” 라고 설정해둔 것이죠.

디버깅 코드

getHostname() 함수에 디버깅 코드를 추가하고 확인해보면, 실제로 isValid 범위 밖 문자일 경우 hostname 파싱을 중단하고, 나머지 문자열은 앞에 / 를 붙여 경로(path)로 사용하는 것을 볼 수 있습니다.

때문에, http://EVIL_DOMAIN*.toss.im 의 hostname이 EVIL_DOMAIN 이 되어버리고, Hostname Spoofing 취약점이 발생하게 됩니다.

WHATWG URL API ↔ url.parse() 비교

WHATWG URL API 의 hostname 파싱 결과를 보면 evil_domain*.toss.im 으로 Node.js와 다른 것을 확인할 수 있습니다.

Reserved Characters

Node.js에서 이렇게 파싱하는 이유는 RFC(Request For Comments) 문서를 보면 알 수 있습니다. RFC 3986Standard URI Syntax 를 정의해둔 문서인데요.

문서의 여러 항목 중 아래 2.2 Reserved Characters 보면 그 이유를 찾을 수 있습니다.

https://www.rfc-editor.org/rfc/rfc3986#section-2.2

Reserved Characters 는 URI를 구성할 수 있는 문자 중 특수 목적을 갖고 사용할 문자들을 미리 예약해둔 것인데요. 예를 들면 port 구분자로 사용되는 :(colon) , path 구분자로 사용되는 /(slash) 가 예약된 문자인 것이죠.

따라서, *, !, $, :, # 와 같은 문자들은 hostname으로 사용할 수 없습니다.

그 아래를 보면 사용해도 되는 Unreserved Characters 도 정의되어 있습니다.

https://www.rfc-editor.org/rfc/rfc3986#section-2.3

정의된 문자들을 보면 위에서 확인한 url.parse()의 isValid 조건과 비슷한 것을 알 수 있죠.


취약점 악용 시나리오

이러한 WHATWG URL APIurl.parse() 간 파싱 결과 차이는 서비스의 도메인 검증로직을 우회하는데 악용할 수 있습니다.

// server.ts (exec command: ts-node server.ts)
/* dependencise
	express@^4.18.1
	ts-node@^10.9.1
	typescript@^4.3.2
	node-fetch@2
	@types/node-fetch@^2.6.2
*/
import express, { Request, Response, NextFunction } from 'express';

const node_fetch = require("node-fetch");
const app = express();

app.get("/image/resize", async (req: Request, res: Response) => {
	// GET메소드로 url파라미터 입력 받음
	const url = req.query.url as string; // [1]
	// WHATWG URL API를 이용해 hostname 파싱
	const host = new URL(url).hostname; // [2]

	// 파싱한 hostname 검증 (example.com과 *.example.com일 경우에만 분기문 통과)
	if(host === "toss.im" || host.endsWith(".toss.im")) { // [3]
		// 검증된 hostname일 경우, node_fetch로 http request
		var result = await node_fetch.default(url); // [4]
		var requestUrl = result.url; // [5]
		// 파라미터로 입력된 url과 node_fetch로 실제 요청한 url 콘솔 출력
		console.log(`Input URL: ${url} / Request URL: ${requestUrl}`); // [6]
	// 그 외 경우는 reject
	} else {
		console.log("reject");
	}
});

app.listen(4540, () => {
});

위 코드는 사용자에게 파라미터로 url을 입력받고, 입력된 url 검증 후 fetch하는 간단한 웹서버입니다.

new URL(url).hostname 으로 hostname을 가져온 뒤, hostname이 toss.im 과 일치하거나 .toss.im 으로 끝나는지 확인합니다. 일반적으로 도메인을 검증할 때 사용하는 방식이죠.

파라미터로 입력한 url과 node-fetch에서 실제로 요청한 url 콘솔 출력

이 경우, 위 그림처럼 url파라미터 값을 https://google.com!.toss.im 로 입력하면 검증 로직이 우회되고 서버는 https://google.com/!.toss.im/ 으로 요청하게 되는데요. 그 이유는 node-fetch 라이브러리 코드를 보면 알 수 있습니다.

/* node-fetch v2.6.11 
 * [https://github.com/node-fetch/node-fetch/tree/v2.6.11]
 * request.js
 *
 * Request class contains server only options
 *
 * 
 */

import Url from 'url'; // [1]
import Stream from 'stream';
import whatwgUrl from 'whatwg-url';
import Headers, { exportNodeCompatibleHeaders } from './headers.js';
import Body, { clone, extractContentType, getTotalBytes } from './body';

const INTERNALS = Symbol('Request internals');
const URL = Url.URL || whatwgUrl.URL;

// fix an issue where "format", "parse" aren't a named export for node <10
const parse_url = Url.parse; // [2]
const format_url = Url.format;

/**
 * Wrapper around `new URL` to handle arbitrary URLs
 *
 * @param  {string} urlStr
 * @return {void}
 */
function parseURL(urlStr) {
	/*
		Check whether the URL is absolute or not

		Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
		Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
	*/
	if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.exec(urlStr)) {
		urlStr = new URL(urlStr).toString() // [3]
	}

	// Fallback to old implementation for arbitrary URLs
	return parse_url(urlStr); // [4]
}

위 코드는 node-fetch 라이브러리에서 url을 파싱하는 코드 부분입니다. 중요한 부분은 주석으로 번호 표시를 해두었는데요.

  • [1]: Node.js의 url 라이브러리를 가져옵니다.
  • [2]: Node.js의 url.parse()함수를 parse_url 변수에 저장합니다.
  • [3]: 파싱할 url이 정규식 조건과 일치하면 WHATWG URL API 로 파싱합니다.
  • [4]: 그 외에 경우는 Node.js의 url.parse()함수로 파싱합니다.

url 파라미터로 입력한 https://google.com!.toss.im[3] 정규식 조건에 충족되지 않으니, [4] url.parse()로 hostname이 파싱되었고 Node.js의 잘못된 파싱 방식으로 인해 https://google.com/*.toss.im 으로 요청하게 된 것입니다.

이처럼 서비스 서버의 검증 로직을 우회하고 공격자가 원하는 임의의 도메인으로 요청하도록 하는 공격기법을 SSRF(Server Side Request Forgery) 라고 하는데요. 공격자는 SSRF 공격을 통해 외부에 공개되어 있지 않은 서비스 내부 망에 접근하여 민감한 정보들을 탈취하거나, 관리자 기능들을 악용할 수 있습니다.


취약점 패치 컨트리뷰션

화이트해커 문화에는 취약점을 제보하고 그에 따른 보상을 지급하는 버그바운티(Bug Bounty) 라는 프로그램이 존재합니다. 보안에 중요한 가치를 두고 있는 기업들이 독립적으로 운영하기도 하고, 국가기관에서 운영하기도 하는데요.

토스 버그바운티 챌린지

토스에서도 작년에 자체적으로 토스 버그바운티 챌린지 (https://bugbounty.toss.im)를 진행한 바 있고, 국가 기관에서는 한국인터넷진흥원(KISA)이 국내 소프트웨어에 대한 취약점을 제보받고 있습니다.

Node.js url.parse() 취약점 제보

저 또한 버그바운티 프로그램을 통해 Node.js 측에 취약점을 제보하였고, 취약점을 알맞게 패치할 수 있는 방안들에 대해 논의하면서 컨트리뷰션을 진행하였는데요.

기존에 Unreserved Characters 를 화이트리스트로 처리하는 방식 대신 Reserved Characters 를 블랙리스트로 처리하는 방식으로 변경하여 isValid 조건을 좀 더 엄격하게 가져가도록 패치하였습니다.

https://github.com/nodejs/node/pull/45011 에서 패치된 코드를 확인해 볼 수 있고, 해당 Pull Request는 v19.1.0, v18.13.0 에서 적용되었습니다.

https://nodejs.org/api/url.html#urlparseurlstring-parsequerystring-slashesdenotehost

추가로 기존에 Legacy 상태였던 url.parse()함수를 Deprecated 로 변경하였는데요. --pending-deprecation 옵션을 사용하는 경우, 런타임에서 Deprecated 함수임을 경고하도록 패치되었습니다.

이 글을 읽으신 분들도 Node.js를 사용하고 계시다면, 취약점이 존재하는 버전을 사용 중인지 확인해보시는 것을 권장드립니다. *취약점은 v19.1.0, v18.13.0 에서 패치되었습니다.

그리고 저희 보안기술팀(Security Tech)에서 이와 같은 보안 연구를 같이 해나갈 동료분들을 찾고 있습니다. 관심이 있으시다면 언제든지 을 두드려 주세요!

재미있게 읽으셨나요?

좋았는지, 아쉬웠는지, 아래 이모지를 눌러 의견을 들려주세요.

😍
🤔
website-code-blue

토스팀이 만드는 수많은 혁신의 순간들

당신과 함께 만들고 싶습니다.
지금, 토스팀에 합류하세요.
채용 중인 공고 보기