Content-Type: text/plain

(Part of a series of writeups from Google CTF 2018.)

The challenge text reads:

Well it's definitely the 90s. Using what was found in the mysterious .ico file, you extract the driver for the Aluminum-Key Hardware password storage device. Let's see what it has in store.

The file, js_safe_1.html is a JavaScript virtual safe.

The HTML comment in the header of the file explains more.

<!--
Advertisement:
Looking for a hand-crafted, browser based virtual safe to store your most
interesting secrets? Look no further, you have found it. You can order your own
by sending a mail to js_safe@example.com. When ordering, please specify the
password you'd like to use to open and close the safe and the content you'd
like to store. We'll hand craft a unique safe just for you, that only works
with your password of choice and contains your secret. (We promise we won't
peek when handling your data.)
-->

The HTML file also contains some JavaScript.

const alg = { name: 'AES-CBC', iv: Uint8Array.from([211,42,178,197,55,212,108,85,255,21,132,210,209,137,37,24])};
const secret = Uint8Array.from([26,151,171,117,143,168,228,24,197,212,192,15,242,175,113,59,102,57,120,172,50,64,201,73,39,92,100,64,172,223,46,189,65,120,223,15,34,96,132,7,53,63,227,157,15,37,126,106]);
async function open_safe() {
  keyhole.disabled = true;
  password = /^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(keyhole.value);
  if (!password || !(await x(password[1]))) return document.body.className = 'denied';
  document.body.className = 'granted';
  const pwHash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password[1]));
  const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt']);
  content.value = new TextDecoder("utf-8").decode(await crypto.subtle.decrypt(alg, key, secret))
}

The open_safe function is called when the password is entered. If it matches the format CTF{...}, it's passed on to the x function for validation.

If it's valid, decrypt an AES-CBC-encrypted secret using the supplied password and display it to the user.

Makes you wonder why you wouldn't just try to decrypt the secret with the supplied password and skip the validation step. If the password is wrong then the user is no better off anyway, and you're as secure as your encryption algorithm (very secure) and not your obfuscation mechanism (very insecure).

Even though this is a challenge and not the real world, it's still a reminder of a pattern seen all too often: the belief that obfuscation is a reasonable substitute for good security.

Anyway, the validation function is where the interesting stuff happens!

