← 목록으로

SQL Injection: 원리, 탐지, 방어

[Web] Web · 작성: 2026-07-19 19:15:17 · 수정: 2026-07-21 22:53:06 · 조회 25

SQL Injection을 "따옴표 하나 넣으면 뚫리는 취약점"으로만 알고 있으면, 왜 파라미터화된 쿼리는 절대 안전한지, 왜 어떤 DB는 SQLi 한 방으로 파일 시스템까지 내줘 버리는지를 설명할 수 없다. 이 글은 이 블로그(Node/Express + better-sqlite3)를 실제 예제 삼아, SQL 파서 내부부터 DB 엔진이 그 문자열을 "명령"으로 착각하는 정확한 지점, 그리고 거기서 시작해 서버 전체 장악까지 이어지는 체이닝을 끝까지 따라간다.

소스 레벨: 왜 문자열 결합이 위험한가

이 블로그의 로그인 라우트(routes/auth.js)는 이렇게 되어 있다.

const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);

?플레이스홀더다. 만약 이걸 아래처럼 문자열 결합으로 짰다면 어떻게 됐을지 비교해보자.

// 취약한 버전 (실제 코드 아님, 비교용)
const sql = `SELECT * FROM users WHERE username = '${username}'`;
const user = db.prepare(sql).get();

username' OR '1'='1' --를 넣으면 db.prepare()가 받는 최종 문자열은:

SELECT * FROM users WHERE username = '' OR '1'='1' --'

여기서 중요한 건 "왜 인증이 뚫리는가"가 아니라 **"이 문자열이 SQLite 안에서 실제로 무슨 일을 겪는가"**다.

커널(엔진) 레벨: SQLite가 SQL 문자열을 처리하는 4단계

better-sqlite3는 결국 C로 작성된 SQLite 코어(sqlite3.c)를 그대로 감싼 바인딩이다. db.prepare(sql)을 호출하면 내부적으로 sqlite3_prepare_v2()가 호출되고, SQLite 엔진 안에서 다음 파이프라인을 탄다.

  1. Tokenizer (tokenize.c) — SQL 문자열을 SELECT, FROM, 식별자, 리터럴, 연산자 같은 토큰 스트림으로 쪼갠다. 이 단계에서 '1'='1'문자열 리터럴 토큰이 아니라 방금 조립된 SQL 문자열의 일부로서 새로 토큰화된다 — 즉 OR, '1', =, '1'이 각각 독립된 문법 요소로 인식된다는 게 핵심이다. 원래 개발자가 짠 SQL과 공격자가 넣은 데이터 사이에 어떤 경계도 없다.
  2. Parser (parse.y, Lemon 파서가 생성한 LALR 파서) — 토큰 스트림을 문법 규칙에 맞춰 AST(추상 구문 트리)로 만든다. WHERE username = '' OR '1'='1'은 이 단계에서 (username = '') OR ('1' = '1')이라는 정상적인 이항 논리 표현식 트리로 파싱된다. 파서 입장에서는 이게 공격 페이로드인지 원래 조건절인지 구분할 방법이 전혀 없다 — 문법적으로 100% 유효한 SQL이기 때문이다.
  3. Code Generator (select.c, where.c) → VDBE 바이트코드 — AST를 SQLite의 가상 머신인 **VDBE(Virtual DataBase Engine)**가 실행할 바이트코드로 컴파일한다. EXPLAIN으로 실제로 눈으로 볼 수 있다.
sqlite> EXPLAIN SELECT * FROM users WHERE username = '' OR '1'='1';
0   Init           0   9   0
1   OpenRead       0   2   0   2       0
2   Rewind         0   8   0
3   Column         0   1   1
4   Ne             5   6   1
5   Integer        1   7   0
6   ...
7   Or             ...
8   IfNot          7   ...  jump to Next
9   ResultRow      ...

Or opcode, Ne(not-equal) opcode가 보이는가 — 공격자가 넣은 '1'='1'VDBE 레지스터 비교 연산으로 그대로 컴파일되어, 매 로우마다 실제로 평가된다. 즉 SQLi는 "SQL 엔진의 버그"가 아니라, 엔진이 자기 할 일(파싱→컴파일→실행)을 완벽하게 정상적으로 수행한 결과다. 문제는 애플리케이션이 "명령"과 "데이터"를 같은 채널(SQL 문자열)로 엔진에 넘겼다는 것뿐이다.

