<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Path Traversal on jyukki's Blog</title><link>https://jyukki.com/tags/path-traversal/</link><description>Recent content in Path Traversal on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Thu, 22 Oct 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/path-traversal/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Archive Extraction Gateway, 압축 파일을 신뢰할 수 있는 입력으로 바꾸는 법</title><link>https://jyukki.com/learning/deep-dive/2026-10-22-archive-extraction-security-budget-playbook/</link><pubDate>Thu, 22 Oct 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/2026-10-22-archive-extraction-security-budget-playbook/</guid><description>ZIP·TAR 같은 압축 파일을 업로드받을 때 압축 해제 예산, 경로 검증, 격리 실행, 검사 증거를 분리해 zip bomb과 path traversal을 운영 가능한 정책으로 다루는 방법을 정리합니다.</description><content:encoded><![CDATA[<p>백업 묶음, 고객이 올린 자료, 플러그인 패키지, 대량 import는 ZIP·TAR·7z 같은 압축 파일로 들어오는 경우가 많습니다. 업로드 API가 <code>201 Created</code>를 돌려줬다고 해서 그 파일을 애플리케이션이 안전하게 열 수 있다는 뜻은 아닙니다. 작은 압축 파일이 수십 GiB로 풀릴 수 있고, <code>../../</code> 경로가 작업 디렉터리 밖을 가리킬 수 있으며, symlink와 특수 파일이 뒤 단계의 검사나 복사를 다른 대상으로 돌릴 수 있습니다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/">파일 업로드 격리·악성코드 스캔</a>, <a href="/learning/deep-dive/2026-10-03-content-addressed-object-integrity-playbook/">Content-Addressed Object Integrity</a>, <a href="/learning/deep-dive/deep-dive-document-rendition-versioning-playbook/">문서 처리 파이프라인의 원본·파생본 분리</a>, <a href="/learning/deep-dive/deep-dive-request-body-guardrail-streaming-playbook/">요청 본문 크기·스트리밍 Guardrail</a>을 아카이브 입력 경로에 연결합니다. 목표는 모든 압축 형식을 지원하는 것이 아니라, <strong>해제 전·중·후에 서버가 소비할 수 있는 자원을 증명하고 위험한 산출물을 publish하지 않는 것</strong>입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>압축 파일의 위험을 업로드 크기, 해제 크기, 파일 경로, parser, 후속 처리의 다섯 층으로 나누는 기준을 얻습니다.</li>
<li>zip bomb과 path traversal을 단일 정규식이 아니라 수량 예산과 상태 전이로 막는 방법을 배웁니다.</li>
<li>격리 worker가 생성한 파일을 어떻게 manifest로 확인하고 공개 영역으로 승격할지 정리합니다.</li>
<li>암호화 아카이브, 중첩 압축, 검사 실패처럼 애매한 경우에 fail-closed를 적용할 조건을 만듭니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-업로드-수락과-아카이브-소비-승인은-다른-사건이다">1) 업로드 수락과 아카이브 소비 승인은 다른 사건이다</h3>
<p>업로드 단계에서는 네트워크 전송을 끝냈을 뿐입니다. 실제 위험은 백엔드가 목록을 읽고, 압축을 풀고, 이미지·문서·코드 같은 하위 파일을 파싱하며 시작됩니다. 일반적인 파일 검사만 앞에 두면 압축 내부의 실제 파일 수와 해제 후 크기를 모르고, 반대로 애플리케이션 pod 안에서 곧바로 풀면 임시 디스크·inode·CPU·메모리·공유 볼륨이 함께 위험해집니다.</p>
<p>따라서 입력의 상태를 최소한 <code>received → quarantined → inspected → extracted → verified → published</code>로 나눕니다. <code>received</code>와 <code>quarantined</code>는 원본 바이트를 보관할 수 있는 상태이지만, 사용자 다운로드·색인·미리보기의 입력이 될 수는 없습니다. <code>verified</code>는 정책을 통과한 extract manifest가 있고, 각 산출물이 허용 형식인지 확인된 상태입니다. 어느 단계든 timeout, scanner 오류, 예상 밖의 entry type은 <code>rejected</code> 또는 <code>needs_review</code>로 끝내며 자동 공개로 되돌아가면 안 됩니다.</p>
<h3 id="2-압축-해제는-하나의-크기-제한이-아니라-다섯-개의-예산-문제다">2) 압축 해제는 하나의 크기 제한이 아니라 다섯 개의 예산 문제다</h3>
<p>“100MiB 이하 ZIP만 받는다”는 정책만으로는 부족합니다. 공격자는 높은 압축률, 아주 많은 빈 파일, 깊은 중첩, 긴 파일명, 압축 헤더와 실제 데이터의 불일치를 조합할 수 있습니다. 형식 라이브러리가 제공하는 metadata도 신뢰할 수 없는 입력으로 다루고, 스트리밍 중 실제로 쓴 바이트를 다시 계수해야 합니다.</p>
<p>아래 값은 시작점이지 보편적인 보안 표준이 아닙니다. 예를 들어 고객 백업을 허용하는 제품은 해제 크기가 더 커질 수 있지만, 그 경우에도 별도 queue와 디스크 quota를 가져야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>예산</th>
          <th style="text-align: right">일반 문서 묶음의 출발 기준</th>
          <th>초과 시 기본 행동</th>
          <th>이유</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>압축 원본 크기</td>
          <td style="text-align: right">100MiB</td>
          <td>업로드 거부 또는 별도 import 경로</td>
          <td>ingress·저장 비용 보호</td>
      </tr>
      <tr>
          <td>실제 해제 총량</td>
          <td style="text-align: right">1GiB</td>
          <td>즉시 중단·quarantine 유지</td>
          <td>zip bomb·디스크 고갈 차단</td>
      </tr>
      <tr>
          <td>entry 수</td>
          <td style="text-align: right">10,000개</td>
          <td>중단·review</td>
          <td>inode·scanner·색인 폭주 방지</td>
      </tr>
      <tr>
          <td>해제 비율</td>
          <td style="text-align: right">100:1</td>
          <td>중단 또는 승인 queue</td>
          <td>작지만 과도하게 부푸는 입력 감지</td>
      </tr>
      <tr>
          <td>단일 entry</td>
          <td style="text-align: right">256MiB</td>
          <td>해당 entry 거부</td>
          <td>하위 parser·메모리 예산 보호</td>
      </tr>
      <tr>
          <td>경로 깊이</td>
          <td style="text-align: right">10단계</td>
          <td>거부</td>
          <td>비정상 트리·후속 도구 오류 완화</td>
      </tr>
      <tr>
          <td>wall-clock</td>
          <td style="text-align: right">60초/작업</td>
          <td>worker kill·재시도 금지</td>
          <td>CPU 고갈과 무한 처리 방지</td>
      </tr>
  </tbody>
