[Web] Web · 작성: 2026-07-19 19:15:17 · 수정: 2026-07-21 22:53:06 · 조회 26
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 안에서 실제로 무슨 일을 겪는가"**다.
better-sqlite3는 결국 C로 작성된 SQLite 코어(sqlite3.c)를 그대로 감싼 바인딩이다. db.prepare(sql)을 호출하면 내부적으로 sqlite3_prepare_v2()가 호출되고, SQLite 엔진 안에서 다음 파이프라인을 탄다.
tokenize.c) — SQL 문자열을 SELECT, FROM, 식별자, 리터럴, 연산자 같은 토큰 스트림으로 쪼갠다. 이 단계에서 '1'='1'은 문자열 리터럴 토큰이 아니라 방금 조립된 SQL 문자열의 일부로서 새로 토큰화된다 — 즉 OR, '1', =, '1'이 각각 독립된 문법 요소로 인식된다는 게 핵심이다. 원래 개발자가 짠 SQL과 공격자가 넣은 데이터 사이에 어떤 경계도 없다.parse.y, Lemon 파서가 생성한 LALR 파서) — 토큰 스트림을 문법 규칙에 맞춰 AST(추상 구문 트리)로 만든다. WHERE username = '' OR '1'='1'은 이 단계에서 (username = '') OR ('1' = '1')이라는 정상적인 이항 논리 표현식 트리로 파싱된다. 파서 입장에서는 이게 공격 페이로드인지 원래 조건절인지 구분할 방법이 전혀 없다 — 문법적으로 100% 유효한 SQL이기 때문이다.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처럼 생겨도 물리적으로 명령 채널에 도달할 수 없다. 이스케이프나 블랙리스트 필터링이 "운이 좋으면 막히는" 수준이라면, 파라미터화는 파이프라인 구조상 원천 차단이라는 차이가 여기서 나온다.
POST /login에 username=' OR '1'='1' -- 전송body-parser로 폼 데이터를 파싱, req.body.username에 저 문자열이 그대로 담김db.prepare(sql) 호출OR '1'='1'이 진짜 WHERE 조건의 일부로 파싱됨Or 조건을 평가 → 모든 로우가 조건을 만족users 테이블의 첫 번째 로우(보통 id=1, 관리자 계정)가 반환됨bcrypt.compareSync(password, user.password_hash)는 어차피 통과 못 하지만, 애초에 if (!user || !passwordOk) 로직이 --로 뒷부분(AND password = ...)을 통째로 주석 처리해버렸기 때문에 패스워드 검증 조건 자체가 쿼리에서 사라진 상태로 로우를 가져온 게 핵심 — 이후 코드에서 user가 존재한다는 사실만으로 인증을 통과시키는 로직이 있다면 그대로 뚫린다Union-based로 users 테이블의 password_hash를 통째로 뽑아내면(' UNION SELECT id, username, password_hash, created_at FROM users --), bcrypt 해시라 즉시 크랙은 어렵지만 다른 서비스에서 같은 비밀번호를 재사용하는 사용자를 노린 크리덴셜 스터핑 공격의 초기 데이터셋이 된다.
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는 기본적으로 멀티 스테이트먼트 실행을 막아두지만, 드라이버·설정에 따라 이 체인이 실제로 성립하는 스택이 많다.)
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는 기본값이 비활성화라 이 블로그 자체는 이 경로로는 안전하다.)
DB 서버가 별도 호스트(MySQL/PostgreSQL)라면 xp_cmdshell(MSSQL), COPY ... TO PROGRAM(PostgreSQL), UDF(MySQL, 이 블로그의 다른 글 UDF 기반 DB 권한 상승 참고) 같은 기능으로 DB 프로세스 권한의 OS 명령 실행까지 이어질 수 있다. 여기에 DB 서버가 내부망의 다른 시스템에만 접근 권한이 있다면, SQLi로 얻은 셸을 발판 삼아 SSRF 없이도 곧바로 내부망 피벗이 가능해진다.
load_extension, xp_cmdshell, 파일시스템 쓰기 권한을 애초에 제거하면 위 체이닝의 마지막 단계 자체가 불가능해진다sqlmap -u "<url>" --batch로 자동 탐지하되, 어느 단계(Boolean/Time/Union/OOB)에서 판단했는지를 직접 이해하고 있어야 오탐을 줄일 수 있다SQL Injection이 "특수문자 필터링 문제"가 아니라 "명령과 데이터를 같은 파싱 파이프라인에 태웠는가"의 문제라는 관점으로 보면, 왜 유독 이 취약점 클래스가 20년 넘게 OWASP Top 10 단골인지, 그리고 왜 DB 엔진의 기능이 풍부할수록(확장 로딩, 파일 I/O 함수) SQLi 하나의 파급력이 기하급수적으로 커지는지가 같은 논리로 설명된다.