[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) 알고리즘으로 한 컴포넌트씩 순회하며 처리한다.
/로 분리해 컴포넌트 배열로 만듦: uploads, .., .., .., etc, passwd.. 컴포넌트를 만나면 커널은 현재 디렉터리의 부모 dentry로 그대로 이동 — 이건 파일시스템의 정상 기능(모든 디렉터리가 ., .. 엔트리를 갖는 것)이지 버그가 아니다. 커널 입장에서 "이 ..가 애플리케이션이 의도한 것인지 공격자가 주입한 것인지" 구분할 방법이 없다는 게 SQLi/XSS와 완전히 같은 구조다passwd.jpg → /etc/passwd 심볼릭 링크를 만들어 확장자 필터까지 우회하는 변형도 가능하다애플리케이션의 문자열 필터(if (file.includes('..')) reject)는 이 커널 알고리즘이 실행되기 훨씬 전 단계에서 도는 것이고, 커널은 필터를 통과한 문자열을 넘겨받으면 그 안의 ..을 아무 의심 없이 정상 처리한다. 그래서 필터를 완벽히 우회하는 인코딩 변형이 계속 나온다 — URL 인코딩(%2e%2e%2f), 이중 인코딩(%252e%252e%252f), 유니코드 우회 등은 전부 "애플리케이션 필터를 통과하는 시점"과 "커널이 최종 문자열을 해석하는 시점" 사이의 디코딩 차이를 노린다.
PHP 5.3.4 이전 버전에서는 file.php%00.jpg 같은 입력이 실제로 파일 읽기를 뚫었다. 이유는 PHP 내부가 문자열 길이를 저장하는 고수준 문자열 타입을 쓰는 반면, 최종적으로 open() 시스템 콜에 전달할 때는 **C 언어의 null-terminated string(널 바이트로 문자열 끝을 표시하는 규약)**으로 변환해야 했기 때문이다. file.php\0.jpg를 C 문자열로 취급하면 \0 이후는 아예 존재하지 않는 것으로 잘려버린다 — 애플리케이션은 확장자 검사를 .jpg로 통과시켰지만, 실제로 커널에 전달되는 경로는 file.php뿐이었던 것. 이건 "고수준 언어의 문자열 모델"과 "커널이 요구하는 C 문자열 규약" 사이의 불일치가 만든 취약점이라는 점에서, 앞서 본 인코딩 우회들과 본질이 같다 — 검증 계층과 실행 계층이 문자열을 서로 다르게 해석하는 지점은 전부 잠재적 우회 지점이 된다.
GET /download?file=../../../../etc/passwd 요청req.query.file을 그대로 받아 path.join(UPLOAD_DIR, file) 실행 (안전 검증 없다고 가정)fs.readFile() → 내부적으로 open(2) 시스템 콜 호출, 커널이 위 path_lookup() 과정을 그대로 수행/etc/passwd의 inode를 정상적으로 열어 파일 디스크립터 반환 — 커널 입장에서는 애플리케이션이 정상적인 절대경로로 파일을 요청한 것과 100% 동일한 처리res.sendFile()을 통해 HTTP 응답 바디로 그대로 반환됨.env / 설정 파일 유출 → 세션 위조LFI로 애플리케이션의 .env를 읽어 **세션 시크릿(SESSION_SECRET)**을 확보하면, express-session이 쿠키에 서명할 때 쓰는 HMAC 키를 그대로 손에 넣는 것과 같다. 이 시크릿만 있으면 공격자는 로그인 절차 없이 임의의 userId를 담은 세션 쿠키를 직접 서명해서 위조할 수 있다 — LFI(읽기 전용 취약점)가 인증 우회로 바로 이어지는 가장 짧은 체인이다. DB 접속 정보까지 같이 새면 SQL Injection 글에서 다룬 DB 직접 접근까지 이어진다.
LFI 자체는 "읽기"만 가능한 것처럼 보이지만, 애플리케이션이 사용자 입력을 그대로 로그 파일에 기록하는 지점(User-Agent, Referer 헤더 등)이 있다면 이야기가 달라진다.
User-Agent: <?php system($_GET['c']); ?> 헤더를 담아 아무 요청이나 보냄 → 웹서버 access 로그(~/webapp/logs/access.log 같은 경로)에 이 문자열이 텍스트로 그대로 기록됨?file=../../../../var/log/nginx/access.log처럼 그 로그 파일 자체를 include/read 대상으로 지정include($_GET['file']) 같은 패턴)이 있다면, 로그 안에 텍스트로만 존재하던 PHP 코드가 이 순간 실제로 파싱·실행된다"파일을 읽기만 하는 취약점"이, "사용자 입력이 어딘가에 텍스트로 저장되는 다른 기능"과 만나는 순간 원격 코드 실행으로 격상된다는 게 이 체인의 핵심 통찰이다 — 이 블로그의 실제 access.log에서도 스캐너들이 User-Agent나 URL 경로에 스크립트 태그·PHP 코드 조각을 미리 심어보는 요청이 관측됐는데, 정확히 이 체인을 노린 사전 정찰 시도다.
/proc/self/environ → 프로세스 환경변수 전체 유출Linux에서는 /proc/self/environ을 읽으면 **현재 프로세스의 환경변수 전체(DB 비밀번호, API 키, 클라우드 자격증명이 환경변수로 주입되는 경우가 많다)**를 그대로 볼 수 있다. LFI가 웹서버 프로세스 권한으로 파일을 읽는 것이므로, 이 경로 하나로 .env를 안 쓰는 스택에서도 동일한 시크릿 유출이 가능하다.
LFI로 애플리케이션 자신의 소스코드(routes/*.js, views/*.ejs)를 읽어낼 수 있다면, 공격자는 더 이상 블랙박스 추측에 의존하지 않고 정확한 SQL 쿼리 구조, 다른 엔드포인트의 인증 로직, 하드코딩된 시크릿을 눈으로 확인하며 다른 취약점(SQLi, 인증 우회)을 훨씬 정밀하게 찾아낼 수 있다 — LFI가 직접적인 피해를 주기보다 다른 모든 체인의 정찰 단계를 극적으로 가속시키는 증폭기 역할을 한다는 점이 중요하다.
path.resolve()로 완전히 정규화한 뒤, 결과가 반드시 허용된 루트 디렉터리 접두사로 시작하는지 재검증fs.realpath()로 실제 경로까지 풀어서 최종 검증/etc/passwd, .env, 소스코드 디렉터리를 읽을 필요가 없다면 OS 레벨 파일 권한(chmod)으로 프로세스 계정의 읽기 권한 자체를 제한해서, 애플리케이션 로직이 뚫려도 커널이 마지막에 EACCES로 막아주는 심층 방어를 하나 더 둔다LFI를 "파일 하나 읽히는 문제"가 아니라 **"커널의 경로 탐색 알고리즘은 ..을 항상 정상 처리한다는 사실과, 애플리케이션의 문자열 필터가 그 알고리즘이 실제로 보는 문자열과 다른 표현을 놓칠 수 있다는 구조적 틈"**으로 이해하면, 왜 읽기 전용처럼 보이는 이 취약점이 세션 위조·RCE·다른 취약점의 정찰 증폭까지 이어지는지가 같은 논리로 설명된다.