SQL Injection이란 무엇이고 어떻게 방지할까?
SQL 주입 공격의 실제 분석, 구체적인 예제, 그리고 프로덕션 코드에서 실제로 작동하는 방어 방법.
SQL injection은 1990년대 후반부터 시작되었으며 지금도 웹 애플리케이션이 침해당하는 가장 흔한 방식 중 하나다. 버그는 단순하다: 공격자가 보낸 입력이 일반 데이터가 아닌 SQL 코드로 해석되고, 데이터베이스가 본래 의도하지 않은 작업을 수행하는 것이다.
공격이 실제로 어떻게 작동하는가
PHP에서 다음과 같이 쿼리를 작성하는 로그인 폼을 상상해보자:
$query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
앱이 입력을 살균하지 않으면 공격자는 username 필드에 admin' --을 입력한다. 쿼리는 다음과 같이 된다:
SELECT * FROM users WHERE username = 'admin' --' AND password = ''
--는 줄의 나머지를 주석처리하므로 비밀번호 검사가 일어나지 않는다. 이것이 전형적인 인증 우회다. 공격자들은 UNION SELECT를 사용해 다른 테이블에서 데이터를 끌어오거나, 세미콜론으로 여러 쿼리를 연결해 드라이버가 허용한다면 DROP TABLE users; 같은 파괴적인 작업을 실행할 수도 있다.
Blind SQL injection도 있는데, 앱이 쿼리 결과를 직접 표시하지 않는 경우다. 공격자들은 타이밍(MySQL의 SLEEP(5))이나 boolean 응답(참 조건과 거짓 조건에서 페이지가 다르게 작동하는지)을 통해 정보를 추론한다. sqlmap 같은 도구는 취약한 파라미터를 찾으면 이런 종류의 추출을 자동화한다.
문자열 연결이 근본 문제인 이유
이 공격의 모든 변형은 한 가지로 돌아간다: 코드와 데이터를 같은 문자열로 섞는 것이다. 데이터베이스는 정당한 값과 주입된 문법의 차이를 구분할 수 없다. 둘 다 같은 채널을 통해 들어오기 때문이다. 따옴표를 이스케이프하는 것은 경우에 따라 도움이 되지만 취약하다 — 다양한 인코딩, 2단계 주입(데이터가 한 번 저장된 후 나중에 안전하지 않게 재사용되는 경우), 드라이버별 이상들이 모두 우회 경로를 만든다. 이스케이프는 패치일 뿐 해결책이 아니다.
매개변수화된 쿼리가 진정한 해결책이다
해결책은 매개변수화된 쿼리(준비된 명령문이라고도 불림)를 사용해 드라이버 레벨에서 SQL 코드와 사용자 데이터를 분리하는 것이다. 데이터베이스는 먼저 쿼리 구조를 받고 나중에 값을 바인딩하므로 사용자 입력이 쿼리의 의미를 절대 바꿀 수 없다.
Python의 psycopg2에서:
cur.execute("SELECT * FROM users WHERE username = %s AND password = %s", (username, password))
Node.js의 mysql2에서:
connection.execute('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]);
Java의 JDBC에서:
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE username = ? AND password = ?");
stmt.setString(1, username);
stmt.setString(2, password);
패턴을 보면: 플레이스홀더(%s, ?)가 데이터의 자리를 차지하고 실제 값들은 따로 전달된다. 문자열 연결도 없고 수동 이스케이프도 필요 없다. 이것은 일반적인 애플리케이션에서 작성할 쿼리의 대다수에 작동한다.
동적 테이블이나 컬럼 이름은 어떻게 할까?
매개변수화는 값을 다루지만 식별자를 다루지 않는다 — 테이블 이름을 매개변수로 바인딩할 수 없다. 앱이 테이블을 동적으로 선택해야 한다면(드물고 종종 설계 문제의 신호), 사용자 입력을 직접 신뢰하지 말고 허드렛값된 목록에 대해 허용된 값을 화이트리스트로 지정하라:
allowed_tables = {'orders', 'invoices', 'customers'}
if table_name not in allowed_tables:
raise ValueError("Invalid table")
사용자 입력에서 이스케이프를 사용하더라도 절대 문자열 포매팅을 통해 식별자 이름을 구성하지 말아야 한다.
쿼리 자체를 넘어선 계층화된 방어
매개변수화된 쿼리가 주요 통제지만 몇 가지가 더 중요하다:
- 데이터베이스 계정의 최소 권한. 앱의 DB 사용자는
DROP,ALTER또는 관련 없는 스키마에 접근할 수 없어야 한다. 주입이 빠져나가더라도 제한된 권한이 피해를 제한한다. - ORM은 기본적으로 도움이 된다. Django ORM, SQLAlchemy, Hibernate는 모두 표준 쿼리 빌딩 메서드를 사용할 때 쿼리를 자동으로 매개변수화한다. 개발자들이 원시 SQL로 떨어지거나
.extra()/text()호출로 문자열 보간을 사용할 때 위험이 다시 나타난다 — 그 지점들을 특별히 감시하자. - 입력 검증은 보조 계층이지 대체물이 아니다. 이메일 필드가 이메일처럼 보이는지 확인하는 것은 좋은 관행이지만 그 자체로는 주입을 멈추지 못한다 — 공격자들은 느슨한 검증을 통과하는 창의적인 페이로드를 찾는다.
- WAF는 알려진 공격 패턴을 잡을 수 있지만 이것은 탐지 계층이지 근본적인 코드 해결책이 아니다.
자신의 코드에서 이 버그를 테스트하기
정적 분석 도구(Python의 Bandit, SQL 주입 규칙 세트가 포함된 Semgrep)를 통해 쿼리를 실행하자. CI의 일부로서 말이다. 수동 테스트를 위해 모든 입력 필드에 작은따옴표(')를 주입해보고 응답에서 SQL 에러 메시지가 새어나오는지 확인하자 — 이것은 종종 쿼리가 매개변수화되지 않았다는 첫 신호다.
이것에 대해 더 깊이 들어가고 싶다면, Korra Studio의 Web Security 트랙은 주입을 XSS와 인증 우회와 함께 다루고, Databases 섹션은 이런 종류의 버그를 피하는 쿼리 설계 패턴을 자세히 설명한다.
AI 도움을 받아 작성했으며, Michal Pilch(CISSP), Korra Studio에서 검토 및 게시했어요.
이것은 Korra Studio 나레지베이스의 한 노트예요. 플랫폼은 모든 주제를 1-to-1 멘토링과 함께 제공해요.
무료로 시작하기arrow_forward