async function x(password) {
    // TODO: check if they can just use Google to get the password once they understand how this works.
    var code = 'icffjcifkciilckfmckincmfockkpcofqcoircqfscoktcsfucsivcufwcooxcwfycwiAcyfBcwkCcBfDcBiEcDfFcwoGcFfHcFiIcHfJcFkKcJfLcJiMcLfNcwwOcNNPcOOQcPORcQNScRkTcSiUcONVcUoWcOwXcWkYcVkЀcYiЁcЀfЂcQoЃcЂkЄcЃfЅcPNІcЅwЇcІoЈcЇiЉcЈfЊcPkЋcЊiЌcІiЍcЌfЎcWoЏcЎkАcЏiБcІkВcБfГcNkДcГfЕcЇkЖcЕiЗcЖfИcRwЙcИoКcЙkЛcUkМcЛiНcМfОcИkПcОiРcПfСcUwТcСiУcQkФcУiХcЃiЦcQwЧcЦoШcЧkЩcШiЪcЩfЫcRiЬcЫfЭcКiЮcЭfЯcСoаcЯiбcГiвcЙiгcRoдcгkеcдiжdТaзcЛfиdзaжcжийcСkкdйaжcжклcйfмdлaжcжмнdТaжcжноdЀaжcжопdNaжcжпрcUiсcрfтdсaуdЁaтcтутcтофcТfхdфaтcтхтcтктcтнтcтмцdсaтcтцтcтктcтутcтнчaaтшdЯaщcйiъcщfыdъaьcжыэcVfюdэaьcьюьcьояdЛaьcьяьcьуьcьыѐчшьёѐшшђcOfѓdђaѓcѓнѓcѓнєcUfѕdєaѓcѓѕіcЯfїdіaѓcѓїјaёѓљaaтњcжшћcЎiќcћfѝdќaњcњѝњcњeўcЏfџdўaњcњџѠdАaњcњѠњcњшњcњѝњcњfњcњџѡљшњѢaaтѣcжшѣcѣѝѣcѣeѣcѣџѤcЯkѥdѤaѣcѣѥѣcѣшѣcѣѝѣcѣfѣcѣџѦѢшѣѧcцнѧcѧїѨdСaѧcѧѨѧcѧкѧcѧуѩaёѧѪcхмѫdрaѪcѪѫѪcѪкѬdYaѪcѪѬѪcѪиѭaѩѪѮcяюѯdНaѮcѮѯѮcѮиѮcѮхѮcѮкѰaѭѮѱdVaѲcхѱѲcѲѕѳcNoѴcѳkѵcѴfѶdѵaѲcѲѶѲcѲiѲcѲlѲcѲmѷјѲgѸјѭѷѹbѰѸѺcXfѻdѺaѻcѻюѻcѻоѻcѻкѻcѻoѼdђaѻcѻѼѻcѻнѻcѻнѻcѻѕѻcѻїѽaёѻѾѽѹшѿceeҀceeҁcee҂ceeѿaѾeҀјѿT҂ѡҀшҁјh҂hѦҁшѿaѾfҀјѿV҂ѡҀшҁјh҂hѦҁшѿaѾiҀјѿU҂ѡҀшҁјh҂hѦҁшѿaѾjҀјѿX҂ѡҀшҁјh҂hѦҁшѿaѾkҀјѿЁ҂ѡҀшҁјh҂hѦҁшѿaѾlҀјѿF҂ѡҀшҁјh҂hѦҁшѿaѾmҀјѿЄ҂ѡҀшҁјh҂hѦҁшѿaѾnҀјѿЉ҂ѡҀшҁјh҂hѦҁшѿaѾoҀјѿЄ҂ѡҀшҁјh҂hѦҁшѿaѾpҀјѿЋ҂ѡҀшҁјh҂hѦҁшѿaѾqҀјѿЍ҂ѡҀшҁјh҂hѦҁшѿaѾrҀјѿА҂ѡҀшҁјh҂hѦҁшѿaѾsҀјѿF҂ѡҀшҁјh҂hѦҁшѿaѾtҀјѿВ҂ѡҀшҁјh҂hѦҁшѿaѾuҀјѿД҂ѡҀшҁјh҂hѦҁшѿaѾvҀјѿЗ҂ѡҀшҁјh҂hѦҁшѿaѾwҀјѿК҂ѡҀшҁјh҂hѦҁшѿaѾxҀјѿН҂ѡҀшҁјh҂hѦҁшѿaѾyҀјѿР҂ѡҀшҁјh҂hѦҁшѿaѾAҀјѿТ҂ѡҀшҁјh҂hѦҁшѿaѾBҀјѿФ҂ѡҀшҁјh҂hѦҁшѿaѾCҀјѿW҂ѡҀшҁјh҂hѦҁшѿaѾDҀјѿХ҂ѡҀшҁјh҂hѦҁшѿaѾEҀјѿЪ҂ѡҀшҁјh҂hѦҁшѿaѾFҀјѿЬ҂ѡҀшҁјh҂hѦҁшѿaѾGҀјѿЮ҂ѡҀшҁјh҂hѦҁшѿaѾHҀјѿа҂ѡҀшҁјh҂hѦҁшѿaѾIҀјѿe҂ѡҀшҁјh҂hѦҁшѿaѾJҀјѿб҂ѡҀшҁјh҂hѦҁшѿaѾKҀјѿв҂ѡҀшҁјh҂hѦҁшѿaѾLҀјѿK҂ѡҀшҁјh҂hѦҁшѿaѾMҀјѿе҂ѡҀшҁјh҂hѦҁш'
    var env = {
        a: (x,y) => x[y],
        b: (x,y) => Function.constructor.apply.apply(x, y),
        c: (x,y) => x+y,
        d: (x) => String.fromCharCode(x),
        e: 0,
        f: 1,
        g: new TextEncoder().encode(password),
        h: 0,
    };
    for (var i = 0; i < code.length; i += 4) {
        var [lhs, fn, arg1, arg2] = code.substr(i, 4);
        try {
            env[lhs] = env[fn](env[arg1], env[arg2]);
        } catch(e) {
            env[lhs] = new env[fn](env[arg1], env[arg2]);
        }
        if (env[lhs] instanceof Promise) env[lhs] = await env[lhs];
    }
    return !env.h;
}

