DB 와 앱이 같은 ID 를 다르게 읽으면 검사가 우회된다
검사 층과 실행 층이 같은 입력을 각자 파싱하면 두 파서의 차집합이 우회로가 됩니다. DB 를 흉내 내는 대신 좁은 정규형만 믿고 나머지는 fail-closed 로 막는 방법을 적었습니다.
업로드 용량 한도를 "요청이 신고한 파일 크기"가 아니라 이미 저장된 첨부의 실제 크기로 판정하도록 바꿨습니다. 그러다가 검사만 피해 가는 입력이 있다는 걸 알게 됐습니다.
구조는 이렇습니다. 요청에는 첨부 ID 목록이 오고, 앱은 그 ID 로 저장된 크기를 조회해 합산합니다. 같은 요청의 뒤쪽에서는 그 ID 문자열을 그대로 numeric(15,0) 컬럼에 INSERT 해서 게시글과 첨부를 연결합니다.
같은 문자열을 앱과 DB 가 다르게 읽습니다
앱이 조회 키를 만들 때는 정수만 받도록 파싱했습니다. 그런데 DB 는 훨씬 너그럽게 받습니다. PostgreSQL 15 에서 numeric(15,0) 컬럼에 넣어 보면 이렇습니다.
입력 문자열 numeric(15,0) 에 저장되는 값
'123.4' 123 (반올림)
'122.5' 123
'.6' 1
' 123 ' 123 (앞뒤 공백 무시)
'1.234e2' 123
'+0123' 123선행 0 을 수십 자 붙여도 같은 값으로 저장됩니다. 앱의 정수 파서는 이 문자열들을 거부하거나 길이 제한에 걸립니다.
앱이 키를 못 만들면 "저장된 값 없음"으로 보고, 요청이 신고한 크기로 판정하는 경로로 떨어집니다. 그러면 소수점이 붙은 ID 와 작은 신고 크기를 같이 보내는 것만으로 연결은 실제로 큰 첨부에 걸리고, 판정은 작은 값으로 통과합니다.
검사와 실행이 같은 입력을 서로 다르게 해석하는 전형적인 우회입니다.
DB 처럼 파싱하면 안 될까요?
처음 생각한 수정은 "앱도 DB 처럼 파싱하자"였습니다. 반올림 모드를 맞추고 길이 검사를 보정하면 될 것 같았죠.
하지만 이건 끝나지 않는 추격입니다. 공백, 지수 표기, 부호, 선행 0 을 전부 맞춰야 하고, DB 버전이 오르면 문법이 늘어납니다. PostgreSQL 16 에서는 숫자 표기에 밑줄 구분자(1_000)와 16진, 8진, 2진 접두어(0x7B)가 추가됐습니다. 하나라도 어긋나면 같은 우회가 다시 열립니다.
정규형만 믿고 나머지는 막습니다
- 신뢰하는 형식을 좁게 정합니다.
^[1-9][0-9]{0,14}$, 즉 선행 0 이 없는 양의 정수이면서 컬럼 자릿수 안인 것만 인정합니다. - 정규형이 아닌 ID 는 조회하지 않고, 판정에서 보수적인 값으로 셉니다. 새로 추가되는 쪽이면 무한대로 둬서 어떤 한도든 초과하게 만들고(거부), 빠지거나 유지되는 쪽(수정 전 합계처럼 클수록 통과에 유리한 쪽)이면 0 으로 둡니다. 방향마다 보수적인 값이 다르다는 점이 핵심입니다. 한쪽만 무한대로 두면 차이를 계산하는 곳에서 반대로 뚫립니다.
- 예외를 던지지 말고 값으로 처리합니다. 판정을 감싼 쪽이 예외를 삼켜 "판정 실패는 통과"로 처리하는 코드가 흔합니다.
- 센티널 값은 먼저 따로 가릅니다.
"0"이나 빈 값처럼 "이번 요청에서 새로 만든 첨부"를 뜻하는 표기가 있다면 정규형 검사보다 먼저 기존 경로로 보냅니다. 안 그러면 fail-closed 가 정상 신규 업로드를 전부 막습니다. 이때 센티널 판단 조건은 실행 층이 "새 항목"으로 보는 조건과 글자 그대로 같아야 합니다. 문자열 비교인지, 숫자로 파싱해 0 인지까지 맞춰야 합니다.
정상 클라이언트는 정규형만 보내니 영향이 없고, DB 버전이나 문법이 바뀌어도 불변식은 유지됩니다.
검사 층과 실행 층이 같은 입력을 각자 파싱하면 두 파서의 차집합이 곧 우회로입니다. 실행 층의 파서를 흉내 내지 말고, 검사 층이 믿을 좁은 정규형을 정한 뒤 나머지는 막는 게 맞았습니다. 정규화한 값을 실행 층에도 그대로 넘겨서 두 층이 같은 문자열을 보게 하면 더 좋습니다.