This article delves into the reverse engineering of Dart executable or Flutter release applications. We focus on the reverse engineering of byte arrays.
No type for bytes
Dart does not have a specific type for bytes, it only has integers (int
). Byte arrays are typically coded as Uint8List
objects, a class dedicated to representing fixed-length list of 8-bit unsigned integers. It reduces bit waste in comparison to List<int>, where each byte would only use 8 bits of 64… Uint8List
is not part of Dart’s core language but implemented in the typed_data
library.
This is a simple Dart program with a byte array:
import 'dart:typed_data';
void main() {
// Pico le Croco
Uint8List msg = Uint8List.fromList([80, 105, 99, 111, 32, 108, 101, 32, 67, 114, 111, 99, 111]);
print(String.fromCharCodes(msg));
}
Where is the array?
We compile a Dart AOT snapshot for the previous program:
$ dart compile aot-snapshot picohello.dart
Info: Compiling with sound null safety.
Generated: /home/axelle/research/flutter/picohello/picohello.aot
The generated picohello.aot
is an ELF 64-bit object, compiled for x86_64 in my case. We search for the array with various methods, but we can’t find the bytes:
$ strings picohello.aot | grep "Pico le Croco"
$ python3 bgrep.py -t hex '5069636f206c652043726f636f' picohello.aot
$ python3 bgrep.py -t hex '506963' picohello.aot
$ python3 bgrep.py -t hex '6f636950' picohello.aot
$ binwalk -R '\x50\x69\x63\x6f' picohello.aot
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
Representation of Small Integers
Dart has 3 types of integers: small integers, medium integers and big integers. The first two are represented by the built-in int
type. Big integers are different, and have their own class BigInt.
The difference between small integers and medium ones are at implementation level (Dart developers just specify an int
, the rest is masked). In the implementation of the Dart SDK, we see the following:
// from runtime/vm/compiler/runtime_api.h
constexpr int kSmiBits = 30;
// from runtime/vm/compiler/runtime_api.cc
bool IsSmi(int64_t v) {
return Utils::IsInt(kSmiBits + 1, v);
}
A SMall Integer (SMI) is on 31 bits (30+1), a Medium INTeger (Mint) is larger.
At compilation time, the least significant bit of a small integer is set to 0, and the value of the integer is coded on the remaining 31 bits.
The “visual” effect is that it doubles the value. For example, SMI value=5 (101
in binary) will be represented as 0x0a
(1010
in binary, least significant bit set to 0).
So, our previous byte array [80, 105, 99, 111, 32, 108, 101, 32, 67, 114, 111, 99, 111]
is going to be represented as [160, 210, 198, 222, 64, 216, 202, 64, 134, 228, 222, 198, 222]
, or in hexadecimal [0xa0, 0xd2, 0xc6, 0xde, 0x40, 0xd8, 0xca, 0x40, 0x86, 0xe4, 0xde, 0xc6, 0xde]
.
Note that naively searching for these bytes in the binary won’t work because this is a byte array, not a string. The assembly code will write each byte to corresponding addresses, thus introducing instructions in between the byte values.
python3 bgrep.py -t hex ‘a0d2c6de’ picohello.aot
Reversing the AOT snapshot with Radare2
We use Radare2 to disassemble picohello.aot
. We search for the main
function and disassemble it.
$ r2 ./picohello.aot
[0x00050000]> aaa
[0x00050000]> afl~main
0x0009eabc 3 283 main
0x0009ed10 3 33 sym.main_1
[0x00050000]> s main
[0x0009eabc]> pif
push rbp
mov rbp, rsp
sub rsp, 0x28
cmp rsp, qword [r14 + 0x38]
jbe 0x9ebcb
mov rbx, qword [r15 + 0x267]
mov r10d, 0x1a
call sym.stub__iso_stub_AllocateArrayStub
mov qword [var_8h], rax
mov r11d, 0xa0
mov qword [rax + 0x17], r11
mov r11d, 0xd2
mov qword [rax + 0x1f], r11
mov r11d, 0xc6
mov qword [rax + 0x27], r11
mov r11d, 0xde
The last couple of lines show the bytes of our array!
mov r11d, 0xa0 ; write A0 to R11 -- character 'P'
mov qword [rax + 0x17], r11 ; write R11 at address RAX+0x17
mov r11d, 0xd2 ; write D2 -- character 'i'
mov qword [rax + 0x1f], r11 ; write it at address RAX+0x1f
mov r11d, 0xc6 ; character 'c'
mov qword [rax + 0x27], r11
Before that, we notice 2 lines with a call to an array allocation stub.
mov r10d, 0x1a
call sym.stub__iso_stub_AllocateArrayStub
0x1a
is the representation of SMI 13
. This is the length of our array.
__ __ .------------------------------------.
(o |_| o)_____ | |
| ___________) < "Hurray! We found our byte array!" |
\ / | |
\ / `------------------------------------'
\________/
[0x00050000]>
Tools
- Simple Radare2 Plugin to comment SMI loading: https://github.com/cryptax/misc-code/blob/master/flutter/dart-bytes.py
- Same, but as a JEB script: https://github.com/cryptax/misc-code/blob/master/jeb/DartBytes.py
Conclusion
We have learned how Dart byte arrays are assembled, and are now able to recognize them in disassembly.
Note the fact SMI are doubled has many other impacts. For instance, imagine you need to XOR an integer array with a key. Then, the assembly will actually have to (1) test if the integer is a SMI or a Mint, (2) if it’s a SMI, divide it by 2, (3) XOR with the key, and loop. 😉
— Cryptax