← 목록으로

Path Traversal / LFI (Local File Inclusion): 커널 경로 탐색부터 RCE 체이닝까지

[Web] Web · 작성: 2026-07-21 22:36:53 · 수정: 2026-07-21 23:12:34 · 조회 41

Path Traversal / LFI(Local File Inclusion)를 "../를 필터링하면 되는 취약점"으로만 알면, 왜 널바이트 하나가 필터를 통째로 무력화시켰는지, 왜 파일을 "읽기"만 했을 뿐인데 원격 코드 실행으로 끝나는지를 설명할 수 없다. 이 글은 Linux 커널의 경로 탐색(pathname lookup) 알고리즘부터, 로그 파일 하나를 오염시켜 RCE로 이어지는 체이닝까지 따라간다.

소스 레벨: 취약한 코드와 안전한 코드의 정확한 차이

이 블로그 스타일의 파일 서빙 기능(routes/files.js류)을 예로 들면, 취약한 패턴은 대략 이렇다.

// 취약한 버전 (설명용 예시, 실제 코드 아님)
app.get('/download', (req, res) => {
  const filePath = path.join(UPLOAD_DIR, req.query.file);
  res.sendFile(filePath);
});

path.join('/Users/martina/webapp/uploads', '../../../etc/passwd')을 호출하면 Node의 path.join경로를 정규화(normalize)하긴 하지만 UPLOAD_DIR 바깥으로 나가는 것 자체는 막지 않는다join의 역할은 세그먼트를 이어붙이고 ..을 문자열 레벨에서 해소하는 것뿐이지, "결과가 원래 디렉터리 안에 있어야 한다"는 정책은 별도로 검증해야 한다. 안전한 버전은 이렇다.

const resolved = path.resolve(UPLOAD_DIR, req.query.file);
if (!resolved.startsWith(path.resolve(UPLOAD_DIR) + path.sep)) {
  return res.status(403).end(); // UPLOAD_DIR 바깥으로 벗어나면 차단
}
res.sendFile(resolved);

path.resolve로 절대경로까지 완전히 풀어낸 뒤, 문자열 접두사 비교로 "정말 그 디렉터리 안에 있는가"를 명시적으로 재검증하는 게 핵심이다. 이 마지막 검증 한 줄이 없으면, 사용자 입력에 있는 ../ 개수만큼 상위 디렉터리로 거슬러 올라갈 수 있다.

커널 레벨: ../가 실제로 해소되는 지점은 애플리케이션이 아니다

여기서 중요한 오해가 하나 있다 — ../를 "애플리케이션이 해석해서 상위 폴더로 간다"고 생각하기 쉽지만, 최종적으로 파일을 여는 시점(fs.readFile, open() 시스템 콜)에서 경로를 실제로 해석하는 건 커널의 VFS(Virtual File System) 계층이다. Node의 fs.readFile(path)는 결국 open(2) 시스템 콜을 호출하고, 커널은 이 문자열 경로를 path_lookup()(Linux) 알고리즘으로 한 컴포넌트씩 순회하며 처리한다.

  1. 경로를 /로 분리해 컴포넌트 배열로 만듦: uploads, .., .., .., etc, passwd
  2. 각 컴포넌트를 순서대로 디렉터리 엔트리(dentry)에서 조회하며 이동
  3. .. 컴포넌트를 만나면 커널은 현재 디렉터리의 부모 dentry로 그대로 이동 — 이건 파일시스템의 정상 기능(모든 디렉터리가 ., .. 엔트리를 갖는 것)이지 버그가 아니다. 커널 입장에서 "이 ..가 애플리케이션이 의도한 것인지 공격자가 주입한 것인지" 구분할 방법이 없다는 게 SQLi/XSS와 완전히 같은 구조다
  4. 심볼릭 링크를 만나면 링크가 가리키는 실제 경로로 다시 치환해 탐색을 계속함(symlink resolution) — 애플리케이션이 확장자만 검사하고 심볼릭 링크 여부를 확인하지 않으면, 업로드 디렉터리 안에 passwd.jpg → /etc/passwd 심볼릭 링크를 만들어 확장자 필터까지 우회하는 변형도 가능하다
  5. 최종 컴포넌트에 도달하면 해당 inode를 열어 파일 디스크립터를 반환