파라미터화된 쿼리는 왜 이 파이프라인 자체를 건드리지 못하게 만드는가

db.prepare('SELECT * FROM users WHERE username = ?')를 호출하는 순간, SQLite는 ?가 포함된 SQL만으로 1~3단계(토크나이즈→파싱→코드생성)를 미리 끝내고 VDBE 바이트코드를 확정해버린다. 이때 생성되는 바이트코드에는 ? 자리에 OP_Variable이라는 opcode가 들어간다.

sqlite> EXPLAIN SELECT * FROM users WHERE username = ?;
...
3   Variable       1   1   0        ; r[1] = parameter(1)
4   Ne             5   ...

이후 .get(username)으로 실제 값을 바인딩하면, sqlite3_bind_text()가 그 문자열을 OP_Variable이 참조하는 레지스터에 순수 데이터(바이트 배열)로 꽂아넣을 뿐이다. 이 값은 다시는 토크나이저를 거치지 않는다. ' OR '1'='1이라는 문자열을 통째로 넣어도 SQLite는 그걸 "82바이트짜리 사용자명 데이터"로만 보지, 그 안의 OR, ', = 같은 글자를 SQL 문법 기호로 재해석하지 않는다. 명령(바이트코드)이 이미 컴파일 완료된 상태에서 데이터가 뒤늦게 주입되기 때문에, 데이터가 아무리 SQL처럼 생겨도 물리적으로 명령 채널에 도달할 수 없다. 이스케이프나 블랙리스트 필터링이 "운이 좋으면 막히는" 수준이라면, 파라미터화는 파이프라인 구조상 원천 차단이라는 차이가 여기서 나온다.

페이로드가 서버에서 실제로 작동하는 순서 (엔드투엔드)

  1. 공격자가 POST /loginusername=' OR '1'='1' -- 전송
  2. Express가 body-parser로 폼 데이터를 파싱, req.body.username에 저 문자열이 그대로 담김
  3. (취약한 버전이라면) 이 문자열이 SQL 템플릿에 결합되어 db.prepare(sql) 호출
  4. SQLite 파서가 문자열을 새로 토크나이즈 → OR '1'='1'이 진짜 WHERE 조건의 일부로 파싱됨
  5. VDBE가 테이블을 풀 스캔하며 각 로우에 대해 Or 조건을 평가 → 모든 로우가 조건을 만족
  6. users 테이블의 첫 번째 로우(보통 id=1, 관리자 계정)가 반환됨
  7. bcrypt.compareSync(password, user.password_hash)는 어차피 통과 못 하지만, 애초에 if (!user || !passwordOk) 로직이 --로 뒷부분(AND password = ...)을 통째로 주석 처리해버렸기 때문에 패스워드 검증 조건 자체가 쿼리에서 사라진 상태로 로우를 가져온 게 핵심 — 이후 코드에서 user가 존재한다는 사실만으로 인증을 통과시키는 로직이 있다면 그대로 뚫린다

여기서 끝나지 않는다: SQLi 체이닝

1) 데이터 유출 → 크리덴셜 스터핑

Union-based로 users 테이블의 password_hash를 통째로 뽑아내면(' UNION SELECT id, username, password_hash, created_at FROM users --), bcrypt 해시라 즉시 크랙은 어렵지만 다른 서비스에서 같은 비밀번호를 재사용하는 사용자를 노린 크리덴셜 스터핑 공격의 초기 데이터셋이 된다.

2) SQLite ATTACH DATABASE → 임의 파일 쓰기 → 웹쉘

SQLite는 별도의 DB 파일을 런타임에 붙일 수 있는 ATTACH DATABASE 구문을 지원한다. 만약 애플리케이션이 SQLi로 임의 SQL을 여러 문장 실행할 수 있는 지점(예: 세미콜론으로 여러 statement를 이어붙일 수 있는 드라이버/설정)이라면:

ATTACH DATABASE '/Users/martina/webapp/public/shell.php' AS shell;
CREATE TABLE shell.pwn (data TEXT);
INSERT INTO shell.pwn (data) VALUES ('<?php system($_GET["c"]); ?>');