If you scroll along, you'll see that code variable is very long. It's four hundred 4-byte instructions of a tiny VM, whose global (and only) state is the object env. In this limited environment, the only operations available are method application and object instantiation with exactly 2 parameters each. And it'll await any Promises it finds along the way.

The initial state is quite limited.

var env = {
    a: (x,y) => x[y],
    b: (x,y) => Function.constructor.apply.apply(x, y),
    c: (x,y) => x+y,
    d: (x) => String.fromCharCode(x),
    e: 0,
    f: 1,
    g: new TextEncoder().encode(password),
    h: 0,
};

In terms of functions, that's array dereference (getItem), method application (apply), add, and fromCharCode.

As a starting point, we're allowed the numbers 0 and 1, and of course, access to the password we entered (as an array of bytes).

h is the variable that must equal 0 at the end to indicate a valid password.

Where there's contrived bytecode there's room for a contrived disassembler.

class ExpressionDisassembler {
    constructor(lhs, fn, arg1, arg2, env) {
        this.lhs = lhs
        this.fn = fn
        this.arg1 = arg1
        this.arg2 = arg2
        this.env = env

        this.arg1old = env[arg1]
        this.arg2old = env[arg2]

        this.labels = {
            a: 'getItem',
            b: 'apply',
            c: 'add',
            d: 'fromCharCode',
            g: 'passwordBytes',
            ѡ: 'bitwiseXOR',
            Ѧ: 'bitwiseOR',
            ѐ: 'window',
            ј: 'Array',
        }
    }

    getNiceName(symbol) {
        return this.labels[symbol] || symbol
    }

    async logFunctionCall() {
        this.log({ isInstance: false })
    }

    async logClassInstantiation() {
        this.log({ isInstance: true })
    }

    async log({ isInstance }) {
        let lhs = this.lhs
        let fn = this.getNiceName(this.fn)
        let arg1 = this.getNiceName(this.arg1)
        let arg2 = this.getNiceName(this.arg2)
        let arg1old = this.arg1old
        let arg2old = this.arg2old

        let v = this.env[this.lhs]
        if (v instanceof Promise) {
            v = await v
        }
        console.log(`${lhs} = ${isInstance ? 'new ' : ''}${fn}(${arg1}, ${arg2}) = ${fn}(${arg1old}, ${arg2old}) = ${v}`)
    }
}

We alias and give names to some of the more important functions in labels, which should make it easier to understand the disassembly.

var disassembler = new ExpressionDisassembler(lhs, fn, arg1, arg2, env)

try {
    env[lhs] = env[fn](env[arg1], env[arg2]);
    await disassembler.logFunctionCall()
} catch(e) {
    env[lhs] = new env[fn](env[arg1], env[arg2]);
    await disassembler.logClassInstantiation()
}

With our disassembler code interspersed, everything is ready. When we enter our sample password into the interface we'll get a dump of everything that happened.

For reference, view the full disassembly.

If you recall from earlier, we're starting with almost nothing: the numbers 0 and 1 and some basic functions. Here we can see that the first step is to create more numbers.

