You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Unfortunately, the year is 2024 and there is yet to be a high-quality Rust decompiler on the market. Hex-Rays is a gold standard for native decompilation, but it the pseudo-C it produces is extremely verbose. This is especially true considering the large amount of syntactic sugar Rust provides. Unfortunately, Rust pushes programmers to *actually consider errors and handle them*, much to the detriment of us reverse engineers. A single-character `?` operator can produce dozens of lines of boilerplate in pseudo-C.
17
+
But first, a quick digression.
18
+
19
+
## The absolute state of reverse engineering in 2024
20
+
21
+
Unfortunately, the year is 2024 and there is yet to be a high-quality Rust decompiler on the market. We have become really good at decompiling C, and Hex-Rays is a gold standard for native decompilation. However, the pseudo-C it produces is extremely verbose for non-C languages. This is especially true considering the large amount of syntactic sugar Rust provides. Unfortunately, Rust pushes programmers to *actually consider errors and handle them*, much to the detriment of us reverse engineers. A single-character `?` operator can produce dozens of lines of boilerplate in pseudo-C.
Combine that with proc macros, a powerful type system, differences in calling convention, and modern compiler optimizations and inlining, and you have a truly awful time. Even allocation failures are handled by the compiler, a fact I find deeply distressing.
A good Rust decompiler ought to "lift away" all of these details and fold them into high level Rust code. In Binary Ninja terms, there is really a hierarchy of:
30
+
31
+
***machine code**; binary format has been parsed and memory maps and sections/segments have been populated; ⮐
32
+
***disassembly** (this is a meaningful distinction as assembly is not bijective for real-world ISAs); ⮐
33
+
***lifted IL**, essentially a translation of native instruction semantics to a retargetable IL instruction set with some control reconstruction ⮐
34
+
***low-level IL**, where control flow graph is available and statements are ASTs not [3AC](https://en.wikipedia.org/wiki/Three-address_code) ⮐
35
+
***medium-level IL**, where variables have been reconstructed; types have been reconstructed; the stack is not present as a concept and stack slots have been made variables; variables have been split at type boundaries ⮐
36
+
***high-level IL**, where high-level control flow constructs have been re-introduced ⮐
37
+
***target language**, which could be C, but in modern times also C++, Go, Rust, Swift, Objective-C, Zig, Crystal, Haskell, etc. Language-specific idioms have been reconstructed and reintroduced. However, the output may not exactly reflect the same semantics of the original binary.
38
+
39
+
The final step, emitting the target language, **which is nowadays often NOT C**, is our greatest weakness in 2024. A new generation of engineers and systems folk have discovered the [fruits of Chris Lattner's labor](https://llvm.org/) and staked their claim on today's software landscape. Unfortunately for reverse engineers, we continue to deal with the Cambrian explosion in binary diversity, eating shit reading worsening pseudo-C approximations of things that are not C.
40
+
41
+
This problem will probably not get solved in the near future. There is no market for a high-quality Rust decompiler. First, no one writes exploits or malware in languages like Rust or Haskell. Unlike C/C++/Obj-C, the Rust/Haskell/etc ecosystems are predominantly open-source further decreasing the need for reverse engineering. Lastly, improved source control and ready availability of managed enterprise services (i.e. GitHub) make first-party loss of source code much rarer nowadays. So like, no one really cares about decompiling Rust other than unfortunate CTF players.
42
+
43
+
Golang is a notable exception. Golang is like, *the* language for writing malware--great standard library, good cross-platform support, brain-dead easy concurrency model, easy to cross-compile, fully statically linked, and designed with junior programmers in mind. You could shit out a Golang SSH worm in like 200 LoC crushing carts and ketamine no problem. People worry about AGI Skynet hacking the Pentagon to trigger a nuclear holocaust but really it's more gonna be like eastern European dudes rippin' it with some hella gang weed ChatGPT ransomware.
44
+
45
+
So maybe we'll get a good Golang decompiler first?
46
+
47
+
## OK, so the actual challenge
48
+
49
+
Right, so you go in your ... fucken ... IDA Pro and throw some F5 on it. The shit is illegible so you squint at it really hard and read the function names of the library calls I guess?
50
+
51
+
That didn't really go anywhere so I just started playing with the binary. First you notice it produces different output each time, so it must call `rand()` or similar. It also means for the challenge to be solveable, it needs to include something in the encrypted output that can be used to derive the original random state. Running it under `strace`, we can see it uses the `getrandom` syscall. We also notice it reads in the input in blocks of 48 bytes, and emits 60 bytes. So it's probably including 12 extra bytes of the random state so the process is invertible.
The random value generation is likely important for the crypto, so this is probably a good starting point. So we look for a path from `main` to `getrandom`.
Honestly im kind of a fraud and I havent written rust in like 4 years, but that sounds like the kind of thing a Rust programmer would do--overcomplicating a simple operation with functional programming.
64
+
65
+
At this point I wanted to simplify the problem so I want to make the challenge deterministic. If we look at the actual getrandom implementation in Rust, we see the following:
So I just went in a hex editor and forced the "getrandom available" variable to `0` and patched `/dev/urandom` to `./my_urandom`. Now we can seed the program with whatever we want. I just did `ln -s /dev/zero my_urandom` to start out.
72
+
73
+
Going back to the part with the random vector, I run it under gdb and just breakpoint after the iter fill call returns. And indeed it has the same random bytes each time, depending on the seed from urandom. We can continue basically just slogging through this function, figuring out the behavior by debugger rather than code because the decompilation is nearly useless.
74
+
75
+
Sometimes the binary prints "Prime too small". We can see later in the binary there's some code related to this:
It loads a BigInt from a string. This number also happens to be prime. But what's strange is there isn't any prime generation related code, so what would it mean for the prime to be "too small"? We realized that it's not that a particular random prime number is too small, but rather that our fixed prime parameter is too small as a modulus for some othe random values.
80
+
81
+
Then there is some MD5 and SHA1 shit, along with some XOR because of course there's MD5 and SHA1.
It's not very easy to read the code so again it's easier to just debug it. ̄\_(ツ)_/ ̄
88
+
89
+
There's also some BigInt shit. It uses the prime P earlier as the modulus, which makes sense. We originally thought it may be using a RSA group ("prime too small") but it's just using a prime group.
Lastly, we noticed that the binary does 12 repeated rounds per 48-byte input block. While debugging, we just patched this to be 1 round, so the operation is easier to debug.
94
+
95
+
At this point, after a lot of guess-and-check, we deduced the round operation and fully reimplemented it in Python. Shoutout to Riatre who is a reversing God.
Analyzing the block cipher, we can see that the design is a shitty Feistel network where the round function is some bullshit based on MD5 and SHA1. The key schedule is generated by the `derive` function. The `derive` function takes in a 12 bytes and outputs 12 bytes, performing operations in [GF(2^16)](https://en.wikipedia.org/wiki/Finite_field_arithmetic), represented with 0x2B as reducing polynomial. It also is parameterized by an additional boolean parameter (which we call `m`) so it can generate two different keys per input.
The rest of the network is easy to invert, but the problem is the `derive` function. Converting it from the bitwise implementation to finite field math, here is what we are working with. Let $a = (a_0, a_1, \cdots, a_5)$ be the six elements vector over the finite field GF(2^16). Then:
136
+
137
+
$$\text{derive}(a) = \begin{cases}\begin{pmatrix}
138
+
a_0 * a_1 + a_4\\
139
+
a_1 * a_2 + a_5\\
140
+
a_2 * a_3 + a_0\\
141
+
a_3 * a_4 + a_1\\
142
+
a_4 * a_5 + a_2\\
143
+
a_5 * a_0 + a_3\\
144
+
\end{pmatrix} & m = 0\\
145
+
\begin{pmatrix}
146
+
a_2 * a_3 + a_5\\
147
+
a_3 * a_4 + a_0\\
148
+
a_4 * a_5 + a_1\\
149
+
a_5 * a_0 + a_2\\
150
+
a_0 * a_1 + a_3\\
151
+
a_1 * a_2 + a_4\\
152
+
\end{pmatrix} & m = 1\end{cases}$$
153
+
154
+
To invert `derive`, we have a quadratic system of 6 equations over 6 variables. We can use Sagemath to find the roots. Our equations define a ring ideal and Sage can find its algebraic variety.
155
+
156
+
```python
157
+
defsolve_(solve_for, m):
158
+
159
+
R.<x>= PolynomialRing(GF(2))
160
+
irreducible_poly = x^16+ x^5+ x^3+ x +1
161
+
F = GF(2^16, modulus=irreducible_poly, name='a')
162
+
163
+
F_solve_for = [F.from_integer(x) for x in solve_for]
164
+
165
+
G.<a0, a1, a2, a3, a4, a5>= F[]
166
+
if m ==0:
167
+
my_id = Ideal(
168
+
a0 * a1 + a4 - F_solve_for[0],
169
+
a1 * a2 + a5 - F_solve_for[1],
170
+
a2 * a3 + a0 - F_solve_for[2],
171
+
a3 * a4 + a1 - F_solve_for[3],
172
+
a4 * a5 + a2 - F_solve_for[4],
173
+
a5 * a0 + a3 - F_solve_for[5],
174
+
)
175
+
elif m ==1:
176
+
my_id = Ideal(
177
+
a2 * a3 + a5 - F_solve_for[0],
178
+
a3 * a4 + a0 - F_solve_for[1],
179
+
a4 * a5 + a1 - F_solve_for[2],
180
+
a5 * a0 + a2 - F_solve_for[3],
181
+
a0 * a1 + a3 - F_solve_for[4],
182
+
a1 * a2 + a4 - F_solve_for[5],
183
+
)
184
+
assert my_id.dimension() ==0
185
+
my_variety = my_id.variety()
186
+
fuckshit = [[
187
+
variety[a0].to_integer(),
188
+
variety[a1].to_integer(),
189
+
variety[a2].to_integer(),
190
+
variety[a3].to_integer(),
191
+
variety[a4].to_integer(),
192
+
variety[a5].to_integer()
193
+
] for variety in my_variety]
194
+
return fuckshit
195
+
```
196
+
197
+
So now we're done right? NO! There is one final snag.
198
+
199
+
There can be more than 1 solution to the system! So we need to consider all possible solutions per round recursively. An optimization we can do is that for all rounds we're trying to invert except the final round, we have both rv0 and rv1, and we can further specify the equations from the `m=1` in the ideal. (See solve.py in this repo.)
200
+
201
+
So finally, we can wrap the whole thing up and decrypt all of the blocks in the encrypted flag:
0 commit comments