SQL 인젝션과 파라미터 바인딩
무엇이 뚫리는가
- 입력을 SQL 문자열에 이어 붙이면 값이 아니라 구문으로 해석된다
OR 1=1 한 조각으로 WHERE 조건이 무력화되고 테이블 전체가 응답에 실린다
UNION SELECT로 다른 테이블(비밀번호 해시, 결제 정보)까지 끌어올 수 있다
방어 우선순위
| 순서 | 방법 | 비고 |
|---|
| 1 | 파라미터 바인딩(PreparedStatement) | 값과 구문을 분리 |
| 2 | ORM, 쿼리빌더의 바인딩 API | JPA, MyBatis #{} |
| 3 | 화이트리스트 검증 | 바인딩 불가한 자리 전용 |
| 4 | 최소 권한 DB 계정 | 뚫려도 피해 범위 축소 |
자리표시자를 쓸 수 없는 곳
값은 자리표시자로 넘길 수 있습니다. 그런데 값이 아닌 것은 못 넘깁니다.
| 넘길 수 있나 | 예 |
|---|
| 값 | 조건에 들어가는 이름, 번호 |
| 표나 열 이름 | 못 넘긴다 |
| 정렬 방향 | 못 넘긴다 |
| 조건 개수가 변하는 목록 | 개수만큼 자리표시자를 만들어야 한다 |
가운데 둘이 사고가 납니다. 정렬 열을 사용자 입력으로 받아 문자열에 이어 붙이는 경우입니다.
그때는 허용 목록으로 다룹니다. 미리 정해 둔 이름 중 하나인지 확인하고, 아니면 거절합니다.
걸러내는 것이 아니라 허용한 것만 통과시키는 방향입니다.
권한이 방어의 마지막 겹이다
질의를 안전하게 만들어도 그 계정이 무엇이든 할 수 있으면 피해가 큽니다.
읽기만 하는 경로는 읽기 전용 계정으로
표를 지우거나 만드는 권한은 애플리케이션 계정에 두지 않는다
뚫렸을 때의 피해 범위를 줄이는 것이라 앞의 방어와 성격이 다릅니다. 둘을 함께 둡니다.
실무 포인트
- 바인딩이 안 되는 자리가 있다: 테이블과 컬럼명,
ORDER BY 컬럼은 값이 아니라 식별자다 → 화이트리스트로만 처리
- MyBatis
${}는 문자열 치환, #{}가 바인딩이다. 코드 리뷰에서 ${} 검색은 필수
- 이스케이프 함수를 직접 만들지 않는다. 인코딩, 주석, 다중바이트 우회를 못 막는다
- DB 에러 메시지를 그대로 응답에 노출하면 스키마와 드라이버 정보가 유출된다
Q.레거시 코드에서 문자열로 조합된 쿼리를 발견했다. 어떤 순서로 조치하겠습니까?
전부 고치기 전에 피해 범위를 좁히는 조치를 먼저 넣습니다.
| 순서 | 조치 | 이유 |
|---|
| 1 | 사용자 입력이 닿는 경로부터 찾는다 | 내부 상수만 조합한 쿼리는 급하지 않다 |
| 2 | DB 계정 권한을 줄인다 | 뚫려도 읽기만, 필요한 테이블만 |
| 3 | 오류 메시지를 감춘다 | 쿼리와 스키마 노출을 막는다 |
| 4 | 위험한 경로를 바인딩으로 바꾼다 | 값 자리를 완전히 막는다 |
| 5 | 식별자 자리는 허용 목록으로 | 컬럼명과 정렬 방향은 바인딩이 안 된다 |
| 6 | 정적 분석을 CI 에 넣는다 | 재발을 막는다 |
1번을 먼저 하는 이유는 레거시가 크면 전수 수정에 시간이 걸리고, 그동안 노출이 계속되기 때문입니다. 2번과 3번은 코드를 거의 안 건드리고 당일 적용할 수 있습니다.
흔한 실수: 전부 한 번에 바꾸겠다고 답하는 것. 규모가 크면 배포 위험이 커지고 그 사이 방치됩니다. 완화와 수정을 나누어 말하는 것이 실무 판단입니다.
Q.정렬 컬럼을 사용자가 고르는 목록 API는 왜 파라미터 바인딩만으로 안전해지지 않나요?
바인딩은 값 자리에만 쓸 수 있고 정렬 대상은 값이 아니라 쿼리 구조의 일부이기 때문입니다.
SELECT * FROM products WHERE category = ? -- 값. 바인딩 된다
ORDER BY ? -- 식별자. 되지 않는다
DB는 실행 계획을 세우기 전에 어떤 컬럼으로 정렬할지 알아야 합니다. 값은 나중에 채워도 되지만 구조는 미리 확정돼야 합니다.
그래서 허용 목록으로 처리합니다.
| 목록 | 내용 |
|---|
| 허용 컬럼 | name, price, created 를 각각 실제 컬럼명에 대응시킨다 |
| 허용 방향 | asc 와 desc 만 받아 ASC, DESC 로 바꾼다 |
| 목록에 없으면 | 기본값으로 대체하거나 400 으로 거부한다 |
흔한 실수: 컬럼명을 이스케이프하거나 특수문자만 걸러 통과시키는 것. 우회 가능성이 남습니다. 입력을 통과시키지 않고 미리 정한 값으로 바꿔치기하는 것이 안전합니다.
Q.ORM을 쓰면 SQL 인젝션이 원천 차단된다고 볼 수 있나요?
아닙니다. ORM은 기본 경로를 안전하게 만들어 주지만 빠져나가는 통로가 있습니다.
| 통로 | 예 |
|---|
| 원시 쿼리 | createNativeQuery, raw, $queryRaw 로 문자열을 조합 |
| 식별자 지정 | 정렬 컬럼, 테이블명을 동적으로 넣는 API |
| 조건 조립 문법 | 일부 ORM 의 where 문자열 표현식 |
| 함수와 연산자 이름 | 집계 함수나 연산자를 문자열로 받는 경우 |
ORM 을 쓰는 팀에서 사고가 나는 자리는 대개 성능 튜닝이나 복잡한 통계 쿼리 때문에 원시 쿼리로 내려간 곳입니다.
흔한 실수: ORM 도입을 대책으로 답하고 끝내는 것. 원시 쿼리 사용 지점을 목록으로 관리하고 그 부분만 정적 분석이나 리뷰로 지키는 것이 실제 대책입니다. 그리고 ORM 은 인젝션과 별개로 접근 제어를 해주지 않습니다.
Q.인젝션이 이미 발생했다고 가정할 때, 피해를 줄이기 위한 설계 장치는 무엇이 있나요?
방어가 뚫린 뒤에도 피해를 줄이는 장치를 미리 둡니다.
| 장치 | 줄이는 피해 |
|---|
| DB 계정 권한 최소화 | 읽기 전용 계정이면 데이터 변조와 삭제를 막는다 |
| 테이블과 컬럼 단위 권한 | 결제 정보 테이블에 접근 자체를 막는다 |
| 민감 컬럼 암호화 | 덤프가 나가도 평문을 얻지 못한다 |
| 쿼리 로그와 이상 탐지 | 평소와 다른 대량 조회를 잡는다 |
| 응답 크기 제한과 호출 한도 | 한 번에 전체를 빼가는 것을 늦춘다 |
| 오류 메시지 은닉 | 스키마 파악을 어렵게 한다 |
핵심 발상은 뚫린다는 가정 아래 설계하는 것입니다. 애플리케이션이 필요한 최소 권한만 가지면 인젝션의 결과가 "그 계정이 할 수 있는 일"로 제한됩니다.
흔한 실수: 방어 하나로 끝났다고 보는 것. 바인딩을 했더라도 라이브러리 취약점이나 놓친 경로가 있을 수 있어, 피해 축소 장치가 함께 있어야 합니다.
먼저 스스로 답해보고 아래 답변과 견줘보세요. 막히는 부분은 문제로 확인할 수 있어요.
읽었으면 문제로 확인해보세요
보안 문제를 풀면 틀린 문제가 자동으로 노트에 쌓입니다. 가입 없이 5문제를 먼저 풀어볼 수도 있어요.