tl;dr We exploit a weakness in the hash function to revert it for a known data string. (The weakness does **not** require in-depth understanding of the hashing, see step 2)
*The exploit idea is quite simple, but I find it rather complex to explain it correctly. Sorry if the write-up is a bit confusing.*
Between nanothorpe and minithorpe, 3 small changes happened:
1. Deletion of the URL decoding -> we cannot send the 0x80 character that is part of the padding
2. Argumentparsing changed -> now, if we provide several identical parameters, the first will be used instead of the last
3. the expiry parameter is appendend instead of prepended to our message
Those changes turn the length attack unfeasable. Now it is time to take a look at the hash function in _compress()
Our exploit will do the following steps:
1. Let the server sign a message consisting of 2 Blocks in total (we know everything except the first 32 bytes in the first block, we put an own expiry= in the first block, and cmd=ls in the second block)
2. Using the final state of the octothorpe class and the second block, we compute octothorpe's state after compressing the first block, essentially deleting the second block (with the cmd=ls parameter)
3. We can now compress our own second block appending our own cmd: `cmd=cat+%2Fflag_%2A.txt`
##### Step 1
We know the secret is 32 Bytes (check the Docker file), we add an `expiry=` parameter with a time far in the future.
We add some unused parameters for filling and finish the first message with `cmd=ls`. The total message is 128 Bytes - 32 Bytes (the secret added by the server) - 9 Bytes (added by octothorpe to finalize the hashing). (The exact length does not matter)
We let the server sign this message and extract the query_string (which includes the server's expiry= as well) and the signature (final state of octothorpe)
##### Step 2
Let's investigate the hash function:
* We have a 16 byte state, and a 64 byte block as the input.
* There are 20 Rounds. In each round, every byte of the block is used in some magic and modifies the state at index `j-1` and `j+1`. There has to be something fishy about this.
* If we want to revert the hashing, we should do it round-by-round, so we look at the rounds seperatly
* If you take a closer look what is "constant" and what is unknown in the hashing, we can rewrite the inner loop as:
for i in range(64):
j = 'independent of input'
self._state[j-1] = self._state[j-1] ^ magic-depending-on-(state[j])
self._state[j+1] = self._state[j+1] ^ magic-depending-on-(state[j])
* We wanted to know, which byte in the 16 byte state contribute to which byte in the resulting state. Therefor, we just run the program and collected this information. So, in each iteration, we know `i` and `j` (both do not depend on the state or input, so we can do this with abitrary values). And we know that the state's bytes at index `j-1` and `j+1` are affected by the original values at these positions and the input value at index `i`. From this, we can compute the *depends on*-information. It looks like this for the last round:
0 depends on 15, 1, 15, 1, 15, 1, 15, 1,
1 depends on 0, 2, 0, 2, 0, 2, 0, 2,
2 depends on 3, 1, 3, 1, 3, 1, 3, 1,
3 depends on 2, 4, 2, 4, 2, 4, 2, 4,
4 depends on 3, 5, 3, 5, 3, 5, 3, 5,
5 depends on 6, 4, 6, 4, 6, 4, 6, 4,
6 depends on 5, 7, 5, 7, 5, 7, 5, 7,
7 depends on 6, 8, 6, 8, 6, 8, 6, 8,
8 depends on 9, 7, 9, 7, 9, 7, 9, 7,
9 depends on 8, 10, 8, 10, 8, 10, 8, 10,
10 depends on 9, 11, 9, 11, 9, 11, 9, 11,
11 depends on 12, 10, 12, 10, 12, 10, 12, 10,
12 depends on 11, 13, 11, 13, 11, 13, 11, 13,
13 depends on 12, 14, 12, 14, 12, 14, 12, 14,
14 depends on 15, 13, 15, 13, 15, 13, 15, 13,
15 depends on 0, 14, 0, 14, 0, 14, 0, 14,
Weeee. So, only the values of the 1st and 15th (and the 0th byte itself) in the old state affect the 0th byte in the state after this final round (numbers appear several times, because several values for `i` use the same state bytes). And this is the worst case already. What does this mean? The bytes are fairly independent, allowing us to bruteforce them step by step instead of all at once.
Let's say, the (unknown) state before the final round is `[s_0, s_1, s_2, ..., s_15]` and the final state is `[t_0, t_1, t_2, ..., t_15]` (known from the signature). So, `t_0` depends on the values of `s_1`, `s_15` and `s_0` for a fixed input block. We also consider `block` to be known (if the message is at least 2 blocks long, we know the last block).
So, basically, the value `t_0 = s_0 ^ magic(s_1) ^ magic(s_15)`. There are `256*256` possibilities for `s_1` and `s_15` and for all those possibilities, we can simulate the hashing and compute `magic(s_1) ^ magic(s_15)`, because we know `sbox`, `block`, `shift`, ... Also knowning `t_0`, we can now deduce `s_0`. So, for each pair `(s_1, s_15)`, we also know `s_0`. We now have about `256*256` triples to continue with (but this number will now grow much).
Now let's go to to `t_1`. It depends on `s_0`, `s_2` and `s_ 1`. We already fixed `s_1` and computed `s_0`, so only bruteforce 256 possibilities for `s_2` and check which one gives the exptected value of `t_1`. In most cases there is one value that fits, so we do this for each of the triples and now have around the same amount of tuples with 4 bytes fixed in each of them. We continue this up to `t_15`. Looking at `t_14` and `t_15`, you can see that they depend on `s_15` and `s_0`, two values that are already fixed, so when we get there, we just need to check whether the hashing computes the expected values of `t_14` and `t_15`. And hereby the number of around `256*256` tuples decreases to 1 valid tuple. This tuple (fixed values for all 16 bytes `s_i`) is a valid state for `s` that will result in `t` after applying the hashing using the given input `block`. We undid round 20 and can now do the same for round 19, round 18, ... and all the way back until we undid round 1 giving us the initial state of octothorpe after hashing the first block, but before hashing the second block.
* some cases, there are 2 or more input states valid (we encountered rare cases of 4 or even 8 states), so we had to undo previous rounds for all of those states, but most of them lead to a dead end (no input state could lead to the expected output and could thus be dropped). In the end, we got 7 states where only one was the actual state used by octothorpe on the server. But we can try 7 possibilities.
* We implemented this algorithm in C++ to be faster. It took about 1 minute to compute.
* The code is really bad c++, because I did not want to think about clean solutions, so here is just a pseudo code variant for inverting a single round:
function invert_one_round(round, state_after, block):
possible_states = [[-1] * 16] # 1 state, nothing known yet
for idx in range(16):
for ps in possible_states:
d1, d2 = get-depends-on(idx) # e.g. idx=0 -> d1 = 15, d2 = 1
for v1 in range(256) and v2 in range(256): # (or if we already fixed d1 or d2 in ps, use this one)
ps[d1] = v1, ps[d2] = v2
tmp = state_after[idx] # this is the exptected state (= t_idx)
for each depedency of idx: # a dependency contains the index inside of block and the state used to compute magic()
tmp = tmp ^ magic(block_idx, j, jj, round) # magic() is this line: self.sbox[block[i] ^ rol(state[j], self.shift[jj], 8)], all values are known / fixed here
# now tmp is the value of s_idx
if ps[idx] == -1:
ps[idx] = tmp # fix this one
elif ps[idx] != tmp:
# error, continue with next possible_state
possible_states = possible_pre_states # for next round
return possible_states # all states lead to after_state, when applying the hashing using input block in the given round
##### Step 3
We setup octothorpe with the state after the first block, add our own second block containing the command we want to execute and hash this second block. This message can be sent to the `/api/run` endpoint just like in nanothorpe. As we can have multiple possible state, we just try all of them (all tests gave about 5-10 states).