파라미터 타입 혼동으로 인한 경로 검증 우회

파라미터 타입 혼동으로 인한 경로 검증 우회

설명

Express 같은 Node.js 서버에서 중복 쿼리 파라미터는 문자열 대신 배열로 해석될 수 있습니다. 사용자 입력이 문자열이라고 가정하고 indexOf 또는 includes로 경로 탐색 문자열을 검사하면 배열 동작 차이로 우회될 수 있으며, 이후 path.resolve 또는 파일 응답에 사용되면 경로 검증이 깨질 수 있습니다.

잠재적 영향

  • 공격자가 중복 파라미터를 사용해 경로 검증을 우회하고 제한 디렉터리 밖의 파일에 접근할 수 있습니다.
  • 문자열 전용 검증 로직이 배열, 객체 등 다른 타입에서 예상과 다르게 동작할 수 있습니다.
  • 경로 탐색 방어가 우회되어 민감 파일 노출이나 임의 파일 접근으로 이어질 수 있습니다.

해결 방법

  • 경로 검증 전에 typeof value === "string" 또는 Array.isArray(value) 거부 검사를 수행하세요.
  • 절대 경로를 거부하고, path.resolve로 계산한 최종 경로가 기준 디렉터리 내부인지 path.relative로 확인하세요.
  • 기준 디렉터리에 심볼릭 링크가 있을 수 있다면 realpath로 실제 파일 경로도 같은 디렉터리 내부인지 다시 확인하세요.
  • 검사와 파일 사용 사이에 경로가 바뀌지 않도록 기준 디렉터리는 공격자가 수정할 수 없게 관리하세요.
  • 가능하면 사용자 입력 경로 대신 서버가 발급한 파일 ID와 허용 목록 매핑을 사용하세요.

예시

변경 전

javascript
const express = require("express");
const app = express();
const path = require("path");

app.get("/download", (req, res) => {
  const file = req.query.file;
  if (file.indexOf("..") !== -1) {
    return res.status(400).send("Bad request");
  }

  const selectedFile = Array.isArray(file) ? file[file.length - 1] : file;
  return res.sendFile(path.resolve("/srv/public", selectedFile));
});

변경 후

javascript
const express = require("express");
const app = express();
const fs = require("fs");
const path = require("path");

const publicDir = fs.realpathSync("/srv/public");

function isInside(baseDir, targetPath) {
  const relative = path.relative(baseDir, targetPath);
  return (
    relative !== "" &&
    relative !== ".." &&
    !relative.startsWith(".." + path.sep) &&
    !path.isAbsolute(relative)
  );
}

app.get("/download", (req, res) => {
  const file = req.query.file;
  if (typeof file !== "string" || path.isAbsolute(file)) {
    return res.status(400).send("Bad request");
  }

  const candidate = path.resolve(publicDir, file);
  if (!isInside(publicDir, candidate)) {
    return res.status(400).send("Bad request");
  }

  let realFile;
  try {
    realFile = fs.realpathSync(candidate);
  } catch {
    return res.status(404).send("Not found");
  }

  if (!isInside(publicDir, realFile)) {
    return res.status(400).send("Bad request");
  }
  return res.sendFile(realFile);
});

설명:

  • 변경 전: 입력이 문자열이라고 가정해 검사하므로 배열 등 다른 타입에서 검증 의미가 달라질 수 있습니다.
  • 변경 후: 공격자가 수정할 수 없는 기준 디렉터리를 사용하고, 문자열이 아닌 값과 절대 경로를 먼저 거부합니다. 정규화된 경로와 심볼릭 링크를 해석한 실제 경로가 모두 기준 디렉터리 내부인지 확인한 뒤 파일을 전송합니다.

공격 예시:

Express의 쿼리 파서는 같은 이름의 파라미터가 반복되면 값을 배열로 만들 수 있습니다. 이때 file.indexOf("..")는 문자열의 부분 문자열 검색이 아니라 배열 요소 검색으로 동작합니다. 배열 안에 ".."와 정확히 같은 요소가 없으면 검사를 통과하고, 이후 코드가 배열의 마지막 값을 파일명으로 사용하면 경로 탐색 문자열이 그대로 전달됩니다.

bash
curl "http://localhost:3000/download?file=report.txt&file=../../../../etc/passwd"

위 요청에서 req.query.file은 다음과 유사한 배열이 될 수 있습니다.

javascript
["report.txt", "../../../../etc/passwd"]

file.indexOf("..")는 -1을 반환해 차단하지 못하지만, selectedFile은 "../../../../etc/passwd"가 되어 제한 디렉터리 밖의 파일을 요청할 수 있습니다.

참조