Rustberry (201) - JustCTF 2023
Rustberry was a reverse engineering challenge that was worth 201 points at the end of JustCTF 2023. You can download the original challenge here.
Problem Description
I have enough of VMs. This is a simple crackme
Note: flag is in format jctf{[A-Za-z0-9_]+}
nc vaulted.nc.jctf.pro 1337
Solution
We’re given a binary rustberry.exe
, which seems to be have been initially written in Rust and compiled for ARMv7+:
$ file rustberry.exe
rustberry.exe: ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 3.2.0, BuildID[sha1]=fe44afa081afc7b0025b39da63c436ebdc7038be, with debug_info, not stripped
Typically, the main function of Rust ELFs follows the naming convention <project_name>::main
, and indeed we find rustberry::main
in Ghidra’s decompilation of the binary.
void main(undefined4 param_1,undefined4 param_2)
{
code *local_c;
local_c = rustberry::main;
std::rt::lang_start_internal
(&local_c,&anon.1360747006cbb19a0f51f675ad6cc70e.0.llvm.6404864161857707856,param_1,
param_2,0);
return;
}
...
void rustberry::main(char *param_1)
{
undefined *puVar1;
undefined4 *puVar2;
...
}
Examining it, a few things stand out:
__s2
is likely a Vec<u8>
that’s storing some kind of key:
__s2 = (undefined4 *)std::alloc::__default_lib_allocator::__rust_alloc(0xac,4);
if (__s2 == (undefined4 *)0x0) {
/* WARNING: Subroutine does not return */
alloc::alloc::handle_alloc_error(0xac,4);
}
__s2[0x25] = 7;
__s2[0x29] = 0x1c;
__s2[0x2a] = 0xff;
__s2[0x18] = 3;
__s2[0x26] = 0x21;
__s2[0x19] = 0x1a;
__s2[0x1a] = 0x11;
__s2[0x1b] = 0x14;
__s2[0x20] = 0x11;
__s2[0x21] = 0x11;
__s2[0x1d] = 0x13;
__s2[0x1e] = 1;
__s2[0x1f] = 0x20;
__s2[0x13] = 8;
__s2[0x28] = 0xb;
__s2[0x27] = 0xb;
__s2[0x11] = 0xb;
__s2[0x17] = 0xb;
__s2[8] = 0x15;
__s2[9] = 0x33;
__s2[0x22] = 0x18;
__s2[0x10] = 0xf;
__s2[10] = 0x1a;
__s2[0xb] = 9;
__s2[0xc] = 0x14;
__s2[0xd] = 0x12;
__s2[3] = 5;
__s2[0x1c] = 0x22;
__s2[4] = 0x1b;
__s2[5] = 0xd;
__s2[6] = 0x1d;
__s2[0x23] = 0x1a;
__s2[0x15] = 0x1a;
__s2[0xf] = 0x1a;
__s2[7] = 0x1a;
__s2[0x24] = 2;
__s2[0x12] = 0;
__s2[0x14] = 0xd;
__s2[0x16] = 0x1d;
__s2[0xe] = 0x13;
*__s2 = 9;
__s2[1] = 2;
__s2[2] = 0x13;
__dest = (byte *)std::alloc::__default_lib_allocator::__rust_alloc(0x41,1);
__dest
is likely some Vec<u8>
that’s storing the upper and lowercase alphabet and some special characters:
__dest = (byte *)std::alloc::__default_lib_allocator::__rust_alloc(0x41,1);
if (__dest == (byte *)0x0) {
/* WARNING: Subroutine does not return */
alloc::alloc::handle_alloc_error(0x41,1);
}
memcpy(__dest,
"abcdefghijklmnopqrstuvwxyz_{}0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZGive me the flag? \nYou\' ve entered \nError: \nIndex out of bounds()/rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/ library/core/src/str/pattern.rs"
,0x41);
puVar2
is likely the String corresponding to our user input. Furthermore, there’s a main loop that’s computing uVar6 = puVar2[i]
, checking if puVar2[i] in dest[1]
, and copying dest.index_of(puVar2[i])
into __s1[i]
:
do {
uVar6 = (uint)*(byte *)((int)puVar2 + uVar9);
if (uVar6 == *__dest) {
uVar6 = count_leading_zeroes(0);
iVar7 = 0;
LAB_0001624c:
iVar7 = iVar7 + (uVar6 >> 5 ^ 1);
}
else {
if (uVar6 == __dest[1]) {
uVar6 = count_leading_zeroes(0);
iVar7 = 1;
goto LAB_0001624c;
}
...
if (uVar6 == __dest[0x40]) {
iVar7 = 0x40;
uVar6 = count_leading_zeroes(uVar6 - __dest[0x40]);
goto LAB_0001624c;
}
}
*(int *)(__s1 + uVar9 * 4) = iVar7;
uVar9 = uVar9 + 1;
} while (uVar4 != uVar9);
There’s a comparison that compares uVar4
to 0x2b = 42, suggesting that uVar4
is the length of the input. Furthermore, the comparison checks that the __s1
and __s2
are bytewise identical for the first 0xac = 172 characters. If the check succeeds, then the program will output “You’ve entered correctly”, and otherwise it will output “You’ve entered incorrectly”.
if ((uVar4 != 0x2b) || (iVar7 = bcmp(__s1,__s2,0xac), iVar7 != 0)) goto LAB_000162f4;
local_48 = 9;
local_4c = (undefined4 *)std::alloc::__default_lib_allocator::__rust_alloc(9,1);
if (local_4c == (undefined4 *)0x0) {
/* WARNING: Subroutine does not return */
alloc::alloc::handle_alloc_error(9,1);
}
// "correctly"
*(undefined *)(local_4c + 2) = 0x79;
local_4c[1] = 0x6c746365;
uVar8 = 0x72726f63;
...
LAB_000162f4:
local_48 = 0xb;
local_4c = (undefined4 *)std::alloc::__default_lib_allocator::__rust_alloc(0xb,1);
if (local_4c == (undefined4 *)0x0) {
/* WARNING: Subroutine does not return */
alloc::alloc::handle_alloc_error(0xb,1);
}
// "incorrectly"
*(undefined4 *)((int)local_4c + 7) = 0x796c7463;
local_4c[1] = 0x63657272;
uVar8 = 0x6f636e69;
We therefore need to find a way to supply a string such that after the indices are checked against __dest
and copied into __s1
, __s1
and __s2
match. This is actually quite simple since we’re already given the correct indices in __s2
. We can recover the original string by doing __dest[__s2[i]]
.
__dest = "abcdefghijklmnopqrstuvwxyz_{}0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZGive me the flag? \nYou' ve entered \nError: \nIndex out of bounds()/rustc/84c898d65adf2f39a5a98507f1fe0ce10a2b8dbc/ library/core/src/str/pattern.rs"
__s2 = [9, 2, 19, 5, 27, 13, 29, 26, 21, 51, 26, 9, 20, 18, 19, 26, 15, 11, 0, 8, 13, 26, 29, 11, 3, 26, 17, 20, 34, 19, 1, 32, 17, 17, 24, 26, 2, 7, 33, 11, 11, 28]
puVar2 = "".join([__dest[i] for i in __s2])
print(puVar2)
jctf{n0_vM_just_plain_0ld_ru5tb3rry_ch4ll}
.
Indeed, after running the binary with this, we confirm that we have the correct flag.
$ export LD_LIBRARY_PATH=/usr/arm-linux-gnueabihf/lib/
$ ./rustberry.exe
Give me the flag?
jctf{n0_vM_just_plain_0ld_ru5tb3rry_ch4ll}
You've entered correctly
Some additional notes
We made some assumptions about the purpose of some variables since decompiled code isn’t always sensible. For example, it’s not 100% clear by looking at the excerpt below that uVar4
is the length of the input and puVar2
is the input string.
local_38 = 1;
local_30 = 0;
local_44 = 0;
std::io::stdio::_print(&local_44);
local_54 = 0;
local_58 = (undefined4 *)0x1;
local_5c = 0;
local_50 = std::io::stdio::stdin();
std::io::stdio::Stdin::read_line(&local_44,&local_50,&local_5c);
uVar4 = local_54;
puVar2 = local_58;
We can ascertain this via dynamic analysis.
From looking at the dissassembly, we can see that
(uint)*(byte *)((int)puVar2 + uVar9);
corresponds to
00015c2c 02 00 db e7 ldrb r0,[r11,r2]
meaning puVar2
is stored in r11
. Furthermore,
if ((uVar4 != 0x2b) || (iVar7 = bcmp(__s1,__s2,0xac), iVar7 != 0)) goto LAB_000162f4
corresponds to
00016268 2b 00 5a e3 cmp r10,#0x2b
0001626c 20 00 00 1a bne LAB_000162f4
00016270 09 00 a0 e1 cpy r0,r9
00016274 07 10 a0 e1 cpy r1,r7
00016278 ac 20 a0 e3 mov r2,#0xac
0001627c 08 f2 ff eb bl <EXTERNAL>::bcmp
00016280 00 00 50 e3 cmp r0,#0x0
00016284 1a 00 00 1a bne LAB_000162f4
so uVar4
is stored in r10
, __s1
is stored in r9
, and __s2
is stored in r7
.
We can then run the binary via qemu-arm and gdbserver, set relevant breakpoints, and examine the contents of these registers:
$ qemu-arm -g 1234 rustberry.exe
Give me the flag?
jctf{n0_vM_just_plain_0ld_ru5tb3rry_ch4ll}
...
gdb-peda$ b *0x40006268
Breakpoint 1 at 0x40006268
gdb-peda$ x/s $r11
0x40056860: "jctf{n0_vM_just_plain_0ld_ru5tb3rry_ch4ll}\ni\261"
gdb-peda$ p $r10
$13 = 0x2b
gdb-peda$ x/172x $r7
0x40056890: 0x09 0x00 0x00 0x00 0x02 0x00 0x00 0x00
0x40056898: 0x13 0x00 0x00 0x00 0x05 0x00 0x00 0x00
0x400568a0: 0x1b 0x00 0x00 0x00 0x0d 0x00 0x00 0x00
0x400568a8: 0x1d 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400568b0: 0x15 0x00 0x00 0x00 0x33 0x00 0x00 0x00
0x400568b8: 0x1a 0x00 0x00 0x00 0x09 0x00 0x00 0x00
0x400568c0: 0x14 0x00 0x00 0x00 0x12 0x00 0x00 0x00
0x400568c8: 0x13 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400568d0: 0x0f 0x00 0x00 0x00 0x0b 0x00 0x00 0x00
0x400568d8: 0x00 0x00 0x00 0x00 0x08 0x00 0x00 0x00
0x400568e0: 0x0d 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400568e8: 0x1d 0x00 0x00 0x00 0x0b 0x00 0x00 0x00
0x400568f0: 0x03 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400568f8: 0x11 0x00 0x00 0x00 0x14 0x00 0x00 0x00
0x40056900: 0x22 0x00 0x00 0x00 0x13 0x00 0x00 0x00
0x40056908: 0x01 0x00 0x00 0x00 0x20 0x00 0x00 0x00
0x40056910: 0x11 0x00 0x00 0x00 0x11 0x00 0x00 0x00
0x40056918: 0x18 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x40056920: 0x02 0x00 0x00 0x00 0x07 0x00 0x00 0x00
0x40056928: 0x21 0x00 0x00 0x00 0x0b 0x00 0x00 0x00
0x40056930: 0x0b 0x00 0x00 0x00 0x1c 0x00 0x00 0x00
0x40056938: 0xff 0x00 0x00 0x00
gdb-peda$ x/172x $r9
0x40056988: 0x09 0x00 0x00 0x00 0x02 0x00 0x00 0x00
0x40056990: 0x13 0x00 0x00 0x00 0x05 0x00 0x00 0x00
0x40056998: 0x1b 0x00 0x00 0x00 0x0d 0x00 0x00 0x00
0x400569a0: 0x1d 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400569a8: 0x15 0x00 0x00 0x00 0x33 0x00 0x00 0x00
0x400569b0: 0x1a 0x00 0x00 0x00 0x09 0x00 0x00 0x00
0x400569b8: 0x14 0x00 0x00 0x00 0x12 0x00 0x00 0x00
0x400569c0: 0x13 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400569c8: 0x0f 0x00 0x00 0x00 0x0b 0x00 0x00 0x00
0x400569d0: 0x00 0x00 0x00 0x00 0x08 0x00 0x00 0x00
0x400569d8: 0x0d 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400569e0: 0x1d 0x00 0x00 0x00 0x0b 0x00 0x00 0x00
0x400569e8: 0x03 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x400569f0: 0x11 0x00 0x00 0x00 0x14 0x00 0x00 0x00
0x400569f8: 0x22 0x00 0x00 0x00 0x13 0x00 0x00 0x00
0x40056a00: 0x01 0x00 0x00 0x00 0x20 0x00 0x00 0x00
0x40056a08: 0x11 0x00 0x00 0x00 0x11 0x00 0x00 0x00
0x40056a10: 0x18 0x00 0x00 0x00 0x1a 0x00 0x00 0x00
0x40056a18: 0x02 0x00 0x00 0x00 0x07 0x00 0x00 0x00
0x40056a20: 0x21 0x00 0x00 0x00 0x0b 0x00 0x00 0x00
0x40056a28: 0x0b 0x00 0x00 0x00 0x1c 0x00 0x00 0x00
0x40056a30: 0xff 0x00 0x00 0x00
Thus, by supplying the flag, we have that the first 172 bytes pointed to by r7
and r9
are identical, meaning bcmp(__s1, __s2, 0xac)
is satisfied. We can also observe that r11
contains the contents of our flag, so puVar2
does actually store our input. Finally, r10
does indeed correspond to the length of the input (0x2b), meaning our assumption about uVar4
being the length of the input was correct.
Flag
jctf{n0_vM_just_plain_0ld_ru5tb3rry_ch4ll}