경로 조작 (Path Traversal)

Path Traversal

설명

경로 조작은 사용자 입력을 그대로 파일 경로에 사용할 때 발생합니다. 공격자는 ../, 절대 경로(/, C:\ 등), 또는 심볼릭 링크를 이용해 애플리케이션이 의도한 디렉터리 밖의 파일에 접근하도록 유도할 수 있습니다. 이를 통해 /etc/passwd 같은 시스템 파일을 읽거나, 애플리케이션 설정/로그를 덮어쓰고 삭제할 수 있습니다.

잠재적 영향

  • Sensitive Data Exposure (민감 정보 노출): 디렉터리 밖의 시스템 파일, 소스 코드, 환경설정(.env 등) 유출
  • Data Tampering/Deletion (데이터 변경/삭제): 업로드/로그/설정 파일을 덮어쓰기 또는 삭제하여 기능 오작동 유발
  • Privilege Escalation (권한 상승): 서비스 권한으로 임의 파일 쓰기를 악용해 실행 경로에 스크립트 배치 등 간접적 권한 상승 가능
  • Service Disruption (서비스 중단): 핵심 파일 삭제/손상으로 애플리케이션 장애 또는 DoS 유발

해결 방법

  • 사용자 입력으로 경로를 만들지 말고, 반드시 정규화와 경계 확인을 수행하세요.
    • const resolved = fs.realpathSync(path.resolve(SAFE_ROOT, input));
    • resolved가 SAFE_ROOT + path.sep로 시작하는지 확인하고, 아니라면 거부하세요.
  • 단순 파일명만 허용해야 한다면 allow-list(확장자/패턴) 또는 sanitize-filename 사용, 혹은 엄격한 정규식(예: ^[a-zA-Z0-9._-]{1,100}$) 검증을 적용하세요.
  • 절대 경로, .., 빈 값 등은 즉시 차단하세요.
  • realpath로 기존 경로의 링크를 해소하세요. realpath나 fs.lstat로 검사한 뒤에도 파일이 바뀔 수 있으므로 기준 디렉터리와 하위 경로를 공격자가 수정할 수 없도록 보호하세요.
  • 파일 접근 권한을 최소화하고(Least Privilege), 에러 메시지에는 실제 경로를 노출하지 마세요.
  • 업로드/다운로드 루트를 고정(SAFE_ROOT)하고, 그 밖의 경로 접근은 금지하세요.

예시

변경 전

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

// 취약한 예: 사용자 입력을 join만 하고 사용 (.. 탈출 가능)
app.get('/view', (req, res) => {
  const name = req.query.f; // 예: ../../etc/passwd
  const unsafePath = path.join(__dirname, 'public', name);
  fs.createReadStream(unsafePath)
    .on('error', () => res.status(404).end('not found'))
    .pipe(res);
});

app.listen(3000);

변경 후

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

// SAFE_ROOT는 공격자가 수정할 수 없는 서버 관리 디렉터리여야 함
const SAFE_ROOT = fs.realpathSync(path.resolve(__dirname, 'public'));

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

app.get('/view', (req, res) => {
  const name = req.query.f;
  if (typeof name !== 'string') {
    return res.status(400).send('parameter f is required');
  }

  // 경로 정규화 + 심볼릭 링크 해소
  const candidate = path.resolve(SAFE_ROOT, name);
  let resolved;
  try {
    resolved = fs.realpathSync(candidate);
  } catch {
    return res.status(404).end('not found');
  }

  // 루트 경계 확인 (SAFE_ROOT 하위 파일만 허용)
  if (!isInside(SAFE_ROOT, resolved)) {
    return res.status(403).end('forbidden');
  }

  // 검증된 경로만 사용
  const stream = fs.createReadStream(resolved);
  stream.on('error', () => res.status(404).end('not found'));
  stream.pipe(res);
});

app.listen(3000);

설명:

  • 변경 전: 사용자 입력 f를 검증 없이 경로 결합에 사용합니다. path.join은 .. 구성 요소를 정규화하지만 결과가 기준 디렉터리 안에 남는지는 보장하지 않으므로, 정규화된 경로가 상위 디렉터리로 탈출해 애플리케이션 루트 밖의 파일에 접근할 수 있습니다.
  • 변경 후: 문자열 입력만 받은 뒤 SAFE_ROOT를 기준으로 path.resolve하고, fs.realpathSync로 심볼릭 링크를 해소합니다. path.relative로 실제 경로가 공격자가 수정할 수 없는 SAFE_ROOT의 하위 파일인지 확인한 뒤에만 접근합니다. 검사와 파일 열기 사이에도 해당 디렉터리 구조를 신뢰할 수 있어야 하므로, 공격자가 하위 경로를 변경할 수 있는 환경에서는 이 검사만으로 경쟁 조건까지 해결되지는 않습니다.

참조