---
title: "DB 와 앱이 같은 ID 를 다르게 읽으면 검사가 우회된다"
description: "검사 층과 실행 층이 같은 입력을 각자 파싱하면 두 파서의 차집합이 우회로가 됩니다. DB 를 흉내 내는 대신 좁은 정규형만 믿고 나머지는 fail-closed 로 막는 방법을 적었습니다."
date: 2026-10-27
updated: 2026-10-27T11:52:33.686Z
tags: [security, postgresql, validation, fail-closed]
canonical: https://blog.wooncloud.com/posts/canonical-id-fail-closed-vs-mimic-db-parse
---

업로드 용량 한도를 "요청이 신고한 파일 크기"가 아니라 이미 저장된 첨부의 실제 크기로 판정하도록 바꿨습니다. 그러다가 검사만 피해 가는 입력이 있다는 걸 알게 됐습니다.

구조는 이렇습니다. 요청에는 첨부 ID 목록이 오고, 앱은 그 ID 로 저장된 크기를 조회해 합산합니다. 같은 요청의 뒤쪽에서는 그 ID 문자열을 그대로 `numeric(15,0)` 컬럼에 INSERT 해서 게시글과 첨부를 연결합니다.

## 같은 문자열을 앱과 DB 가 다르게 읽습니다

앱이 조회 키를 만들 때는 정수만 받도록 파싱했습니다. 그런데 DB 는 훨씬 너그럽게 받습니다. PostgreSQL 15 에서 `numeric(15,0)` 컬럼에 넣어 보면 이렇습니다.

```text
입력 문자열      numeric(15,0) 에 저장되는 값
'123.4'          123 (반올림)
'122.5'          123
'.6'             1
' 123 '          123 (앞뒤 공백 무시)
'1.234e2'        123
'+0123'          123
```

선행 0 을 수십 자 붙여도 같은 값으로 저장됩니다. 앱의 정수 파서는 이 문자열들을 거부하거나 길이 제한에 걸립니다.

![앱이 거부한 ID를 DB가 반올림해 받아들이면 두 파서의 차이가 그대로 우회로가 된다.](/images/posts/canonical-id-fail-closed-vs-mimic-db-parse/4d4793fa-0074-4581-8016-c8a3a9e86a50.webp)

앱이 키를 못 만들면 "저장된 값 없음"으로 보고, 요청이 신고한 크기로 판정하는 경로로 떨어집니다. 그러면 소수점이 붙은 ID 와 작은 신고 크기를 같이 보내는 것만으로 연결은 실제로 큰 첨부에 걸리고, 판정은 작은 값으로 통과합니다.

검사와 실행이 같은 입력을 서로 다르게 해석하는 전형적인 우회입니다.

![소수점이 붙은 ID는 앱의 정수 파서에서는 거부돼 신고 크기로 판정되지만 DB는 반올림해 실제 큰 첨부에 연결되고, 정규형이 아닌 ID를 보수값으로 막으면 이 우회가 닫힌다.](/images/posts/canonical-id-fail-closed-vs-mimic-db-parse/0d1924b7-9810-4ed3-b7b9-3fd169ad038c.svg)

## DB 처럼 파싱하면 안 될까요?

처음 생각한 수정은 "앱도 DB 처럼 파싱하자"였습니다. 반올림 모드를 맞추고 길이 검사를 보정하면 될 것 같았죠.

하지만 이건 끝나지 않는 추격입니다. 공백, 지수 표기, 부호, 선행 0 을 전부 맞춰야 하고, DB 버전이 오르면 문법이 늘어납니다. PostgreSQL 16 에서는 숫자 표기에 밑줄 구분자(`1_000`)와 16진, 8진, 2진 접두어(`0x7B`)가 추가됐습니다. 하나라도 어긋나면 같은 우회가 다시 열립니다.

## 정규형만 믿고 나머지는 막습니다

1. 신뢰하는 형식을 좁게 정합니다. `^[1-9][0-9]{0,14}$`, 즉 선행 0 이 없는 양의 정수이면서 컬럼 자릿수 안인 것만 인정합니다.
2. 정규형이 아닌 ID 는 조회하지 않고, 판정에서 보수적인 값으로 셉니다. 새로 추가되는 쪽이면 무한대로 둬서 어떤 한도든 초과하게 만들고(거부), 빠지거나 유지되는 쪽(수정 전 합계처럼 클수록 통과에 유리한 쪽)이면 0 으로 둡니다. 방향마다 보수적인 값이 다르다는 점이 핵심입니다. 한쪽만 무한대로 두면 차이를 계산하는 곳에서 반대로 뚫립니다.
3. 예외를 던지지 말고 값으로 처리합니다. 판정을 감싼 쪽이 예외를 삼켜 "판정 실패는 통과"로 처리하는 코드가 흔합니다.
4. 센티널 값은 먼저 따로 가릅니다. `"0"` 이나 빈 값처럼 "이번 요청에서 새로 만든 첨부"를 뜻하는 표기가 있다면 정규형 검사보다 먼저 기존 경로로 보냅니다. 안 그러면 fail-closed 가 정상 신규 업로드를 전부 막습니다. 이때 센티널 판단 조건은 실행 층이 "새 항목"으로 보는 조건과 글자 그대로 같아야 합니다. 문자열 비교인지, 숫자로 파싱해 0 인지까지 맞춰야 합니다.

정상 클라이언트는 정규형만 보내니 영향이 없고, DB 버전이나 문법이 바뀌어도 불변식은 유지됩니다.

**검사 층과 실행 층이 같은 입력을 각자 파싱하면 두 파서의 차집합이 곧 우회로입니다.** 실행 층의 파서를 흉내 내지 말고, 검사 층이 믿을 좁은 정규형을 정한 뒤 나머지는 막는 게 맞았습니다. 정규화한 값을 실행 층에도 그대로 넘겨서 두 층이 같은 문자열을 보게 하면 더 좋습니다.

## 참고

- [PostgreSQL Numeric Types](https://www.postgresql.org/docs/current/datatype-numeric.html)
- [PostgreSQL 16 릴리스 노트 (숫자 리터럴 밑줄, 비10진 접두어)](https://www.postgresql.org/docs/16/release-16.html)