i = add(f, f) = add(1, 1) = 2
j = add(i, f) = add(2, 1) = 3
k = add(i, i) = add(2, 2) = 4
l = add(k, f) = add(4, 1) = 5
m = add(k, i) = add(4, 2) = 6
n = add(m, f) = add(6, 1) = 7
o = add(k, k) = add(4, 4) = 8
p = add(o, f) = add(8, 1) = 9
q = add(o, i) = add(8, 2) = 10
r = add(q, f) = add(10, 1) = 11
s = add(o, k) = add(8, 4) = 12
t = add(s, f) = add(12, 1) = 13
u = add(s, i) = add(12, 2) = 14
v = add(u, f) = add(14, 1) = 15
w = add(o, o) = add(8, 8) = 16
x = add(w, f) = add(16, 1) = 17
y = add(w, i) = add(16, 2) = 18
A = add(y, f) = add(18, 1) = 19
B = add(w, k) = add(16, 4) = 20
C = add(B, f) = add(20, 1) = 21
D = add(B, i) = add(20, 2) = 22
E = add(D, f) = add(22, 1) = 23
F = add(w, o) = add(16, 8) = 24
G = add(F, f) = add(24, 1) = 25
H = add(F, i) = add(24, 2) = 26
I = add(H, f) = add(26, 1) = 27
J = add(F, k) = add(24, 4) = 28
K = add(J, f) = add(28, 1) = 29
L = add(J, i) = add(28, 2) = 30
M = add(L, f) = add(30, 1) = 31
N = add(w, w) = add(16, 16) = 32
O = add(N, N) = add(32, 32) = 64
P = add(O, O) = add(64, 64) = 128
Q = add(P, O) = add(128, 64) = 192
R = add(Q, N) = add(192, 32) = 224
S = add(R, k) = add(224, 4) = 228
T = add(S, i) = add(228, 2) = 230
U = add(O, N) = add(64, 32) = 96
V = add(U, o) = add(96, 8) = 104
W = add(O, w) = add(64, 16) = 80
X = add(W, k) = add(80, 4) = 84
Y = add(V, k) = add(104, 4) = 108
Ѐ = add(Y, i) = add(108, 2) = 110
Ё = add(Ѐ, f) = add(110, 1) = 111
Ђ = add(Q, o) = add(192, 8) = 200
Ѓ = add(Ђ, k) = add(200, 4) = 204
Є = add(Ѓ, f) = add(204, 1) = 205
Ѕ = add(P, N) = add(128, 32) = 160
І = add(Ѕ, w) = add(160, 16) = 176
Ї = add(І, o) = add(176, 8) = 184
Ј = add(Ї, i) = add(184, 2) = 186
Љ = add(Ј, f) = add(186, 1) = 187
Њ = add(P, k) = add(128, 4) = 132
Ћ = add(Њ, i) = add(132, 2) = 134
Ќ = add(І, i) = add(176, 2) = 178
Ѝ = add(Ќ, f) = add(178, 1) = 179
Ў = add(W, o) = add(80, 8) = 88
Џ = add(Ў, k) = add(88, 4) = 92
А = add(Џ, i) = add(92, 2) = 94
Б = add(І, k) = add(176, 4) = 180
В = add(Б, f) = add(180, 1) = 181
Г = add(N, k) = add(32, 4) = 36
Д = add(Г, f) = add(36, 1) = 37
Е = add(Ї, k) = add(184, 4) = 188
Ж = add(Е, i) = add(188, 2) = 190
З = add(Ж, f) = add(190, 1) = 191
И = add(R, w) = add(224, 16) = 240
Й = add(И, o) = add(240, 8) = 248
К = add(Й, k) = add(248, 4) = 252
Л = add(U, k) = add(96, 4) = 100
М = add(Л, i) = add(100, 2) = 102
Н = add(М, f) = add(102, 1) = 103
О = add(И, k) = add(240, 4) = 244
П = add(О, i) = add(244, 2) = 246
Р = add(П, f) = add(246, 1) = 247
С = add(U, w) = add(96, 16) = 112
Т = add(С, i) = add(112, 2) = 114
У = add(Q, k) = add(192, 4) = 196
Ф = add(У, i) = add(196, 2) = 198
Х = add(Ѓ, i) = add(204, 2) = 206
Ц = add(Q, w) = add(192, 16) = 208
Ч = add(Ц, o) = add(208, 8) = 216
Ш = add(Ч, k) = add(216, 4) = 220
Щ = add(Ш, i) = add(220, 2) = 222
Ъ = add(Щ, f) = add(222, 1) = 223
Ы = add(R, i) = add(224, 2) = 226
Ь = add(Ы, f) = add(226, 1) = 227
Э = add(К, i) = add(252, 2) = 254
Ю = add(Э, f) = add(254, 1) = 255
Я = add(С, o) = add(112, 8) = 120
а = add(Я, i) = add(120, 2) = 122
б = add(Г, i) = add(36, 2) = 38
в = add(Й, i) = add(248, 2) = 250
г = add(R, o) = add(224, 8) = 232
д = add(г, k) = add(232, 4) = 236
е = add(д, i) = add(236, 2) = 238