SQLite가 /Users/martina/webapp/public/ 밑에 웹 서버 프로세스 권한으로 새 파일을 생성한다 — 이건 SQLite 엔진이 open()/write() 시스템콜을 그대로 호출하는 파일시스템 계층의 동작이라, DB 안이 아니라 OS 파일시스템에 직접 쓰기가 일어난다. 정적 파일을 서빙하는 경로(public/)에 이렇게 파일을 심으면 그 자체로 원격 코드 실행 웹쉘이 완성된다. (better-sqlite3는 기본적으로 멀티 스테이트먼트 실행을 막아두지만, 드라이버·설정에 따라 이 체인이 실제로 성립하는 스택이 많다.)

3) load_extension() → 네이티브 코드 실행 (가장 깊은 체이닝)

SQLite는 .so/.dll 확장 모듈을 런타임에 로드하는 load_extension() API를 제공한다. 이게 활성화된 환경(enableLoadExtension)에서 SQLi로 이 함수를 호출할 수 있다면:

SELECT load_extension('/tmp/evil.so', 'entrypoint');

이 한 줄이 SQLite 엔진 내부에서 OS의 dlopen()/LoadLibrary() 시스템 콜을 그대로 실행한다. dlopen은 지정된 공유 라이브러리 파일을 프로세스의 주소 공간에 매핑하고, 지정된 심볼(entrypoint)을 찾아 네이티브 머신 코드로 직접 점프해서 실행한다 — SQL 인젝션이라는 "웹 취약점"이 SQL 파서 → VDBE → C 함수 호출 → 동적 링커(dlopen) → 커널의 mmap/execve 계열 시스템 콜까지 정확히 한 줄로 관통해서, 애플리케이션 코드와 완전히 무관한 **임의 네이티브 코드 실행(RCE)**으로 끝나는 지점이다. (그래서 프로덕션에서는 load_extension을 반드시 비활성화해야 한다 — better-sqlite3는 기본값이 비활성화라 이 블로그 자체는 이 경로로는 안전하다.)

4) SSRF/내부망 피벗과의 조합

DB 서버가 별도 호스트(MySQL/PostgreSQL)라면 xp_cmdshell(MSSQL), COPY ... TO PROGRAM(PostgreSQL), UDF(MySQL, 이 블로그의 다른 글 UDF 기반 DB 권한 상승 참고) 같은 기능으로 DB 프로세스 권한의 OS 명령 실행까지 이어질 수 있다. 여기에 DB 서버가 내부망의 다른 시스템에만 접근 권한이 있다면, SQLi로 얻은 셸을 발판 삼아 SSRF 없이도 곧바로 내부망 피벗이 가능해진다.

탐지와 방어 (요약)

SQL Injection이 "특수문자 필터링 문제"가 아니라 "명령과 데이터를 같은 파싱 파이프라인에 태웠는가"의 문제라는 관점으로 보면, 왜 유독 이 취약점 클래스가 20년 넘게 OWASP Top 10 단골인지, 그리고 왜 DB 엔진의 기능이 풍부할수록(확장 로딩, 파일 I/O 함수) SQLi 하나의 파급력이 기하급수적으로 커지는지가 같은 논리로 설명된다.

Web 카테고리의 글 (1/12)

  1. SQL Injection: 원리, 탐지, 방어
  2. XSS (Cross-Site Scripting): Stored / Reflected / DOM 차이와 방어
  3. CSRF (Cross-Site Request Forgery): 원리와 방어
  4. IDOR / BOLA: 권한 검증이 빠졌을 때
  5. SSRF (Server-Side Request Forgery): 서버가 대신 요청하게 만들기
  6. 파일 업로드 취약점: 웹쉘로 이어지는 경로
  7. 안전하지 않은 역직렬화 (Insecure Deserialization)
  8. SSTI (Server-Side Template Injection): 원리와 탐지
  9. OS Command Injection: 원리와 필터 우회
  10. JWT 취약점: alg=none부터 알고리즘 컨퓨전까지
  11. Race Condition (TOCTOU) 취약점
  12. Path Traversal / LFI (Local File Inclusion): 커널 경로 탐색부터 RCE 체이닝까지
XSS (Cross-Site Scripting): Stored / Reflected / DOM 차이와 방어 →