NearScrub

2026-07-20

NearScrub 파서 강화하기: PNG 정수 오버플로우와 집 폭탄 방어

NearScrub은 JPEG, PNG, PDF, Office 파일에서 EXIF/GPS 데이터, XMP, IPTC 등 내장된 메타데이터를 브라우저 안에서만 제거합니다 — 업로드도, 서버도 없이, 다른 모든 near 앱과 동일한 구조입니다. 이 사실은 자연스러운 질문을 하나 남깁니다: 서버도 없고 다른 사용자도 없다면, 파일 파서를 견고하게 만드는 것이 대체 누구를 방어하기 위한 것인가? 정직한 답은 이렇습니다 — 바로 이 도구를 쓰는 사람 자신을, 그 자신의 파일로부터 지키기 위해서입니다. 오래된 휴대폰에서 꺼낸 사진, 10년에 걸쳐 서로 다른 소프트웨어 서너 개를 거쳐 다시 저장된 PNG, 버그 있는 변환기를 한 번 이상 거친 docx — 이런 파일들은 악의적인 것이 전혀 아니지만, 입력이 항상 잘 만들어져 있다고 가정하는 파서는 손상되었거나 의도적으로 조작된 파일을 만나는 순간 버퍼 끝을 넘어가거나 수 기가바이트를 할당하려 들 수 있습니다. 무너질 서버는 없지만, 실제 사람의 파일이 열려 있는 실제 브라우저 탭은 여전히 존재하며, 스크러빙 도중에 그 탭이 멈추거나 죽는 것은 완전히 클라이언트 사이드인, 단일 사용자용 도구에서도 진짜 실패 모드입니다. 이 글은 scrub-core.js에서 이 문제가 실제로 고쳐야 할 만큼 중요했던 두 가지 구체적인 사례를 다룹니다.

PNG의 음수 청크 길이

PNG 파일은 길이 정보가 앞에 붙는 청크들의 나열이며, 이 길이 필드는 빅엔디안 4바이트로 부호 없는 32비트 정수로 읽혀야 합니다. pngChunks()는 원래 이를 단순하게 (b0<<24)|(b1<<16)|(b2<<8)|b3 방식으로 읽었습니다. 이는 실제 PNG 대부분 — 청크가 기껏해야 몇 메가바이트인 경우 — 에서는 정확해 보이고 실제로도 정확합니다. 다만 한 가지 특정한 경우에는 틀립니다. 자바스크립트의 비트 연산자는 32비트 부호 있는 정수를 대상으로 동작하기 때문에, 최상위 비트가 켜진 길이 필드 — 0x80000000 이상 — 는 이 계산식에서 큰 양수가 아니라 음수로 나옵니다. 조작되었거나 단순히 손상된 PNG 안의 4바이트 필드 하나가 파서가 파악한 청크 길이를 "매우 큼"에서 "음수"로 뒤집어버릴 수 있었고, 이를 사용하는 end = i + 12 + len 계산은 실제 파일과 아무 합리적 관계도 없는 종료 오프셋을 만들어낼 수 있었습니다 — 정확한 바이트 값에 따라 너무 작거나, 너무 크거나, 방향 자체가 잘못된 값이 나올 수 있는 것입니다. 그 오프셋을 신뢰할 만한 것으로 취급하기 전에 실제 버퍼 크기와 비교해서 확인하는 코드가 그 뒤에 전혀 없었고, 이것이 바로 "길이 필드가 거짓말을 했다"가 "우리는 그 거짓말을 근거로 슬라이스하거나 할당했다"로 바뀌는 종류의 빈틈입니다.

수정: 부호 없는 값으로 강제한 뒤, 실제 값과 대조한다

수정은 두 줄이며, 여전히 pngChunks() 안에 있습니다. 첫째, 길이를 읽는 식 끝에 >>> 0을 붙였습니다 — 자바스크립트에서 0만큼 부호 없는 우측 시프트를 하는 관용구로, 부호 있는 비트 OR 연산이 만들어낸 결과와 무관하게 그 비트 패턴을 부호 없는 값으로 강제 해석합니다. 이것만으로도 부호 반전 문제는 해결됩니다 — 이제 0x80000000의 길이는 -2147483648이 아니라 2,147,483,648로 읽힙니다. 하지만 부호는 고쳤어도 여전히 틀린 값일 수 있으므로, 수정의 나머지 절반은 보정된 값조차 곧바로 신뢰하지 않습니다 — 이전과 동일하게 end = i + 12 + len을 계산한 뒤, 그 청크를 유지하거나 다른 어딘가로 넘기기 전에 즉시 if (end > bytes.length) throw new Error('corruptImage')로 확인합니다. 순서가 중요합니다 — 이 경계 검사는 어떤 subarray 복사보다도, len에 기반한 어떤 할당보다도, 그 청크가 진짜라는 어떤 가정보다도 먼저 일어납니다. 이 검사를 통과하지 못한 파일은 부분적으로 처리되다가 나중에 예측하기 더 어려운 곳에서 터지는 대신, 숫자가 앞뒤가 맞지 않는 것이 처음 확인된 바로 그 한 곳에서 깔끔하게 거부됩니다.

거대하다고 주장하는 아주 작은 압축 파일