</table>
<p>압축 비율은 <code>expected_uncompressed / compressed</code> 하나만 보는 지표가 아닙니다. 압축 헤더가 거짓일 수 있으므로, 실제로 기록한 바이트와 작업 시간·entry 수를 함께 봅니다. <code>compressed=0</code>에 가까운 항목, data descriptor를 뒤에 두는 형식, ZIP64처럼 라이브러리별 해석이 다른 경우에는 metadata 기반 통과를 허용하지 말고 보수적으로 분기하세요.</p>
<h3 id="3-경로-문자열을-고친다고-안전한-파일-트리가-되지는-않는다">3) 경로 문자열을 고친다고 안전한 파일 트리가 되지는 않는다</h3>
<p>아카이브 entry의 이름은 운영체제 파일 경로가 아니라 공격자가 준 문자열입니다. <code>../</code>, 절대 경로, Windows drive prefix, NUL, 과도한 Unicode 정규화 차이뿐 아니라 symlink·hard link·device·FIFO가 문제를 만듭니다. <code>normalize(entry).startsWith(root)</code> 같은 검사는 필요한 첫 단계일 수 있지만, symlink가 만들어진 뒤의 실제 파일 시스템 해석까지 보장하지 않습니다.</p>
<p>안전한 기본값은 다음과 같습니다.</p>
<ol>
<li>archive entry를 OS 경로로 join하기 전에 separator를 하나의 형식으로 정규화하고, 빈 이름·절대 경로·상위 디렉터리·제어 문자를 거부합니다.</li>
<li>허용 타입을 regular file과 필요한 directory로 제한합니다. symlink, hard link, block/character device, FIFO는 지원 요구가 명확하기 전까지 거부합니다.</li>
<li>대상 파일은 sandbox root 아래에서 새로 만들고, 기존 파일을 따라가거나 덮어쓰지 않습니다. 공개 저장소 key와 로컬 임시 경로를 같은 문자열로 재사용하지 않습니다.</li>
<li>추출이 끝난 뒤 manifest의 상대 경로, 크기, digest, MIME 판별 결과를 다시 만들고 다음 단계는 manifest만 읽게 합니다.</li>
</ol>
<p>MIME은 extension이나 archive header의 <code>Content-Type</code>만 믿지 말고 magic bytes와 허용 목록으로 확인해야 합니다. 특히 실행 파일, 스크립트, 이중 확장자, 문서 매크로, 또 다른 아카이브는 “해제했다”가 아니라 별도의 위험 분류가 필요합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-quarantine과-extraction-sandbox의-소유권을-분리한다">1) quarantine과 extraction sandbox의 소유권을 분리한다</h3>
<p>원본은 tenant·업로드 세션·content digest를 묶은 quarantine prefix에 immutable로 저장합니다. extraction worker에는 이 원본을 <strong>읽을 수 있는</strong> 짧은 권한만 주고, 공개 prefix나 production database credential은 주지 않습니다. worker의 쓰기 대상은 작업 ID별 빈 scratch volume과 검증 전 산출 prefix뿐이어야 합니다.</p>
<p>작업을 다음처럼 나누면 재시도와 감사가 쉬워집니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>upload intent
</span></span><span style="display:flex;"><span>  -&gt; immutable quarantine object + sha256
</span></span><span style="display:flex;"><span>  -&gt; format sniff + policy preflight
</span></span><span style="display:flex;"><span>  -&gt; sandboxed extraction (byte / entry / time counters)
</span></span><span style="display:flex;"><span>  -&gt; per-entry type scan + manifest
</span></span><span style="display:flex;"><span>  -&gt; verifier accepts manifest
</span></span><span style="display:flex;"><span>  -&gt; copy/promote to published namespace
</span></span></code></pre></div><p>preflight는 빠른 거부를 위한 보조 장치입니다. entry count나 알려진 uncompressed size가 이미 예산을 넘으면 worker를 시작하지 않을 수 있습니다. 하지만 최종 판정은 streaming extraction 중의 실제 계수여야 합니다. worker가 죽었거나 timeout이 났다면 동일 job을 무한 재시도하지 말고, 시도 횟수와 소비한 byte·time을 receipt에 남긴 뒤 수동 review 또는 새 업로드를 요구하세요.</p>
<h3 id="2-manifest를-다음-단계의-유일한-입력-계약으로-둔다">2) manifest를 다음 단계의 유일한 입력 계약으로 둔다</h3>
<p>해제 작업이 <code>/tmp/foo</code>에 파일을 만들었다는 사실을 문서 변환기나 import job이 직접 믿으면, 재시도·cleanup·경로 변경 때 서로 다른 파일을 읽을 수 있습니다. verifier가 만든 manifest에 <code>source_digest</code>, <code>extractor_version</code>, <code>policy_version</code>, 상대 경로, 실제 크기, digest, detected_type, scan verdict를 넣고, 다음 단계에는 그 manifest ID만 넘깁니다.</p>
<p>예를 들어 2,000개 CSV를 import하는 서비스라면 추출 성공과 import 가능을 분리합니다. CSV 총량은 800MiB여도 한 파일이 300MiB를 넘으면 reject할 수 있고, UTF-8 검증이나 행 수 상한은 별도 import policy로 평가합니다. 이전에 올린 같은 원본 digest가 있다면 <a href="/learning/deep-dive/2026-10-03-content-addressed-object-integrity-playbook/">content-addressed 무결성</a>을 이용해 검증 결과를 재사용할 수 있지만, <strong>policy version이나 scanner definition이 바뀌면 예전 verdict를 그대로 publish 판단에 쓰지 않는 것</strong>이 안전합니다.</p>
<h3 id="3-관측-지표는-거부율보다-예산-소비-분포를-먼저-본다">3) 관측 지표는 거부율보다 예산 소비 분포를 먼저 본다</h3>
<p>아카이브 거부율이 0%라고 안전한 것은 아닙니다. 정상 고객의 대형 파일이 작업 queue를 오래 점유하거나, 압축률이 높은 입력 때문에 scratch disk가 고갈될 수 있습니다. 다음 지표를 archive format·tenant tier·policy version별로 관찰하세요.</p>
<table>
  <thead>
      <tr>
          <th>지표</th>
          <th>경보보다 먼저 볼 질문</th>
          <th>출발 행동</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>실제 해제 bytes p95/p99</td>
          <td>어느 형식·고객이 budget을 쓰는가</td>
          <td>tier별 queue·quota 분리</td>
      </tr>
      <tr>
          <td>entry 수와 경로 거부 사유</td>
          <td>제품 기능 부족인가 공격 탐색인가</td>
          <td>샘플 검토 후 policy 보완</td>
      </tr>
      <tr>
          <td>extraction 시간과 kill 비율</td>
          <td>CPU·I/O·parser 중 병목은 어디인가</td>
          <td>worker 동시성·time budget 조정</td>
      </tr>
      <tr>
          <td><code>verified → published</code> 실패</td>
          <td>scanner·manifest·storage 중 어느 경계인가</td>
          <td>자동 재시도 전에 receipt 비교</td>
      </tr>
  </tbody>
