TRX CTF 25 - Molly
Challenge Description
yes, I play lego games while listening to music in my free time
Molly Overview
We are presented with two PE x86_64 binaries: molly.exe
and molly_dll.dll
Initially, disassembling molly.exe
reveals a multitude of unusual instructions. Furthermore, the high entropy in the .text
section suggests that the code is not standard executable code.
At this point running molly.exe
produces the following output:
(binary is safe, you can also check VirusTotal)
@@@@@@@@@@@@@@@@@@@ , @@@@@@@@@@@@@@@@@@
@@@@@@@@@@@/ ///////////// /@@@@@@@@@@
@@@@@@@/ //* //. /@@@@@@
@@@@@ / ///////// // / @@@@
@@@/ / // ////////////// / /@@
@@ // /////// ////// / /. @
@/ / ////// /////*,. / /
@ / ///// ////////// /
, / ///// ////////////// /
@ / ///// ,////////////// /
@/ / //// ////////// / /
@@ */ // //// ///// /, @
@@@/ / //// ,//// / /@@
@@@@@ / *// / .@@@@
@@@@@@@/ // //. /@@@@@@
@@@@@@@@@@@/ ///////////// /@@@@@@@@@@
> SYSTEM VALIDATION IN PROGRESS
> USER MUST INPUT THE VERIFICATION TOKEN molly.exe <token>
> SYSTEM STATE: STOPPING
> USE WITH CAUTION, NO FAILURES ALLOWED
Note
Upon executing Molly with an incorrect token, the binary becomes corrupted and an additional executable named why.exe
is generated.
At this point molly_dll.dll
likely plays a critical role in unpacking or decrypting molly.exe
, making it the logical starting point for further analysis.
Molly Dynamic Overview
By setting a breakpoint at the entry point of molly.exe
using x64dbg and examining the memory regions corresponding to the .text
section, we observed that the section is fragmented. Some regions are marked as NO_ACCESS
, while others have a fixed size of 0x1000 with READ_EXECUTE
permissions. This suggests that molly.exe is not packed; rather, it is decrypted and executed at runtime.
Dll Static Analysis
When the Windows loader calls an imported DLL with an entry point, it executes on the main thread before the process’s own entry point. As a result, the process entry point will not run until all DLL entry points have completed.
Disassembling the dll_main function within molly_dll.dll
reveals a call to GetProcAddress
for resolving KiUserExceptionDispatcher
:
call qword [rel GetCurrentProcess]
lea rcx, [rel data_180006c48] {"ntdll.dll"}
mov rbx, rax
call qword [rel GetModuleHandleA]
test rax, rax
je 0x180002fb3
lea rdx, [rel data_180006c58] {"KiUserExceptionDispatcher"}
mov rcx, rax
call qword [rel GetProcAddress]
mov qword [rel KiUserExceptionDispatcher_Add], rax
test rax, rax
je 0x180002fb3
The resolution of KiUserExceptionDispatcher
implies that molly_dll.dll
is involved in Windows exception handling, which could be key to its runtime decryption capabilities. Also no standard exception handler appears to be registered… Let’s look deeper
An effective obfuscation technique observed in molly_dll.dll
is the inline use of system calls, which “obscures” the actual API functions being utilized:
mov eax, 0x1c
mov r10, rcx
syscall
retn {__return_addr}
After looking for eax, 0x1c
syscall signature inside ntdll.dll
we can confirm this function is a reimplementation of NtSetInformationProcess
lea rax, [rel sub_1800030b0]
mov r9d, 0x10
mov qword [rsp+0x28 {var_10_1}], rax {sub_1800030b0}
lea r8, [rsp+0x20 {infostruct}]
xor eax, eax {0x0}
mov edx, 0x28
mov rcx, rbx
mov qword [rsp+0x20 {infostruct}], rax {0x0}
call NtSetInformationProcess
NtSetInformationProcess
is invoked with the undocumented PROCESSINFOCLASS
value 0x28, which is responsible for setting an InstrumentationCallback in the process structure.
The function sub_1800030b0
acts as this callback, being invoked during a kernel-mode to user-mode transition with a special register r15
that points to the target user-mode function.
ic_hook:
push r10 {var_8_1}
push rax {var_10}
pushfq {var_18}
push rbx {__saved_rbx}
mov rbx, rsp {__saved_rbx}
lea rax, [rel ic_hook_handler]
cmp rcx, rax
cmove rcx, r10
lea r10, [rsp-0xc0 {var_e0}]
and r10 {var_e0}, 0xfffffffffffffff0
mov rsp, r10
cld
sub rsp, 0x60
mov qword [rsp {var_140}], rcx
mov qword [rsp+0x8 {__saved_rdx}], rdx
mov qword [rsp+0x10 {__saved_rdi}], rdi
mov qword [rsp+0x18 {__saved_rsi}], rsi
mov qword [rsp+0x20 {__saved_r8}], r8
mov qword [rsp+0x28 {__saved_r9}], r9
mov qword [rsp+0x30 {__saved_r11}], r11
mov qword [rsp+0x38 {__saved_r12}], r12
mov qword [rsp+0x40 {__saved_r13}], r13
mov qword [rsp+0x48 {__saved_r14}], r14
mov qword [rsp+0x50 {__saved_r15}], r15
sub rsp, 0x60
movaps xmmword [rsp {__saved_zmm0}], xmm0
movaps xmmword [rsp+0x10 {__saved_zmm1}], xmm1
movaps xmmword [rsp+0x20 {__saved_zmm2}], xmm2
movaps xmmword [rsp+0x30 {__saved_zmm3}], xmm3
movaps xmmword [rsp+0x40 {__saved_zmm4}], xmm4
movaps xmmword [rsp+0x50 {__saved_zmm5}], xmm5
mov rdx, qword [rbx+0x10 {var_10}]
mov rcx, qword [rbx+0x18 {var_8_1}]
call ic_hook_handler
mov qword [rbx+0x18 {var_8}], rax
movaps xmm0, xmmword [rsp {__saved_zmm0}]
movaps xmm1, xmmword [rsp+0x10 {__saved_zmm1}]
movaps xmm2, xmmword [rsp+0x20 {__saved_zmm2}]
movaps xmm3, xmmword [rsp+0x30 {__saved_zmm3}]
movaps xmm4, xmmword [rsp+0x40 {__saved_zmm4}]
movaps xmm5, xmmword [rsp+0x50 {__saved_zmm5}]
add rsp, 0x60
mov rcx, qword [rsp {var_140}]
mov rdx, qword [rsp+0x8 {__saved_rdx}]
mov rdi, qword [rsp+0x10 {__saved_rdi}]
mov rsi, qword [rsp+0x18 {__saved_rsi}]
mov r8, qword [rsp+0x20 {__saved_r8}]
mov r9, qword [rsp+0x28 {__saved_r9}]
mov r11, qword [rsp+0x30 {__saved_r11}]
mov r12, qword [rsp+0x38 {__saved_r12}]
mov r13, qword [rsp+0x40 {__saved_r13}]
mov r14, qword [rsp+0x48 {__saved_r14}]
mov r15, qword [rsp+0x50 {__saved_r15}]
add rsp, 0x60
mov rsp, rbx
pop rbx {__saved_rbx}
popfq
pop rax {var_10}
pop r10 {var_8}
jmp r10
The function in the middle is responsible for setting the return address. Basically, the argument it receives is the original return address and it compares it against one specific target function (KiUserExceptionDispatcher)
, if the addresses match up, then it returns a new hook otherwise it returns the argument passed.
ki_user_exp_stub
cld
lea rcx, [rsp+0x4f0 {exp_record}]
mov rdx, rsp {__return_addr}
call ki_user_exp_hook
test rax, rax
je 0x1800031c0
jmp rax
cld
mov rcx, rsp {__return_addr}
mov rdx, 0x0
call RtlRestoreContext {ic_hook_handler}
ki_user_exp_hook
if (exp_record->ExceptionCode == STATUS_ACCESS_VIOLATION && exp_record->ExceptionAddress)
{
uintptr_t page_base = exp_record->ExceptionInformation[1] & 0xfffffffffffff000;
if (sub_180004110(&page_guard, page_base))
{
if (_Mtx_lock(&encryption_mutex))
{
std::_Throw_Cpp_error(5);
/* no return */
}
if (data_18000a05c == 0x7fffffff)
{
data_18000a05c = 0x7ffffffe;
std::_Throw_Cpp_error(6);
/* no return */
}
sub_180003bd0(&page_guard, page_base);
sub_180003de0(&page_guard);
int64_t result = _Mtx_unlock(&encryption_mutex);
exp_record->ExceptionCode = 0;
exp_record->ExceptionFlags = 0;
exp_record->ExceptionAddress = 0;
exp_record->NumberParameters = 0;
context->EFlags = 0xfe;
return result; // return 0
}
}
return KiUserExceptionDispatcher_Add;
It is evident that ki_user_exp_hook
handles STATUS_ACCESS_VIOLATION
exceptions caused by attempts to access NO_ACCESS
pages, allowing sub_180003bd0
to decrypt it. Once a guarded page is successfully decrypted, ki_user_exp_stub
checks the return value from ki_user_exp_hook
. If the value is 0, it calls RtlRestoreContext
to restore the execution state, allowing the binary to continue execution.
sub_180004110
check if the page is a guarded_pagesub_180003de0
is responsible to re-encrypts old pages, making runtime dumping “ineffective”
Binary Static Decryptor
Now let’s look inside sub_180003bd0
:
void* decrypt_page(int32_t* arg1, uintptr_t page_base)
{
page_base_1 = page_base;
void* result = sub_1800035f0(arg1, &page_base_1);
if (result)
{
int64_t r8_16 = (((((((((((((((uint64_t)page_base ^ 0xcbf29ce484222325) * 0x100000001b3) ^ (uint64_t)(uint8_t)(page_base >> 8)) * 0x100000001b3) ^ (uint64_t)(uint8_t)(page_base >> 0x10)) * 0x100000001b3) ^ (uint64_t)(uint8_t)(page_base >> 0x18)) * 0x100000001b3) ^ (uint64_t)(uint8_t)(page_base >> 0x20)) * 0x100000001b3) ^ (uint64_t)(uint8_t)(page_base >> 0x28)) * 0x100000001b3) ^ (uint64_t)(uint8_t)(page_base >> 0x30)) * 0x100000001b3) ^ page_base >> 0x38;
int64_t* rcx_9 = ((*(uint64_t*)((char*)arg1 + 0x30) & (r8_16 * 0x100000001b3)) << 4) + *(uint64_t*)((char*)arg1 + 0x18);
result = rcx_9[1];
if (result == *(uint64_t*)((char*)arg1 + 8))
{
label_180003dcb:
std::_Xout_of_range("invalid unordered_map<K, T> key");
/* no return */
}
while (page_base != *(uint64_t*)((char*)result + 0x10))
{
if (result == *(uint64_t*)rcx_9)
goto label_180003dcb;
result = *(uint64_t*)((char*)result + 8);
}
if (*(uint8_t*)((char*)result + 0x18))
{
flNewProtect = 0x1000;
dwSize = page_base;
void* var_38_1 = &arg_8;
VirtualProtect(GetCurrentProcess(), &dwSize, &flNewProtect, 4);
uintptr_t page_base_2 = page_base;
int64_t r10_1 = 0;
int64_t i_1 = 0x1000;
int64_t i;
do
{
uint8_t rax_15 = *(uint8_t*)page_base_2;
page_base_2 += 1;
int64_t rcx_12 = r10_1;
uint8_t rotated_nibble = rax_15 >> 4 | rax_15 << 4;
int64_t rax_16;
int64_t rdx_2;
rdx_2 = HIGHQ(-0x7777777777777777 * r10_1);
rax_16 = LOWQ(-0x7777777777777777 * r10_1);
*(uint8_t*)(page_base_2 - 1) = rotated_nibble;
r10_1 += 1;
*(uint8_t*)(page_base_2 - 1) = rotated_nibble ^ page_guard_key[rcx_12 - (rdx_2 >> 3) * 0xf];
i = i_1;
i_1 -= 1;
} while (i != 1);
flNewProtect = 0x1000;
dwSize = page_base;
void* var_38_2 = &arg_8;
VirtualProtect(GetCurrentProcess(), &dwSize, &flNewProtect, 0x20);
return set_page_flag(arg1, page_base, false);
}
}
return result;
}
MollyDll decryption routine is quite simple, it first xor using a known key and then rotate the byte nibbles.
We can write a simple python script that decrypt molly.exe
.text section:
import sys
import pefile
key = [0x66,0x6f,0x72,0x67,0x69,0x76,0x65,0x6d,0x65,0x66,0x61,0x74,0x68,0x65,0x72]
def invert_nibbles(b):
return (b & 0xf0) >> 4 | (b & 0x0f) << 4
def main():
if len(sys.argv) != 2:
sys.exit(1)
pe_path = sys.argv[1]
try:
pe = pefile.PE(pe_path)
except Exception as e:
sys.exit(1)
text_section = None
for section in pe.sections:
if b'.text' in section.Name:
text_section = section
break
if text_section is None:
sys.exit(2)
data = bytearray(text_section.get_data())
pages = text_section.SizeOfRawData // 0x1000
for i in range(pages):
offset = i * 0x1000
for j in range(0x1000):
data[offset + j] = invert_nibbles(data[offset + j])
data[offset + j] ^= key[j % len(key)]
pe.set_bytes_at_offset(text_section.PointerToRawData, bytes(data))
pe.write('molly_dec.exe')
if __name__ == "__main__":
main()
Binary Dynamic Decryption (Bonus)
One interesting feature of Molly is its ability to re-encrypt old unlocked pages once the maximum number of decrypted pages has been reached. However, due to the binary’s size and an incorrectly set limit of 0x10
, Molly allowed 15 pages to remain decrypted, and no re-encryption occurs after the binary exits.
How can we exploit this behavior?
First, configure x64dbg to establish breakpoints prior to Molly’s start and before its termination, and disable the general exception breakpoint.
After running Molly, the console should close while the process remains active. Next, open Scylla to dump the process.
Even if the binary is not fully decrypted, we can still analyze its core components to reconstruct the flag.
Binary Analysis
Upon examining the main function, it becomes clear that Molly compiles and executes a Lua script containing custom functions: epic_gaming1
, epic_gaming2
, and epic_gaming3
; and subsequently defines a global variable flag
with the input provided when running the binary.
Beautified molly script:
local function a(b)
local c = {}
b = b:sub(5, -2)
for d in string.gmatch(b, "[^_]+") do
table.insert(c, d)
end
return c
end
local function e(f)
if #f ~= 4 then
return 1
end
local g = 100 + 89 - (84 - 608 / ((945 + 12 + 92 + 65 - 31) / 57) - 23) - 60
local h = 2370000 / (12 + 93 - 95 / (646 / (85 - 3468 / 68))) / 100 - 52 - 63 - 71
local i = 149 - (-15 + 8800 / (6248 / (125 - (104 - (17502 / (72 / 12) + 93) / 35 + 36))))
local j = (52808 / (31 - 1800 / (51 - (95 - 448448 / (147 - 83) / 91) + 42)) - 20) / 66
if string.char(g, h, i, j) ~= f then
return 1
end
return 0
end
local k = a(flag) -- split content inside {} by _
local l = epic_gaming2(k[1]) -- run first part check (c side)
l = l + e(k[2]) -- run second part check (lua side)
l = l + epic_gaming3(k[3]) -- run third part check (c side)
l = l + epic_gaming1(k[4]) -- run fourth part check (c side)
return l
epic_gaming2
# epic_gaming2 solve
xored = b"\xe8\x9d\x8e\x8b\xbc\xd4\x8d"
key = [ 0xDE, 0xAD, 0xBE, 0xEF ]
part1 = [ xored[i] ^ key[i % len(key)] for i in range(len(xored)) ]
print(''.join([chr(c) for c in part1])) # 600dby3
__int64 __fastcall epic_gaming2(__int64 a1)
{
// ...
if ( v3 == 7 )
{
*(_DWORD *)v10 = 0xEFBEADDE;
*(_QWORD *)v11 = 0x8DD4BC8B8E9DE8LL;
v4 = v11;
v5 = 0;
v6 = v2 - (_QWORD)v11;
while ( 1 )
{
v7 = v4[v6];
*v4 ^= v10[v5 & 3];
// ...
return 1LL;
}
600dby3
epic_gaming_lua
local g = 100 + 89 - (84 - 608 / ((945 + 12 + 92 + 65 - 31) / 57) - 23) - 60
local h = 2370000 / (12 + 93 - 95 / (646 / (85 - 3468 / 68))) / 100 - 52 - 63 - 71
local i = 149 - (-15 + 8800 / (6248 / (125 - (104 - (17502 / (72 / 12) + 93) / 35 + 36))))
local j = (52808 / (31 - 1800 / (51 - (95 - 448448 / (147 - 83) / 91) + 42)) - 20) / 66
print(string.char(g, h, i, j))
d3@r
epic_gaming3
&& ((unsigned __int16)v2[1] | (unsigned __int16)(*v2 << 8)) == COERCE_INT('0p')
&& ((unsigned __int16)v2[3] | (unsigned __int16)(v2[2] << 8)) == COERCE_INT('3n')
&& ((unsigned __int16)v2[5] | (unsigned __int16)(v2[4] << 8)) == COERCE_INT('50')
&& ((unsigned __int16)v2[7] | (unsigned __int16)(v2[6] << 8)) == COERCE_INT('ur')
&& ((unsigned __int16)v2[9] | (unsigned __int16)(v2[8] << 8)) == COERCE_INT('c3') )
0p3n50urc3
epic_gaming1
text = "0n0_d@y_w3_w1ll_m33t"
text += " " * (44 - len(text))
part2_c = [0, 182, 260, 429, 796, 680, 1188, 1043, 1560, 1548, 1520, 2618, 1656, 2548, 2604, 2145, 3184, 2346, 2250, 4332, 2600, 2877, 1870, 3174, 3120, 2050, 2080, 4050, 3668, 4089, 3060, 2573, 3456, 2673, 3876, 4270, 4716, 2960, 4180, 4212, 4400, 4141, 6216, 3612]
print(part2_c[:44])
part2 = ""
from string import printable
for i in range(1, 44):
sum = part2_c[i] / i
for c in printable:
if ord(c) + ord(text[i]) == sum:
part2 += c
break
# aHR0cHM6Ly9wYXN0ZWJpbi5jb20vcmF3L1RZc0NLNEt4 => base64 => "https..."
part2 = "a" + part2
print(part2)
aHR0cHM6Ly9wYXN0ZWJpbi5jb20vcmF3L1RZc0NLNEt4
Final flag is TRX{600dby3_d3@r_0p3n50urc3_aHR0cHM6Ly9wYXN0ZWJpbi5jb20vcmF3L1RZc0NLNEt4}
Molly Revenge
Due to Molly dynamic dumping approach, Molly Revenge presents a hardened version of Molly, incorporating anti-debug checks, an new encryption/decryption routine, fewer unlocked pages limit, and rigorous flag integrity verification.
Molly Revenge Decryptor
import sys
import pefile
from Crypto.Cipher import ChaCha20
chacha_key = bytes([
81, 90, 238, 246,
49, 96, 101, 57,
154, 226, 109, 170,
143, 34, 55, 109,
174, 11, 21, 112,
250, 101, 27, 136,
129, 218, 111, 100,
38, 121, 11, 3
])
chacha_nonce = bytes([0xd0, 0, 0, 0, 0, 0, 0, 0])
def main():
if len(sys.argv) != 2:
sys.exit(1)
pe_path = sys.argv[1]
try:
pe = pefile.PE(pe_path)
except Exception as e:
sys.exit(1)
text_section = None
for section in pe.sections:
if b'.text' in section.Name:
text_section = section
break
if text_section is None:
sys.exit(2)
data = bytearray(text_section.get_data())
pages = text_section.SizeOfRawData // 0x1000
for i in range(pages):
offset = i * 0x1000
cipher = ChaCha20.new(key=chacha_key, nonce=chacha_nonce) # fancy way to use ChaCha20 since nonce doesn't change
data[offset:offset + 0x1000] = bytearray(cipher.decrypt(data[offset:offset + 0x1000]))
pe.set_bytes_at_offset(text_section.PointerToRawData, bytes(data))
pe.write('molly_revenge_dec.exe')
if __name__ == "__main__":
main()
Special Notes
Molly reimplements the Byfron Hyperion anti-tamper feature, originally adopted in Roblox, in a simpler CTF-style manner. This explains the fancy descriptions.
[Roblox] Developers,
We know how important anti-cheat and security are for you and the entire Roblox community. At RDC 1,7k, we announced that we’re turning our focus to cheat prevention. To help get us there, we’re thrilled to welcome Byfron, a leader in anti-cheat solutions, to Roblox. Together, we are combining forces to greatly expand Roblox’s anti-cheat capabilities.
Byfron has developed a state-of-the-art anti-cheat solution that is being utilized by some of the world’s largest game publishers. The team is also passionate about competitive gaming and security, so we think they’re going to be a great match for our community. With their deep domain knowledge and security engineering expertise, we will be accelerating our roadmap to build robust anti-cheat and security solutions. This includes client-side and server-side anti-cheat, alt accounts detection, and additional tools for developers to combat cheaters. Integrating Byfron’s technology into the Roblox platform will improve experience security, protect the competitive landscape, and to allow developers to spend more time building their experiences.
We look forward to sharing more in the coming months. In the meantime, please join us in welcoming Byfron to the Roblox community!