두 번째 사례는 근본적으로 같은 문제의 다른 형태입니다 — 타당성을 확인하기 전에 크기 주장을 믿는 것입니다. docx, xlsx, pptx, odt, ods, odp는 모두 내부적으로 ZIP 압축 파일이며, scrubOffice()는 PII를 담고 있는 속성 파트들(docProps/core.xml, app.xml, custom.xml)을 비우기 위해 이를 압축 해제했다가 다시 압축합니다. ZIP의 엔트리별 헤더는 압축 해제 후 크기를 그 아카이브를 만든 쪽이 적어 넣은 단순한 선언 값으로 담고 있습니다 — 이 숫자가 정직해야 한다고 강제하는 장치는 전혀 없고, 극단적인 압축률도 포맷상 완전히 합법입니다. "집 폭탄(zip bomb)"은 정확히 이 점을 노리도록 만들어진 파일입니다 — 디스크 상에서는 몇 킬로바이트에 불과한 다운로드 파일이, (실제로 압축을 풀도록 내버려두면) 수 기가바이트 이상으로 부풀어 오르도록 만들어질 수 있습니다. 단순한 압축 해제 호출에 그대로 넘기면, 평범해 보이는 첨부파일을 정리하려던 탭의 메모리를 고갈시키기에 충분합니다.

수정: 작업을 하기 전에 그 주장부터 확인한다

NearScrub은 ZIP 처리에 fflate를 사용하며, unzipSync는 엔트리마다 실제로 압축 해제되기 전에 한 번씩 호출되는 filter(file) 콜백을 제공합니다 — 이 시점의 file.originalSize는 실측된 결과가 아니라 아카이브 자체의 (아직 압축된 상태인) 헤더가 주장하는 숫자일 뿐입니다. scrubOffice()는 정확히 이 타이밍을 활용합니다 — 엔트리들에 걸쳐 누적되는 total을 유지하다가, total이 MAX_OFFICE_UNZIPPED_BYTES(2GB — 임베디드 미디어가 있는 실제 docx/xlsx/pptx 파일도 사실상 근접하지 못하는 예산)를 초과하는 순간 bomb 플래그를 세우고 필터에서 false를 반환해 그 엔트리의 압축 해제를 건너뛴 뒤, unzipSync가 반환되고 나면 archiveTooLarge를 던집니다. 이 검사 비용은 엔트리당 덧셈 한 번과 비교 한 번뿐이며, 실제 압축 해제 작업보다 반드시 먼저 일어납니다 — 터무니없는 총합을 선언한 파일은 브라우저가 이미 주소 공간의 상당 부분을 소모해서야 알아내는 대신, 자기 자신의 헤더가 스스로 밝힌 근거로 거부당합니다.

같은 형태가 두 번: 자원을 쓰기 전에 먼저 확인한다

두 수정 모두 한 가지 규칙의 사례입니다 — 신뢰할 수 없는 파일 내부에서 온 숫자(청크 길이, 선언된 압축 해제 후 크기)는 파서가 독립적으로 확인할 수 있는 무언가(실제 버퍼 길이, 고정된 합리적 예산)와 대조하기 전까지는 사실이 아니라 주장일 뿐이라는 것입니다. PNG 사례는 청크를 진짜로 취급하기 전에 길이를 실제 사용 가능한 바이트 수와 대조합니다. Office 사례는 압축 해제를 아예 허용하기 전에 누적 선언 크기를 고정된 상한선과 대조합니다. 두 수정 모두 정상적으로 만들어진 파일의 동작을 관찰 가능한 방식으로 전혀 바꾸지 않습니다 — 2GB 예산도, 경계 검사도 실제 사진이나 스크린샷, 오피스 문서가 만들어내는 범위를 훨씬 벗어난 곳에 있습니다. 이 수정들이 바꾸는 것은, 애초에 정상적으로 만들어질 리 없었던 단 하나의 입력에게 일어나는 일입니다 — 그 파일이 우연히 손상되었든 의도적으로 만들어졌든, 탭이 할당이나 슬라이스를 안전하게 끝맺지 못한 채로 문제를 발견하는 대신, 이미 무엇이 잘못되었는지 정확히 아는 바로 그 지점에서 깔끔하고 이름 붙은 에러 — corruptImage, archiveTooLarge — 가 던져집니다.

서버가 없는데도 왜 신경 쓰는가

이 두 버그는 애초에 인프라를 무너뜨릴 수 없었습니다 — NearScrub에는 무너뜨릴 인프라 자체가 없기 때문입니다. 하지만 "서버가 없다"는 것이 "결과가 없다"는 뜻은 아닙니다. 이 두 경로 중 하나를 만나는 사람은 자기 자신의 브라우저 탭을 보고 있으며, 사진을 누군가에게 보내기 전에 GPS 좌표를 지우려 하거나, 문서를 게시하기 전에 작성자 메타데이터를 지우려 하는 중입니다 — 바로 이런 프라이버시 동기가 있는 순간에 탭이 멈추거나 조용히 잘못된 크기의 결과물을 내놓는 것이야말로 최악의 실패입니다. 로컬에서 동작하는, 단일 사용자용, 업로드 없는 도구는 낯선 사람들의 파일을 파싱하는 서버보다 공격 표면이 작지만, "작다"는 "0이다"가 아니며, 자신의 입력이 항상 잘 동작할 것이라고 가정하는 파일 포맷 파서는 언젠가 그렇지 않은 입력을 만나게 됩니다 — 잘못된 전송으로 손상되었든, 혹은 이 앱이 언젠가 다른 누군가로부터 받은 파일을 다루게 된다면 의도적으로 적대적이든 말입니다. 둘 다, 나쁘게 실패하기보다는 깔끔하게 실패할 가치가 있습니다.

광고
← NearScrub

이 페이지는 동의하신 경우에만 광고를 표시합니다.