</table>
<p>새 형식이나 예외 한도를 도입할 때는 먼저 7일 정도 observe-only로 실제 분포를 기록하고, 가장 위험이 큰 제한부터 enforce합니다. 예외가 반복되면 전역 10GiB 한도를 올리기보다 별도 import 제품 경로, 전용 worker pool, 승인된 포맷 목록을 만드는 편이 낫습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>모든 아카이브를 완전히 해제하지 않고 검사하면 비용은 낮지만, 내부 콘텐츠의 위험을 놓칠 수 있습니다. 반대로 중첩된 아카이브까지 재귀적으로 모두 풀면 공격 표면과 비용이 급격히 커집니다. 일반 제품의 기본값은 <strong>중첩 아카이브를 regular file로만 보관하고 자동 재귀 해제를 하지 않는 것</strong>입니다. 업무상 필요한 경우에만 depth 1 또는 2, 누적 byte budget, 전용 queue를 명시적으로 추가하세요.</p>
<p>암호화 ZIP은 내용 검사가 어렵습니다. 비밀번호를 받는다는 이유로 scanner를 건너뛰거나 production worker에서 여러 번 password를 시도하면 안 됩니다. 정책상 허용하지 않으면 <code>unsupported_encryption</code>으로 거부하고, 허용한다면 password 전달·메모리 보관·audit·실패 횟수를 별도 비밀 처리 경로로 설계해야 합니다.</p>
<p>또한 sandbox가 있다고 해서 안전한 것은 아닙니다. container의 host mount, broad service account, unrestricted egress, 공유 <code>/tmp</code>가 있으면 parser 취약점 하나가 더 넓은 권한으로 이어집니다. worker는 비루트, read-only root filesystem, 제한된 CPU·memory·ephemeral storage, outbound 기본 차단을 원칙으로 두고, 이미지와 extractor library의 취약점 관리도 release 절차에 포함하세요.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<ul>
<li><input disabled="" type="checkbox"> 업로드 원본, quarantine, extraction scratch, published namespace가 서로 다른 prefix·권한·보존 정책을 쓴다.</li>
<li><input disabled="" type="checkbox"> 압축·해제 크기, entry 수, 경로 깊이, 단일 파일, wall-clock의 상한과 초과 시 행동이 있다.</li>
<li><input disabled="" type="checkbox"> metadata preflight와 실제 streaming byte counter를 모두 사용하며, 전자는 최종 통과 근거가 아니다.</li>
<li><input disabled="" type="checkbox"> 절대 경로, 상위 경로, symlink·hard link·special file, 중첩 archive의 기본 정책이 명시돼 있다.</li>
<li><input disabled="" type="checkbox"> extractor는 공개 저장소와 production credential에 직접 쓰지 못하고, 실패 시 kill·quarantine 상태로 끝난다.</li>
<li><input disabled="" type="checkbox"> 다음 단계는 로컬 경로가 아닌 versioned manifest와 entry digest를 입력으로 받는다.</li>
<li><input disabled="" type="checkbox"> policy 버전·extractor 버전·scan verdict·예산 초과 사유를 receipt로 남긴다.</li>
</ul>
<p>연습으로 현재 서비스에서 가장 큰 ZIP 하나를 골라 보세요. 압축 원본 크기만 적지 말고, 실제 해제 크기, entry 수, 최대 단일 파일, 처리 시간, 내부 파일 타입을 표로 기록합니다. 그 값이 지금의 pod ephemeral storage·worker 동시성·scanner 처리량과 곱해졌을 때도 안전한지 계산하면, 막연한 “파일 크기 제한”을 실제 운영 예산으로 바꿀 수 있습니다.</p>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-object-upload-quarantine-scanning-playbook/">파일 업로드 격리·악성코드 스캔</a></li>
<li><a href="/learning/deep-dive/2026-10-03-content-addressed-object-integrity-playbook/">Content-Addressed Object Integrity</a></li>
<li><a href="/learning/deep-dive/deep-dive-document-rendition-versioning-playbook/">문서 처리 파이프라인의 원본·파생본 분리</a></li>
<li><a href="/learning/deep-dive/deep-dive-request-body-guardrail-streaming-playbook/">요청 본문 크기·스트리밍 Guardrail</a></li>
<li><a href="/learning/deep-dive/deep-dive-resumable-multipart-upload-session-playbook/">Resumable Multipart Upload Session</a></li>
</ul>
]]></content:encoded></item></channel></rss>