# 주제: 역슬래시로 끝나는 헤더 값이 outbox 헤더 맵 전체를 깨뜨린다 (런타임 재현) # revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 # severity: P2 # 방법: 애플리케이션 소스를 수정하지 않고, 컴파일된 클래스에 jshell + 리플렉션으로 # private static toJson/fromJson 을 직접 호출했다. 테스트 파일을 추가하지 않았다. # ---- 원인 ---- # JdbcOutboxRepository.java:657-664 # private static int findClosingQuote(String text, int from) { # for (int index = from; index < text.length(); index++) { # if (text.charAt(index) == '"' && text.charAt(index - 1) != '\\') { # return index; # } # } # return text.length(); # } # 닫는 따옴표 판정이 "바로 앞 글자가 역슬래시가 아니다" 뿐이다. # 값이 역슬래시로 끝나면 escape 가 그것을 두 개로 늘리므로(`\\`), # 닫는 따옴표 바로 앞 글자가 역슬래시가 되어 종료 지점을 놓친다. # # 대조: escape(...) 는 제어문자까지 처리한다(:637-655). 그 javadoc(:630-636)이 # "Control characters are the part of the JSON grammar that is easy to forget and impossible to # get away with." 라고 쓴다. 같은 쌍에서 잊힌 두 번째 자리가 이 종료 판정이다. # ---- 재현 ---- # command: jshell --class-path (리플렉션으로 private static 호출) # exit: 0 === case 1: 평범한 값 === in = {x-a=plain} json = {"x-a":"plain"} out = {x-a=plain} EQUAL? true === case 2: 값 안의 따옴표 === in = {x-a=he said "hi"} json = {"x-a":"he said \"hi\""} out = {x-a=he said "hi"} EQUAL? true === case 3: 값이 역슬래시로 끝남 === in = {x-a=a\} json = {"x-a":"a\\"} out = {x-a=a\"} EQUAL? false <-- 값이 a\ -> a\" 로 변형 === case 4: 역슬래시로 끝나는 값 + 뒤에 헤더 하나 더 === in = {x-a=a\, x-b=second} json = {"x-a":"a\\","x-b":"second"} out = {x-a=a\",, :=x-a, a\",=second} EQUAL? false <-- 맵 전체가 붕괴. 키 ":" 와 키 "a\"," 가 생기고 x-b 는 사라진다. === case 5: 값 중간의 역슬래시 (대조군) === in = {x-a=a\b} json = {"x-a":"a\\b"} out = {x-a=a\b} EQUAL? true <-- 중간 역슬래시는 정상. 끝일 때만 깨진다. === HeaderValue 가 역슬래시를 허용하는가 === new HeaderValue("a\") -> OK, value=a\ # => 플랫폼 자신의 검증 타입이 이 입력을 통과시킨다. # HeaderValue 는 WireSafeText.require(...) 로 제어문자만 금지한다(HeaderValue.java:24). # 역슬래시는 제어문자가 아니다. # ---- 헤더 주입으로는 이어지지 않는다 (심각도 상한 확인) ---- # 예약 이름을 파싱 결과의 키로 밀어넣을 수 있는지 확인: json = {"x-evil":"v\\","msg.id":"00000000-0000-0000-0000-000000000000"} out = {x-evil=v\",, :=x-evil, v\",=00000000-0000-0000-0000-000000000000} key=[x-evil] reserved=false key=[:] reserved=false key=[v\",] reserved=false # => 어긋남이 키/값 경계를 밀어내므로 msg.id 는 키가 아니라 *값* 이 된다. 예약 키는 생성되지 않았다. # 또한 쓰기 경로가 애초에 막는다: new HeaderName("msg.id") -> OK MessageHeaders.application({msg.id}) -> REJECTED: message header is not allowed: msg.id # => 데이터 손상 결함이며 헤더 주입 취약점은 아니다. # ---- 영향 경로 ---- # JdbcOutboxRepository.read(...) -> fromJson(headers 컬럼) # -> OutboxRecord.headers() # -> OutboxEnvelopeFactory.toEnvelope(...):61-79 (예약 이름이면 throw, 아니면 그대로 통과) # -> MessageHeaders.platform(headers) -> 브로커로 발행 # case 4 의 깨진 키들은 예약 이름이 아니므로 throw 되지 않고 그대로 나간다. # CDC 경로(DebeziumOutboxRecordMapper:59-61)는 예약 이름을 throw 대신 제거하므로 # 마찬가지로 조용히 통과한다. # ---- 테스트가 덮지 않는다 ---- # command: grep -rn "escape|backslash" messaging-outbox-jdbc-postgresql/src/test # OutboxPostgresIT.java:547 "// A tab and a newline in a header value. Only backslash and quote # were escaped, so ..." # => 제어문자 케이스만 테스트한다. 역슬래시로 *끝나는* 값에 대한 왕복 테스트가 없다.