애플리케이션의 문자열 필터(if (file.includes('..')) reject)는 이 커널 알고리즘이 실행되기 훨씬 전 단계에서 도는 것이고, 커널은 필터를 통과한 문자열을 넘겨받으면 그 안의 ..을 아무 의심 없이 정상 처리한다. 그래서 필터를 완벽히 우회하는 인코딩 변형이 계속 나온다 — URL 인코딩(%2e%2e%2f), 이중 인코딩(%252e%252e%252f), 유니코드 우회 등은 전부 "애플리케이션 필터를 통과하는 시점"과 "커널이 최종 문자열을 해석하는 시점" 사이의 디코딩 차이를 노린다.

역사적 사례: 널바이트(Null Byte) 인젝션이 보여주는 "커널 레벨 우회"의 전형

PHP 5.3.4 이전 버전에서는 file.php%00.jpg 같은 입력이 실제로 파일 읽기를 뚫었다. 이유는 PHP 내부가 문자열 길이를 저장하는 고수준 문자열 타입을 쓰는 반면, 최종적으로 open() 시스템 콜에 전달할 때는 **C 언어의 null-terminated string(널 바이트로 문자열 끝을 표시하는 규약)**으로 변환해야 했기 때문이다. file.php\0.jpg를 C 문자열로 취급하면 \0 이후는 아예 존재하지 않는 것으로 잘려버린다 — 애플리케이션은 확장자 검사를 .jpg로 통과시켰지만, 실제로 커널에 전달되는 경로는 file.php뿐이었던 것. 이건 "고수준 언어의 문자열 모델"과 "커널이 요구하는 C 문자열 규약" 사이의 불일치가 만든 취약점이라는 점에서, 앞서 본 인코딩 우회들과 본질이 같다 — 검증 계층과 실행 계층이 문자열을 서로 다르게 해석하는 지점은 전부 잠재적 우회 지점이 된다.

페이로드가 서버에서 실제로 작동하는 순서

  1. 공격자가 GET /download?file=../../../../etc/passwd 요청
  2. Express 라우터가 req.query.file을 그대로 받아 path.join(UPLOAD_DIR, file) 실행 (안전 검증 없다고 가정)
  3. Node가 fs.readFile() → 내부적으로 open(2) 시스템 콜 호출, 커널이 위 path_lookup() 과정을 그대로 수행
  4. /etc/passwd의 inode를 정상적으로 열어 파일 디스크립터 반환 — 커널 입장에서는 애플리케이션이 정상적인 절대경로로 파일을 요청한 것과 100% 동일한 처리
  5. 파일 내용이 res.sendFile()을 통해 HTTP 응답 바디로 그대로 반환됨

여기서 끝나지 않는다: LFI 체이닝

1) .env / 설정 파일 유출 → 세션 위조

LFI로 애플리케이션의 .env를 읽어 **세션 시크릿(SESSION_SECRET)**을 확보하면, express-session이 쿠키에 서명할 때 쓰는 HMAC 키를 그대로 손에 넣는 것과 같다. 이 시크릿만 있으면 공격자는 로그인 절차 없이 임의의 userId를 담은 세션 쿠키를 직접 서명해서 위조할 수 있다 — LFI(읽기 전용 취약점)가 인증 우회로 바로 이어지는 가장 짧은 체인이다. DB 접속 정보까지 같이 새면 SQL Injection 글에서 다룬 DB 직접 접근까지 이어진다.

2) 로그 오염(Log Poisoning) → 원격 코드 실행 (읽기만 하던 취약점이 RCE로)

