Recovering Rust stripped symbols on MinGW targets
This article is the continuation of a previous blog post about rust symbol recovery. If you are unfamiliar with symbol recovery, I recommand you go read it before reading this entry.
In my previous blog post, I talked about how one can recover symbols from a stripped rust executable, and difficulties you can encounter when dealing with certain compilation options.
Some actors sometimes cross compile their code from Linux to Windows, which requires a specific linker. In its plaform support documentation, rust defines two targets that are Tier 1 targets (officialy supported by rust) that allow easy cross compilation between Linux and Windows: x86_64-pc-windows-gnu
and i686-pc-windows-gnu
. Both use MinGW as a linker.
As a reminder, a simplified schema of the compilation pipeline of a rust program should look something like:
rust source code (.rs) -> rustc -> llvm -> object files (.o) -> link
In the case of x86_64-pc-windows-gnu
and i686-pc-windows-gnu
targets, the linkage step is done by MinGW.
Installing such targets is pretty straighforward on Ubuntu:
apt-get install mingw-w64
rustup target add x86_64-pc-windows-gnu
rustup install stable-x86_64-pc-windows-gnu
You can easily check that everything works with:
cargo new hello_world && cd hello_world
cargo +stable build --target x86_64-pc-windows-gnu
Why does it matter for symbol recovery ?
As a reminder, there are two things we want to sign:
- any dependencies used and identified in the target we want to apply our signature to ;
- the standard library and its runtime.
To sign dependencies, we will be needing to compile them. rustc
is the important part of the compilation pipeline, since it is the component that understands rust code. This is the key component that will generate the code, thus knowing which version to use is important while doing a signature.
Another important part of the compilation toolchain is the linker used. It will have an impact on generated code. The impact can be rather small, but using two different linkers will result in different code. Regarding MinGW, the version of MinGW/GCC should not matter for the linkage part, any MinGW version should do the trick.
However, generating signatures for the standard library and the runtime is a bit trickier. Since those are not generated by rustc
(in the case of MinGW), every runtime of every MinGW version will be slighly different.
For example, here is the function pei386_runtime_relocator
which comes from MinGW
runtime, compiled with MinGW-8/GCC-10.3.0
:
push rbp
push r15
push r14
push r13
push r12
push rdi
push rsi
push rbx
sub rsp, 38h
lea rbp, [rsp+80h]
mov esi, cs:was_init_94382
test esi, esi
jz short loc_1400973A8
lea rsp, [rbp-48h]
pop rbx
pop rsi
pop rdi
pop r12
pop r13
pop r14
pop r15
pop rbp
retn
And here is the code of the same function but from MinGW-10/GCC-12.2.0
:
push rbp
push r15
push r14
push r13
push r12
push rdi
push rsi
push rbx
sub rsp, 48h ; different
lea rbp, [rsp+40h] ; different
mov r12d, cs:was_init_0 ; different
test r12d, r12d ; different
jz short loc_1400BC900
lea rsp, [rbp+8] ; different
pop rbx
pop rsi
pop rdi
pop r12
pop r13
pop r14
pop r15
pop rbp
retn
Making a signature for each MinGW runtime is a tedius and boring work to do, so I did it for you. You can find those signatures on this repo. Almost every version referenced in mingw official download page should be signed.
MinGW version identification
Before applying a runtime signature to a target, we must first identify the MinGW version our target uses.
To do so, I created YARA
rules that help identify the MinGW version of x86_64 linux rust executable binaries.
The process to create those rules was:
- install the specific version of MinGW to sign ;
- build a hello-world with it ;
- compare the result of runtime functions between all hello-world programs from other MinGW version.
Those rules are now implemented in rustbininfo to help identify MinGW versions.
Test case
A good test case was the program archiver
, from 2024’s FCSC CTF. It can be downloaded here. While this challenge can be solved with a blackbox approach, I wanted to try and recover function names.
Here is the result of rustbininfo
on the target:
TargetRustInfo(
rustc_version='1.79.0',
rustc_commit_hash='3c85e56249b0b1942339a6a989a971bf6f1c9e0f',
dependencies=[
Crate(name='aead', version='0.5.2', features=[], repository=None),
Crate(name='aes', version='0.8.4', features=[], repository=None),
Crate(name='aes-gcm', version='0.10.3', features=[], repository=None),
Crate(name='anstream', version='0.6.13', features=[], repository=None),
Crate(name='anstyle', version='1.0.6', features=[], repository=None),
Crate(name='anstyle-parse', version='0.2.3', features=[], repository=None),
Crate(name='anstyle-wincon', version='3.0.2', features=[], repository=None),
Crate(name='bincode', version='1.3.3', features=[], repository=None),
Crate(name='block-buffer', version='0.10.4', features=[], repository=None),
Crate(name='cipher', version='0.4.4', features=[], repository=None),
Crate(name='clap_builder', version='4.5.2', features=[], repository=None),
Crate(name='clap_lex', version='0.7.0', features=[], repository=None),
Crate(name='ctr', version='0.9.2', features=[], repository=None),
Crate(name='digest', version='0.10.7', features=[], repository=None),
Crate(name='generic-array', version='0.14.7', features=[], repository=None),
Crate(name='hmac', version='0.12.1', features=[], repository=None),
Crate(name='polyval', version='0.6.2', features=[], repository=None),
Crate(name='rand_core', version='0.6.4', features=[], repository=None),
Crate(name='serde', version='1.0.197', features=[], repository=None),
Crate(name='sha2', version='0.10.8', features=[], repository=None),
Crate(name='strsim', version='0.11.0', features=[], repository=None),
Crate(name='universal-hash', version='0.5.1', features=[], repository=None)
],
rust_dependencies_imphash='b5386521b71aa121e153e8b45f9986e1',
guessed_toolchain='Mingw-w64 (Mingw8-GCC_10.3.0)'
)
Then I simply used rustbinsign to generate a signature for this target.
The command I used was the following:
$ rbs -l DEBUG sign_target -f -t 1.79.0-x86_64-pc-windows-gnu --template ./profiles/archiver.json --provider IDA --target ./archiver.exe --no-std --signature_name archiver_signature
With archiver.json
being:
{
"profile": {
"release": {
"debug": 1,
"strip": "none",
"opt-level": "z",
"panic": "abort"
}
}
According to the logs, the program ran for about 30mn, and gave me a 16MB signature as an output.
Results
I applied both this signature and the 1.79.0-x86_64-pc-windows-gnu
runtime signature in IDA. Here are the results.
Before applying the signatures:
After:
IDA states that 15 503 functions got recognized.
Issues / Edge cases
I wanted to test this method against BlackCat
’s well known rust ransomware, but their ransomware was compiled to 32 bits, which defeats my MinGW version recognition. I didn’t want to do all the YARA rule MinGW version stuff all over again for 32 bits, so I decided that I would give it a shot another time.