자기 전에 한번 풀어보려고 쉬운 문제를 골랐는데 금방 풀 수 있어서 이렇게 블로그에 포스팅한다. 사실 webhacking.kr에 있는 문제들은 대부분 SQL 문제기 때문에 아직 해당 분야에 공부를 안 한 나로서는 풀기 버겁다. 그래서 PHP나 자바스크립트 문제 위주로 풀고 있는데 그 중에서도 100점짜리, 매우 낮은 단계의 문제를 풀어보았다.
사이트에 접속하면 이상한 주소의 client ip와 유저 에이전트 정보가 표시되고 Wrong IP! 라는 문자열이 사이트 가운데에 출력된다. 개발자 도구로 요청 헤더나 응답 헤더를 살펴봐도 특별한 점을 찾을 수 없는데 특이한 것은 테이블의 client ip에 뭔가 빠진듯한 IP 주소가 적혀 있다는 것이다. 좀 더 알아보기 위해 view-source를 눌러서 소스를 확인해보았다.
<?php
include "../../config.php";
if($_GET['view_source']) view_source();
?><html>
<head>
<title>Challenge 24</title>
</head>
<body>
<p>
<?php
extract($_SERVER);
extract($_COOKIE);
$ip = $REMOTE_ADDR;
$agent = $HTTP_USER_AGENT;
if($REMOTE_ADDR){
$ip = htmlspecialchars($REMOTE_ADDR);
$ip = str_replace("..",".",$ip);
$ip = str_replace("12","",$ip);
$ip = str_replace("7.","",$ip);
$ip = str_replace("0.","",$ip);
}
if($HTTP_USER_AGENT){
$agent=htmlspecialchars($HTTP_USER_AGENT);
}
echo "<table border=1><tr><td>client ip</td><td>{$ip}</td></tr><tr><td>agent</td><td>{$agent}</td></tr></table>";
if($ip=="127.0.0.1"){
solve(24);
exit();
}
else{
echo "<hr><center>Wrong IP!</center>";
}
?><hr>
<a href=?view_source=1>view-source</a>
</body>
</html>
소스는 다음과 같이 간단한 PHP 코드로 작성되어 있는 것을 볼 수 있었다. 먼저 PHP의 extract() 함수를 호출하고 있는데 이는 매개변수로 전달된 배열, 다른 언어에 비유하면 사전(dictionary) 값을 변수로 풀어주는 함수다. 다른 언어에서는 이런게 흔하지 않기 때문에 무슨 동작을 하는지 헷갈릴 수 있는데 간단히 말하면 다음과 같다.
$array['name'] = "Haruhi Suzumiya";
$array['age'] = 17;
$array['circle'] = "SOS-Dan";
extract($array);
echo $name."\n"; // Haruhi Suzumiya
echo $age."\n"; // 17
echo $circle."\n"; // SOS-Dan
예를 들어 위와 같은 배열이 있다고 할 때 이 배열에 대해 extract 함수를 호출하면 각 아이템들의 키가 변수 이름이 되고 값이 변수 값이 되어 실제 사용할 수 있는 변수로 생성된다. 문서의 표현을 빌리자면 배열에서 변수값을 얻어와서 현재 심볼 테이블에 임포트하는 것이다.
그러면 $_SERVER, $_COOKIE는 무엇을 의미할까? 이들은 PHP에서 사전 정의된 변수로 문서를 참고하면 서버 실행에 관련된 수많은 항목들이 포함된 배열을 반환한다. 이를 전부 다 살펴보는 것은 의미가 없고 현재 문제에서 참조하고 있는 변수인 $REMOTE_ADDR, $HTTP_USER_AGENT가 포함되어 있는지 확인해보자.
해당 항목이 존재하는 것을 확인할 수 있었다. $REMOTE_ADDR의 경우 해당 홈페이지를 접속하고 있는 사용자의 IP 주소이며 HTTP_USER_AGENT는 사용자의 웹 브라우저에 대한 정보를 나타낸다. $_SERVER가 extract() 함수에 의해 내부에 있는 값들이 모두 변수화되었으므로 지금 스크립트에는 $REMOTE_ADDR, $HTTP_USER_AGENT라는 변수가 존재하고 있으며 각각 접속한 사용자의 IP 주소, 웹 브라우저 정보가 담겨 있는 것이다. 그렇다면 이 정보로 무엇을 얻을 수 있을까?
echo "<table border=1><tr><td>client ip</td><td>{$ip}</td></tr><tr><td>agent</td><td>{$agent}</td></tr></table>";
if($ip=="127.0.0.1"){
solve(24);
exit();
}
지금 제일 눈여겨 봐야 할 것은 어느 부분에서 solve() 함수가 호출되어 해당 문제를 푸는가이다. 지금 $ip 변수가 "127.0.0.1"이면 문제를 풀었다고 인정하고 있는데 이 $ip 변수는 어디에서 생성될까? 바로 위에서 이를 찾을 수 있다.
if($REMOTE_ADDR){
$ip = htmlspecialchars($REMOTE_ADDR);
$ip = str_replace("..",".",$ip);
$ip = str_replace("12","",$ip);
$ip = str_replace("7.","",$ip);
$ip = str_replace("0.","",$ip);
}
만약 $REMOTE_ADDR 변수가 존재한다면, 즉 사용자의 ip 주소가 이 스크립트로 잘 전달되었다면 htmlspecialchars() 함수를 이용해 특수문자를 HTML 문자로 변환시켜준 후 이를 $ip 변수에 저장하고 있다. 그 후 4번의 str_replace() 함수 호출을 통해 문자열을 변형시키고 있는데 ".."은 "."으로, "12"는 ""으로, "7."은 ""으로, "0."는 ""으로 변환시키고 있다. 이는 $ip 변수가 "127.0.0.1"이 되지 못하게 하도록 하는 과정일 것이다. 왜냐면 이 문제를 풀기 위해서는 $ip 변수가 "127.0.0.1"이 되는 것을 요구하고 있기 때문이다.
그런데 일단 이 변환과정을 우회하는 건 둘째치고 어떻게 사용자의 IP 주소를 "127.0.0.1"로 설정할 수 있을까? 이는 $_COOKIE 함수가 $_SERVER 이후에 extract() 된다는 점에서 착안할 수 있다. $_COOKIE는 HTTP 쿠키를 통해 현재 스크립트로 전달된 변수들을 의미한다. 아무런 조작을 하지 않은 상태에서는 아래처럼 PHPSESSID라는 세션 ID만 쿠키값으로 존재한다.
이 쿠키에 REMOTE_ADDR이란 쿠키값을 추가하면 extract($_COOKIE)가 호출될 때 extract($_SERVER)로 생성한 $REMOTE_ADDR 변수를 쿠키의 $REMOTE_ADDR로 덮어씌움으로써 사용자 IP 주소를 조작할 수 있을 것이다. 그렇다면 어떤 쿠키값을 보내야 문자열 변환 로직을 우회할 수 있을까? 일단 하나하나 살펴보도록 하자.
$ip = str_replace("..",".",$ip);
$ip = str_replace("12","",$ip);
$ip = str_replace("7.","",$ip);
$ip = str_replace("0.","",$ip);
먼저 ".."을 ".'으로 바꾸는 부분은 일반적으로 IP 주소에 마침표가 두 개 이상 들어가는 일이 없기 때문에 불필요한 로직이라고 생각할 수 있지만 다른 로직을 우회할 때 걸림돌이 된다. 먼저 그냥 "127.0.0.1"을 변환시키면 어떻게 되는지 확인해보자(온라인 샌드박스에서 확인해볼 수 있다).
맨 뒷자리 1만 남고 전부 필터링된 것을 볼 수 있다. 어째서 이렇게 된 것일까? 먼저 "127.0.0.1"의 "12"가 삭제되고, "7.0.0.1"의 "7."이 삭제되고, "0.0.1"의 "0."이 삭제되어 맨 마지막 글자인 1만 남게 된 것이다. 이렇게 해당 문자열이 삭제되는데 이를 어떻게 우회할 수 있을까? 이는 바로 문자열의 왼쪽에서 오른쪽으로 한번만 탐색한다는 str_replace() 함수의 특징에 달려 있다.
"127.0.0.1" 대신 "11227.0.0.1"을 넣어보았더니 "121"로 "12"와 맨 마지막 "1"이 합쳐진 결과를 얻을 수 있었다. 왜 그럴까? "11227.0.0.1"은 "12"가 삭제되어 "127.0.0.1"이 된다. 하지만 이미 한번 문자열을 왼쪽부터 훑어서 변환이 완료됐기 때문에 추가적으로 존재할 수 있는 변환 대상(여기서는 "12")은 검사되지 않아 그대로 유지되는 것이다. 비슷하게 "7"을 살리고자 한다면 이를 두 번 쓰면 됀다.
ip 주소의 "."을 살리려면 어떻게 해야 할까? 현재 "7.", "0."을 삭제하고 있기 때문에 "77."을 "77.."처럼 쓰면 살릴 수 있지 않을까? 여기서 이제 맨 처음 str_replace()의 ".." 변환이 걸리게 된다. ".."는 "."으로 변환되며 이는 "77.."을 "77."을 변환시켜 버린다. 그래서 "7."은 삭제되어 버리는데 이를 우회하려면 ".."이 삭제되는 것이 아니라 "."으로 변환된다는 것에서 착안하여 ".."을 두 번 쓰면 된다. 즉 "77...."으로 생성하면 ".."이 "."으로 변환되어 "77.."이 되고, "7."이 삭제되어 "7."이 남게 된다.
비슷하게 "0."도 살리려면 "00...."으로 쓰면 된다. ".."이 "."으로 변경되면서 "00.."이 되고, "0."이 삭제되면서 "0."이 된다.
최종적으로는 위처럼 "112277....00....00....1"을 쿠키값으로 넘기면 "127.0.0.1"로 변환되기 때문에 $ip 검증 로직에서 일치하게 된다. 이를 쿠키값으로 넘기는 방법은 뭐 여러가지가 있을 수 있겠지만 나는 익숙한 방법인 Burp Suite를 사용하여 다음과 같이 전달하였다.
포워딩 한 결과 다음과 같이 성공할 수 있었다(이미 풀었기 때문에 already solved로 출력된다).