들어가며
“정상적인 클라이언트만 온다고 가정하면, 그건 보안이 아니라 희망사항이다.”
7편에서 기능적 버그를 수정했지만, QA 심화 리뷰에서 보안 관점의 취약점 3건이 추가로 발견됐다. 이번에는 악의적인 입력을 전제로 한 공격 시나리오다.
- [CRITICAL] Memory Bomb DoS — 프로토콜 length 스푸핑으로 OOM
- [MAJOR] 힌트 인젝션 — 문자열 리터럴 내
/* route:writer */ - [MAJOR] 키워드 오탐 — 문자열 리터럴 내 SQL 키워드
🔥 CRITICAL: Memory Bomb DoS
문제
PG wire protocol의 모든 메시지는 [type:1byte][length:4bytes][payload] 구조다. ReadMessage()는 length를 읽고 그만큼 make([]byte, length-4)로 버퍼를 할당한다:
// Before: length에 상한 제한 없음
payload := make([]byte, length-4)
공격자가 length에 1073741824 (1GB)를 보내면?
→ make([]byte, 1GB)
→ OS가 1GB 메모리 할당
→ OOM 패닉 → 프록시 크래시
인증 전에도 악용 가능하다. TCP 연결만 맺으면 첫 메시지에서 바로 공격할 수 있다.
수정
MaxMessageSize 상수(16MB)를 추가하고, make() 전에 검사:
const MaxMessageSize = 16 * 1024 * 1024 // 16MB
func ReadMessage(r io.Reader) (*Message, error) {
// ... type, length 읽기 ...
payloadLen := int(length - 4)
if payloadLen > MaxMessageSize {
return nil, fmt.Errorf("message too large: %d bytes (max %d)",
payloadLen, MaxMessageSize)
}
payload := make([]byte, payloadLen)
// ...
}
16MB는 PostgreSQL의 기본 max_allowed_packet과 유사한 수준이다. 정상적인 쿼리가 16MB를 넘는 경우는 거의 없고, 넘더라도 프록시가 아닌 직접 연결을 사용하면 된다.
왜 ReadStartupMessage은 이미 안전한가?
func ReadStartupMessage(r io.Reader) (*Message, error) {
// ...
if length < 4 || length > 10000 { // ← 이미 10KB 제한
return nil, fmt.Errorf("invalid startup message length: %d", length)
}
startup 메시지는 처음 만들 때부터 10KB 상한이 있었다. 하지만 이후의 일반 메시지(ReadMessage)에는 적용하지 않았던 것이 빈틈이었다.
🚨 MAJOR: 힌트 인젝션
문제
프록시는 /* route:writer */ 힌트 주석으로 강제 라우팅을 지원한다. 문제는 extractHint()가 SQL 문자열 리터럴 내부의 힌트도 감지한다는 것:
SELECT * FROM users WHERE note = '/* route:writer */ trick'
정규식이 쿼리 전체를 스캔하므로, 따옴표 안의 /* route:writer */도 매칭된다. 결과적으로 reader 쿼리가 writer로 라우팅된다.
공격 시나리오
악의적인 사용자가 모든 SELECT에 '/* route:writer */' 문자열을 넣으면:
- 모든 읽기 쿼리가 writer(Primary)로 몰림
- reader(Replica) 유휴, writer 과부하
- 사실상 R/W 분산 무효화
수정
힌트 검사 전에 문자열 리터럴을 제거하는 stripStringLiterals() 유틸리티를 추가:
func stripStringLiterals(query string) string {
var result strings.Builder
inSingle, inDouble := false, false
for i := 0; i < len(query); i++ {
ch := query[i]
switch {
case ch == '\'' && !inDouble:
result.WriteByte(ch)
if inSingle {
if i+1 < len(query) && query[i+1] == '\'' {
result.WriteByte('\'')
i++ // escaped quote ('')
} else {
inSingle = false
}
} else {
inSingle = true
}
case ch == '"' && !inSingle:
result.WriteByte(ch)
inDouble = !inDouble
case inSingle || inDouble:
// 따옴표 내부 콘텐츠 스킵
default:
result.WriteByte(ch)
}
}
return result.String()
}
적용 전후:
입력: SELECT * FROM users WHERE note = '/* route:writer */ trick'
변환: SELECT * FROM users WHERE note = ''
→ 힌트 매칭 실패 → QueryRead ✓
PostgreSQL의 escaped quote ('')도 올바르게 처리한다:
입력: SELECT 'it''s fine'
변환: SELECT ''''
🚨 MAJOR: 키워드 오탐
문제
containsWriteKeyword()와 extractCTETables()는 쿼리 텍스트에서 SQL 키워드를 직접 검색한다. 문자열 리터럴 내부도 예외 없이 스캔하므로:
-- 1) false cache invalidation
SELECT * FROM logs WHERE action = 'INSERT INTO admin_table'
→ "admin_table" 캐시 무효화 (오탐)
-- 2) false table extraction
WITH x AS (SELECT * FROM a WHERE b = 'INSERT INTO oops') SELECT 1
→ "oops')" 테이블 추출 (오탐 + 파싱 깨짐)
실제 영향
- 캐시 히트율 저하: 무관한 테이블이 무효화되어 캐시 효과 감소
- 잘못된 분류: SELECT 쿼리가
QueryWrite로 분류될 수 있음 (CTE 경로) - 메트릭 왜곡: writer/reader 카운터가 실제와 불일치
수정
힌트 인젝션과 동일한 stripStringLiterals()를 적용:
func containsWriteKeyword(query string) bool {
upper := strings.ToUpper(stripStringLiterals(query))
// ... 기존 word boundary 검사 ...
}
func extractCTETables(query string) []string {
sanitized := stripStringLiterals(query)
upper := strings.ToUpper(sanitized)
// ... 기존 keyword 스캔 ...
}
stripStringLiterals는 따옴표 자체는 유지하고 내용만 제거하므로, 문자열 위치나 길이가 바뀌어도 키워드 검색에는 영향이 없다.
공통 패턴: 전처리 → 분석
세 함수 모두 같은 패턴을 따른다:
원본 쿼리
↓ stripStringLiterals() ← 문자열 리터럴 제거
↓ extractHint() / containsWriteKeyword() / extractCTETables()
↓ 분석 결과
이 전처리 단계는 splitStatements()의 따옴표 추적 로직과 동일한 원리다. SQL 파서 없이 안전하게 분석하려면 따옴표 경계를 먼저 처리해야 한다는 교훈이다.
배운 점
- 프로토콜 레벨 방어는 필수 — 네트워크에서 들어오는 데이터는 length 필드까지 포함해서 전부 의심해야 한다.
ReadStartupMessage는 방어가 있었지만ReadMessage에는 없었다. - 문자열 리터럴은 SQL 분석의 지뢰 — 완전한 SQL 파서 없이 정규식으로 분석하면, 따옴표 내부가 반드시 문제를 일으킨다.
stripStringLiterals같은 전처리가 최소한의 방어선이다. - 공격자 관점으로 리뷰하라 — “정상적인 사용자"만 상정한 코드 리뷰로는 이런 취약점을 놓친다. “이 입력값을 내가 제어할 수 있다면?” 관점이 필요하다.
- 유틸리티 하나가 여러 버그를 막는다 —
stripStringLiterals라는 단일 함수가 힌트 인젝션, 키워드 오탐, CTE 파싱 오류를 동시에 해결했다.
프로젝트 소스코드: github.com/jyukki97/pgmux
💬 댓글