Numbers are basic building blocks, so it makes sense that we would need a lot of them. Numbers can become characters, characters can become words, and words can become code.

Here the word return is built up character by character.

ж = fromCharCode(Т) = fromCharCode(114) = r
з = add(Л, f) = add(100, 1) = 101
и = fromCharCode(з) = fromCharCode(101) = e
ж = add(ж, и) = add(r, e) = re
й = add(С, k) = add(112, 4) = 116
к = fromCharCode(й) = fromCharCode(116) = t
ж = add(ж, к) = add(re, t) = ret
л = add(й, f) = add(116, 1) = 117
м = fromCharCode(л) = fromCharCode(117) = u
ж = add(ж, м) = add(ret, u) = retu
н = fromCharCode(Т) = fromCharCode(114) = r
ж = add(ж, н) = add(retu, r) = retur
о = fromCharCode(Ѐ) = fromCharCode(110) = n
ж = add(ж, о) = add(retur, n) = return

Using the same technique, the code goes on to create a few more familiar strings such as constructor, window, Array, Uint8Array, and crypto.

These words are used to run code that SHA-256 hashes the input password and stores it in a variable, so that it can later be compared to the hash of the correct password.

ѷ = Array(Ѳ, passwordBytes) = Array(sha-256, 72,101,108,108,111) = [sha-256, 72,101,108,108,111]
Ѹ = Array(ѭ, ѷ) = Array([object SubtleCrypto], [sha-256, 72,101,108,108,111]) = [[object SubtleCrypto], [sha-256, 72,101,108,108,111]]
ѹ = apply(Ѱ, Ѹ) = apply(function digest() { [native code] }, [[object SubtleCrypto], [sha-256, 72,101,108,108,111]]) = [object ArrayBuffer]
Ѿ = new ѽ(ѹ, ш) = ѽ([object ArrayBuffer], x) = 24,95,141,179,34,113,254,37,245,97,166,252,147,139,46,38,67,6,236,48,78,218,81,128,7,209,118,72,38,56,25,105

If the passwords were compared via a normal character-by-character comparison, breaking out once the first non-matching character is hit, it might be possible to figure it out via a sidechannel timing attack. A good way to mitigate this attack is to carry out comparisons in constant time. That is, a successful match takes the same amount of time as an unsuccessful match.

These two functions are bitwise OR and XOR.

ѡ = љ(ш, њ) = љ(x, return x[0]^x[1]) = function anonymous(x) { return x[0]^x[1] }
Ѧ = Ѣ(ш, ѣ) = Ѣ(x, return x[0]|x[1]) = function anonymous(x) { return x[0]|x[1] }

One of the properties of XOR is that if you XOR two of the same values, the result is 0.

Therefore one way to compare two arrays in constant time is to XOR each value with itself and OR it with another value h that starts at 0. If h is 0 at the end of the comparisons then you can be sure the result of the XOR at each iteration was 0, hence the strings match. Importantly, whether or not they match, the entire array is traversed.

Below is the first iteration of the loop through the hash.

ѿ = getItem(Ѿ, e) = getItem(24,95,141,179,34,113,254,37,245,97,166,252,147,139,46,38,67,6,236,48,78,218,81,128,7,209,118,72,38,56,25,105, 0) = 24
Ҁ = Array(ѿ, T) = Array(24, 230) = [24, 230]
҂ = bitwiseXOR(Ҁ, ш) = bitwiseXOR([24, 230], x) = 254
ҁ = Array(h, ҂) = Array(0, 254) = [0, 254]
h = bitwiseOR(ҁ, ш) = bitwiseOR([0, 254], x) = 254

The important part is bitwiseXOR([24, 230], x) = 254, where it compares 24 (from our incorrect password hash) to 230 (from the correct password hash).

All that's left to do is extract out the second parameter of each call to bitwiseXOR, and convert it to a hex string.

e66860546f18cdbbcd86b35e18b525bffc67f772c650cedfe3ff7a0026fa1dee

The comment from earlier ("check if they can just use Google to get the password") tells us to simply look up this hash in a rainbow table.

The flag was CTF{Passw0rd!}.

Code