LFI 자체는 "읽기"만 가능한 것처럼 보이지만, 애플리케이션이 사용자 입력을 그대로 로그 파일에 기록하는 지점(User-Agent, Referer 헤더 등)이 있다면 이야기가 달라진다.

  1. 공격자가 User-Agent: <?php system($_GET['c']); ?> 헤더를 담아 아무 요청이나 보냄 → 웹서버 access 로그(~/webapp/logs/access.log 같은 경로)에 이 문자열이 텍스트로 그대로 기록됨
  2. 공격자가 LFI로 ?file=../../../../var/log/nginx/access.log처럼 그 로그 파일 자체를 include/read 대상으로 지정
  3. 서버가 로그 파일을 "실행 가능한 스크립트"로 include하는 지점(PHP의 include($_GET['file']) 같은 패턴)이 있다면, 로그 안에 텍스트로만 존재하던 PHP 코드가 이 순간 실제로 파싱·실행된다

"파일을 읽기만 하는 취약점"이, "사용자 입력이 어딘가에 텍스트로 저장되는 다른 기능"과 만나는 순간 원격 코드 실행으로 격상된다는 게 이 체인의 핵심 통찰이다 — 이 블로그의 실제 access.log에서도 스캐너들이 User-Agent나 URL 경로에 스크립트 태그·PHP 코드 조각을 미리 심어보는 요청이 관측됐는데, 정확히 이 체인을 노린 사전 정찰 시도다.

3) /proc/self/environ → 프로세스 환경변수 전체 유출

Linux에서는 /proc/self/environ을 읽으면 **현재 프로세스의 환경변수 전체(DB 비밀번호, API 키, 클라우드 자격증명이 환경변수로 주입되는 경우가 많다)**를 그대로 볼 수 있다. LFI가 웹서버 프로세스 권한으로 파일을 읽는 것이므로, 이 경로 하나로 .env를 안 쓰는 스택에서도 동일한 시크릿 유출이 가능하다.

4) 소스코드 노출 → "블랙박스"를 "화이트박스"로 전환하는 체인 증폭기

LFI로 애플리케이션 자신의 소스코드(routes/*.js, views/*.ejs)를 읽어낼 수 있다면, 공격자는 더 이상 블랙박스 추측에 의존하지 않고 정확한 SQL 쿼리 구조, 다른 엔드포인트의 인증 로직, 하드코딩된 시크릿을 눈으로 확인하며 다른 취약점(SQLi, 인증 우회)을 훨씬 정밀하게 찾아낼 수 있다 — LFI가 직접적인 피해를 주기보다 다른 모든 체인의 정찰 단계를 극적으로 가속시키는 증폭기 역할을 한다는 점이 중요하다.

탐지와 방어 (요약)

LFI를 "파일 하나 읽히는 문제"가 아니라 **"커널의 경로 탐색 알고리즘은 ..을 항상 정상 처리한다는 사실과, 애플리케이션의 문자열 필터가 그 알고리즘이 실제로 보는 문자열과 다른 표현을 놓칠 수 있다는 구조적 틈"**으로 이해하면, 왜 읽기 전용처럼 보이는 이 취약점이 세션 위조·RCE·다른 취약점의 정찰 증폭까지 이어지는지가 같은 논리로 설명된다.

Web 카테고리의 글 (12/12)

  1. SQL Injection: 원리, 탐지, 방어
  2. XSS (Cross-Site Scripting): Stored / Reflected / DOM 차이와 방어
  3. CSRF (Cross-Site Request Forgery): 원리와 방어
  4. IDOR / BOLA: 권한 검증이 빠졌을 때
  5. SSRF (Server-Side Request Forgery): 서버가 대신 요청하게 만들기
  6. 파일 업로드 취약점: 웹쉘로 이어지는 경로
  7. 안전하지 않은 역직렬화 (Insecure Deserialization)
  8. SSTI (Server-Side Template Injection): 원리와 탐지
  9. OS Command Injection: 원리와 필터 우회
  10. JWT 취약점: alg=none부터 알고리즘 컨퓨전까지
  11. Race Condition (TOCTOU) 취약점
  12. Path Traversal / LFI (Local File Inclusion): 커널 경로 탐색부터 RCE 체이닝까지
← Race Condition (TOCTOU) 취약점