BR34K_0F_STACK - Securinets CTF 2025
Not so long ago I've played the Securinets Quals 2025, the CTF had great binary tasks (although rev was over-kill in terms of difficulty, I must admit). My results were pretty terrific as I was able to solve only one of the rev tasks in time, but I've also managed to almost finish one of the other ones, it was finished in ~40 minutes after the end of the ctf. The task was called "BR34K_0F_STACK" and involved some unpacking and Delphi analysis with a little bit of crypto at the end, I liked it quite a lot so here's my writeup for the challenge.
We're given a simple 7z archive with the task inside of it: click. The password for archive is: SecurinetsQuals2@25
After unpacking the file we would only see one file - BR34K_0F_STACK .EXE. Let's hop into VM and open it just to see what are we dealing with:
Based on that "script-kiddy"-like interface we can already guess it's something fishy like Delphi or Devel Studio. But let's just to be sure drop it into something like Detect-It-Easy:
Uh-huh, not so fast. We cannot confirm what tools were used to create this app unless we unpack UPX. The binary file itself is also broken in terms of PE structure (it's missing the legacy MS-DOS header), which would make UPX not want to work with that, and as I'm pretty lazy to do manual unpacking, let's just dump it using Scylla and x64dbg in our VM, after fixing the IAT in dumped binary we should get a pretty clean one. Let's try to drop it into the Detect-It-Easy once more:
Nice! It really was written in Delphi, and now we can jump into IDA and open that dump in order to have a look at it.
In order to work with Delphi files it's nice to use DelphiHelper from eset: click. After setting up this plugin (as well as opening the original binary with the Load resources checkbox checked in) and downloading all the IDRs, you should be able to recover the DFMs data, which would give you the information about which function gets fired as a callback for which event (e.g. button press and such). Let's click Shift + Alt + F to trigger the DFM finder module of DelphiHelper, after that we should get a menu on the right with all the data about app's forms:
As you can see I've already went through the whole tree in order to find the check function, if you want to repeat it's: frmHud3D -> dxScene1 -> Root1 -> GuiLayer -> Root3 -> HudWindow1 -> btnCheck. In the properties you'd see the OnClick callback, let's hop into that function (it's address in memory would be 0x54F2F8 for those wondering). And let's press F5 to see what happens inside of it:
int __usercall TfrmHud3D_btnCheckClick@<eax>(int a1@<eax>, int a2@<ecx>)
{
// Variables decl.
// ... Some delphi init stuff.
sub_54F0C4();
// ... Probably some destruction delphi stuff.
sub_44D4F8();
sub_54EAC0();
// Finish the function..
}
I've cleaned it a bit and removed the pieces that are not relevant (it's clearly observable as they don't do anything meaningful, now we're left with a couple of functions, that seemingly take no arguments, but be careful with that as IDA is not that good at analysing the calling convention from binaries written in Delphi. But anyways, let's hop into those functions one by one, and roughly analyze what they're doing. Let's start with 0x54F0C4:
int __usercall sub_54F0C4@<eax>(int a1@<eax>, int a2@<edx>, int a3@<ebx>)
{
// decls, init.
if ( v23 && v22 )
{
v3 = 0;
v4 = unknown_libname_31(ExceptionList, v14, v15);
if ( v4 > 0 )
{
v5 = 1;
do
{
if ( *(_BYTE *)(v22 + v5 - 1) == 0x2D )
++v3;
++v5;
--v4;
}
while ( v4 );
}
if ( v3 >= 3 )
{
System::__linkproc__ LStrPos();
sub_404B78(&v20);
v6 = unknown_libname_31(ExceptionList, v14, v15);
sub_404BB8(v6 + 1, 1);
System::__linkproc__ LStrPos();
sub_404B78(&v18);
sub_404920();
v7 = System::__linkproc__ LStrPos();
sub_404BB8(v7, 1);
System::__linkproc__ LStrPos();
sub_404B78(&v19);
v8 = unknown_libname_31(ExceptionList, v14, v15);
sub_404BB8(v8 + 1, 1);
sub_404920();
sub_406B10();
sub_54C424();
sub_406B10();
sub_54C424();
v9 = unknown_libname_31(&v21, off_54A7B8, ExceptionList);
sub_404B18(v9);
sub_54B95C(v11, v12);
if ( (unsigned __int8)sub_54EEF8(v19, v20) )
a3 = 0;
else
LOBYTE(a3) = 1;
}
}
/// destructing...
}
Hm, that looks interesting. It looks like this thing is analyzing some string by checking for dashes and count of pieces separated by dashes, which is looking like some kinda key, and then if condition matches it performs some other functions in order to validate it seems like. Let's call it ValidateFlagMaybe, or something like that and then analyze remaining two function from the callback just in case. Next one on the list should be 0x44D4F8. Pretty quickly we can see it does not do much, it calls some vtable methods on the passed object by using another recursive function inside. It does not look to interesting, and perhaps is another destruction procedure, let's leave it at the moment. Let's also check 0x54EAC0:
BOOL __usercall sub_54EAC0@<eax>(HWND a1@<eax>)
{
/// decls.
GetWindowRect(a1, &Rect);
X = Rect;
System::Randomize();
v2 = 1001;
do
{
v14 = System::Random(v1);
v3 = System::__linkproc__ ROUND((double)v14);
v14 = System::Random(v4);
v5 = System::__linkproc__ ROUND((double)v14);
v14 = System::Random(v6) + 1;
v8 = System::__linkproc__ ROUND((double)v14);
if ( v8 == 2 )
v8 = -1;
v14 = System::Random(v7) + 1;
v9 = System::__linkproc__ ROUND((double)v14);
if ( v9 == 2 )
v9 = -1;
OffsetRect(&Rect, v3 * v8, v5 * v9);
MoveWindow(a1, Rect.left, Rect.top, Rect.right - Rect.left, Rect.bottom - Rect.top, -1);
--v2;
}
while ( v2 );
return MoveWindow(a1, X.left, X.top, X.right - X.left, X.bottom - X.top, -1);
}
Oh, that's probably the function that handles if the input is wrong! When you input an invalid decoded flag the program shakes the window while rejecting your input, it seems like it, let's rename it to ShakeWindowRandomly or something, and we'll go back to the suspected flag checker procedure at 0x54F0C4. First of all, it would be a good idea to jump into all the subroutines and decompile them in IDA just so that IDA has more context on those subroutines and can guess their types (including arguments) better. After that let's do a quick renaming of everything we know off. I would also rename the variable placed at [esp+30h] to "decoded_flag", as it's most likely what it validates. But you can do that without any guessing, if you launch the program and set a breakpoint at 0x54F0C4 you should see that it really is our decoded flag, and as well you would see that [esp+34h] is the username. I've also applied the patterns from findcrypt and was able to find some builtin Delphi funcs for number converting and whatnot, after a quick manual renaming paired with the plugin renaming we should get something like that:
int __usercall ValidateFlagMaybe@<eax>(char *a1@<eax>, _BYTE *a2@<edx>, int a3@<ebx>)
{
///....
if ( username && decoded_flag )
{
parts_amount = 0;
remaining_len = GetStringLength((int)decoded_flag);
if ( remaining_len > 0 )
{
current_sym_idx = 1;
do
{
if ( decoded_flag[current_sym_idx - 1] == 0x2D )
++parts_amount;
++current_sym_idx;
--remaining_len;
}
while ( remaining_len );
}
if ( parts_amount >= 3 )
{
v6 = System::__linkproc__ LStrPos(&dword_54F2F4, decoded_flag);
Delphi_Copy_404B78((int)decoded_flag, 1, (int)(v6 - 1), (int)&v22);
StringLength = GetStringLength(v22);
sub_404BB8(&decoded_flag, 1, StringLength + 1);
v8 = System::__linkproc__ LStrPos(&dword_54F2F4, decoded_flag);
Delphi_Copy_404B78((int)decoded_flag, 1, (int)(v8 - 1), (int)&v20);
sub_404920(&v22, v20);
v9 = System::__linkproc__ LStrPos(&dword_54F2F4, decoded_flag);
sub_404BB8(&decoded_flag, 1, (int)v9);
v10 = System::__linkproc__ LStrPos(&dword_54F2F4, decoded_flag);
Delphi_Copy_404B78((int)decoded_flag, 1, (int)(v10 - 1), (int)&v21);
v11 = GetStringLength(v21);
sub_404BB8(&decoded_flag, 1, v11 + 1);
sub_404920(&v21, (int)decoded_flag);
sub_406B10((int)&off_54EBDC, &v19);
FGint_Base10StringToGInt_54C424(v19, (int)v25);
sub_406B10((int)&off_54EBE4, &v18);
FGint_Base10StringToGInt_54C424(v18, (int)v24);
v15 = off_54A7B8;
v14 = GetStringLength((int)username);
v12 = sub_404B18(username);
sub_54B95C((int)VMT_54AC84_THash_Panama, (int)v12, v14, (int)&v23, (int)v15);
if ( (unsigned __int8)sub_54EEF8(v23, v25, v24, v21, v22) )
a3 = 0;
else
LOBYTE(a3) = 1;
}
}
///....
}
Yeah, cool, now it almost is readable. If you don't get something similar, maybe some arguments are missing, etc, then try to go into the subroutines and decompiling their inner functions, if you do that enough IDA should see the missing arguments, otherwise you'd have to correct IDA and point it to where the arguments are manually, which is not a huge deal, but it would take some time. If we'd go to check what dword_54F2F4, we should see that it is a dash symbol. So basically it find a dash symbol, takes a substring from the current string to that dash, copies it to another buffer, and then proceeds to call 0x404BB8 on the string. It repeats the said pattern a couple of times. The easiest way to find what 0x404BB8 is doing is just putting a breakpoint and analyzing the behaviour, however you can also do it statically, delphi's strings are not exactly a hard structure to analyze. The answer is simple - it's a substring that acts in place, it takes the first string as argument, and then two indexes by which it cuts the string, it's basically python's equivalent to doing string = string[1:StringLength + 1]. And as it acts in place, decoded_flag now just loses one "part" (parts are pieces that are separated by dash). So if the string was "xxx-yyy" it now becomes "yyy". Alright, let's rename it to SubstrInplace or something similar and continue.
Next unknown call is 0x404920. Again the decompilation is enough to understand what it does, but if you're not sure - set a breakpoint and investigate it yourself. This function is simply concatenating two strings that are passed as arguments and stores the result inplace in the first argument. Let's call it MergeStrings. Cool, let's also see what 0x406B10 is doing:
int __usercall sub_406B10@<eax>(int result@<eax>, char **a2@<edx>)
{
*(_DWORD *)Buffer = result;
if ( result )
{
if ( *(int *)(result + 4) >= 0x10000 )
{
return unknown_libname_26(a2, *(char **)(result + 4));
}
else
{
v5 = *(_DWORD *)(result + 4);
v3 = (HINSTANCE)sub_405FD4(**(_DWORD **)result);
StringA = LoadStringA(v3, v5, Buffer, 4096);
return sub_404748(a2, Buffer, StringA);
}
}
return result;
}
It looks messy, but basically we can see it just loads the string from resources, most likely first argument being resource and the second one - pointer to the buffer to hold the said resource. The function at 0x404B18 from flag checker procedure does nothing.
Next one is 0x54B95C, analysing it statically is a pain, but it returns different values based on the username, so let's do the breakpoint method again. And while we're at it, let's also dump all the other resources from it (Make sure to use username Securinets, to get the correct result):
The breakpoint should be set right after 0x54B95C call. I've marked which string is which resource on the screenshot. So after we do all the renaming:
v8 = System::__linkproc__ LStrPos(&dashSymbol, decoded_flag);
Delphi_Copy_404B78((int)decoded_flag, 1, (int)(v8 - 1), (int *)&first_two_parts_without_dash);
v9 = StringLength((int)first_two_parts_without_dash);
SubstrInplace((int *)&decoded_flag, 1, v9 + 1);
v10 = System::__linkproc__ LStrPos(&dashSymbol, decoded_flag);
Delphi_Copy_404B78((int)decoded_flag, 1, (int)(v10 - 1), (int *)&v22);
MergeStrings(&first_two_parts_without_dash, v22);
v11 = System::__linkproc__ LStrPos(&dashSymbol, decoded_flag);
SubstrInplace((int *)&decoded_flag, 1, (int)v11);
v12 = System::__linkproc__ LStrPos(&dashSymbol, decoded_flag);
Delphi_Copy_404B78((int)decoded_flag, 1, (int)(v12 - 1), (int *)&last_two_parts_without_dash);
v13 = StringLength((int)last_two_parts_without_dash);
SubstrInplace((int *)&decoded_flag, 1, v13 + 1);
MergeStrings(&last_two_parts_without_dash, decoded_flag);
LoadStringFromResource((int)&some_const_1_res);
FGint_Base10StringToGInt_54C424(v21, (int)some_const_1);// 68722765697091485723721317211100788462880275370593368117496797396254137498757
LoadStringFromResource((int)&some_const_2_res);
FGint_Base10StringToGInt_54C424(v20, (int)some_const_2);// 7088302623238842934503017061266983249110295497723677422985727247787796184548356392037437054001558709620406810910526838807521727206527643
v17 = off_54A7B8;
uname_len = StringLength((int)username);
also_uname = CopyUsername(username);
GetKeyForUsername((int)VMT_54AC84_THash_Panama, (int)also_uname, uname_len, (int)&flag_res_hex, (int)v17);// for uname = Securinets: flag_res_hex = 03029A1B2D1D2675A04CA0202AADD37DC79C60814D7E5ED8AB63969665B92950
if ( (unsigned __int8)ValidateFlagNumbers(
flag_res_hex,
some_const_1,
some_const_2,
(int)last_two_parts_without_dash,
(int)first_two_parts_without_dash) )
inv_result = 0;
else
LOBYTE(inv_result) = 1;
Alright, now let's analyze the final piece of the puzzle - the function ValidateFlagNumbers.
Again, let's click through all the inside functions just to be sure the arguments count is right.
int __userpurge ValidateFlagNumbers@<eax>(int a1@<eax>, int *a2@<edx>, int *a3@<ecx>, int a4, int a5)
{
//decls.
v5 = 0;
v25 = 0;
*(_DWORD *)a1a = *a3;
v33 = a3[1];
v34[0] = *a2;
v34[1] = a2[1];
v6 = a2 + 2;
v35 = a1;
unknown_libname_32(a1);
System::__linkproc__ AddRefRecord(v7, (int)off_54BACC);
System::__linkproc__ AddRefRecord(v8, (int)off_54BACC);
unknown_libname_32(a5);
unknown_libname_32(a4);
System::__linkproc__ AddRefRecord(v9, (int)off_54BACC);
System::__linkproc__ AddRefRecord(v10, (int)off_54BACC);
System::__linkproc__ AddRefRecord(v11, (int)off_54BACC);
System::__linkproc__ AddRefRecord(v12, (int)off_54BACC);
System::__linkproc__ AddRefRecord(v13, (int)off_54BACC);
System::__linkproc__ AddRefRecord(v14, (int)off_54BACC);
v24 = &savedregs;
v23[1] = (unsigned int)&loc_54F0B1;
v23[0] = (unsigned int)NtCurrentTeb()->NtTib.ExceptionList;
__writefsdword(0, (unsigned int)v23);
sub_54BEA0(a5, (int *)&v25);
FGint_Base256StringToGInt_54C290((int)v25, (int)v31);
sub_54BEA0(a4, (int *)&v25);
FGint_Base256StringToGInt_54C290((int)v25, (int)v30);
sub_54CE68((int)v34, (int)a2a);
FGint_MontgomeryModExp_54DB34(a1a, (int)a2a, v31, (int)a3a);
FGint_MontgomeryModExp_54DB34(v30, (int)a2a, v34, (int)a4a);
unknown_libname_973((char *)a2a, (int)a4a, (unsigned int)v26);
FGint_FGIntToBase256String_54C19C(&v25, 0, (int)v6);
FGint_ConvertBase256StringToHexString_54BD80((int)v25, &v25, 0, (int)&v35, (int)v6);
Delphi_CompareCall_404A64(v35, (int *)v25);
if ( v16 )
LOBYTE(v5) = 1;
else
v5 = 0;
// destructing..
return v5;
}
Let's go to unknown functions, 1-by-1, again. Let's start with 0x54BEA0, I'll highlight the main part of this function:
if ( GetStringLength(v11) % 2 == 1 )
sub_404964(v11, &dword_54BFB8);
else
sub_4046F0();
v2 = GetStringLength(v9) / 2;
if ( v2 > 0 )
{
v8 = v2;
v3 = 1;
do
{
unknown_libname_25(&v7);
MergeStrings(v10, v7);
++v3;
--v8;
}
while ( v8 );
}
As we've already identified MergeStrings func, it's pretty easy to see that this function just copies one string into another, kind of. We can also confirm that behaviour by using debugger. So let's rename it to CopyString. Then let's go to 0x54CE68. Here we can see some strange stuff. Do you remember the functions that were identified as Base10StringToGInt? Well, this function does something with that "GInt" structure. Let's go back a little bit and analyze Base10StringToGInt so we can understand how it works:
_DWORD *__usercall FGint_Base10StringToGInt_54C424@<eax>(_BYTE *a1@<eax>, int a2@<edx>)
{
// decls.
v23 = a1;
unknown_libname_32((int)a1);
v14 = &savedregs;
v13 = &loc_54C65D;
ExceptionList = NtCurrentTeb()->NtTib.ExceptionList;
__writefsdword(0, (unsigned int)&ExceptionList);
while ( *v23 != 45 && (unsigned __int8)(*v23 - 48) >= 0xAu && GetStringLength((int)v23) > 1 )
sub_404BB8((int *)&v23, 1, 1);
Delphi_Copy_404B78((int)v23, 1, 1, (int)&v17);
Delphi_CompareCall_404A64(v17, dword_54C674);
if ( v3 )
{
v18 = 0;
sub_404BB8((int *)&v23, 1, 1);
}
else
{
v18 = 1;
}
while ( GetStringLength((int)v23) > 1 )
{
Delphi_Copy_404B78((int)v23, 1, 1, (int)&v16);
Delphi_CompareCall_404A64(v16, &dword_54C680);
if ( !v3 )
break;
sub_404BB8((int *)&v23, 1, 1);
}
v22 = GetStringLength((int)v23) / 4;
if ( GetStringLength((int)v23) % 4 )
++v22;
sub_405DC0(v22 + 1);
**(_DWORD **)(a2 + 4) = v22;
v4 = v22 - 1;
if ( v22 != 1 )
{
v5 = 1;
do
{
StringLength = GetStringLength((int)v23);
Delphi_Copy_404B78((int)v23, StringLength - 3, 4, (int)&v19);
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 4 * v5) = Delphi_StrToInt_409384(ExceptionList, v13, v14);
v7 = GetStringLength((int)v23);
sub_404BB8((int *)&v23, v7 - 3, 4);
++v5;
--v4;
}
while ( v4 );
}
v8 = Delphi_StrToInt_409384(ExceptionList, v13, v14);
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 4 * v22) = v8;
DestroyString(&v20);
while ( **(_DWORD **)(a2 + 4) != 1 || *(_DWORD *)(*(_DWORD *)(a2 + 4) + 4) )
{
sub_54C398(a2, 2u, &v21);
Sysutils::IntToStr(v10);
sub_404964(v20, v15);
}
sub_54C070(v20, a2);
/// destroy.
}
Basically what it does is take string in, then split it (from end) to chunks of 4 digits, and then convert them to base 10 int, storing in some array - that's what first loop does. After that it does another loop, if we step through it in debugger - it then converts the resulting array of numbers into base2, after which it does call 0x54C070, and if we look into it, you can see that it packs the given base-2 string repr of our number as 31-bit integers back into the array, basically compressing the original 4-digits numbers array into a lesser one. So:
- **(DWORD**)(gint + 4) = ARRAY_LEN;
- *(DWORD*)(gint + 4) is an array of ARRAY_LEN integers packed as 31-bit ones.
After we've figured that out, let's write a quick func for debugging to read the number from memory, I'll use pymem, you can use whatever you want, here's the code:
def read_gint(addr):
size = program.read_int(program.read_int(addr + 4))
arr = [program.read_int(program.read_int(addr + 4) + (i+1)*4) for i in range(size)]
val = 0
for i, limb in enumerate(arr):
val |= (limb & 0x7FFFFFFF) << (31 * i)
return val
You can use that func in order to make debugging a lot easier. Alright, back to the functions, let's go to next unknown one - it's 0x54CE68, if we look inside of it, and also use a debugger optionally:
unsigned int *__usercall SquareNum@<eax>(int a1@<eax>, int a2@<edx>)
{
int v4; // ebx
int v5; // ebx
int v6; // esi
__int64 v7; // rax
unsigned int *result; // eax
unsigned int v9; // [esp+0h] [ebp-30h]
unsigned int v10; // [esp+4h] [ebp-2Ch]
unsigned __int64 v11; // [esp+10h] [ebp-20h]
int v12; // [esp+18h] [ebp-18h]
unsigned int v13; // [esp+18h] [ebp-18h]
v10 = **(_DWORD **)(a1 + 4);
v9 = 2 * v10;
KB_System_DynArraySetLength(2 * v10 + 1);
**(_DWORD **)(a2 + 4) = 2 * v10;
if ( 2 * v10 )
{
v12 = 2 * v10;
v4 = 1;
do
{
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 4 * v4++) = 0;
--v12;
}
while ( v12 );
}
if ( v10 )
{
v13 = v10;
v5 = 1;
do
{
v6 = *(_DWORD *)(*(_DWORD *)(a1 + 4) + 4 * v5);
LODWORD(v7) = KB_System__llmul(v6, v6, 0);
v11 = v7 + *(unsigned int *)(*(_DWORD *)(a2 + 4) + 8 * v5 - 4);
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 8 * v5 - 4) = v11 & 0x7FFFFFFF;
if ( v10 >= v5 + 1 )
JUMPOUT(0x54CF68);
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 4 * (v5 + v10)) = v11 >> 31;
++v5;
--v13;
}
while ( v13 );
}
*(_BYTE *)a2 = 1;
while ( !*(_DWORD *)(*(_DWORD *)(a2 + 4) + 4 * v9) && v9 > 1 )
--v9;
result = (unsigned int *)(2 * v10);
if ( 2 * v10 != v9 )
{
KB_System_DynArraySetLength(v9 + 1);
result = *(unsigned int **)(a2 + 4);
*result = v9;
}
return result;
}
we will see that it's basically squaring a number (KB_System_llmul(v6, v6, 0) is a dead giveaway). So let's call it SquareNumber or something like that. Next function is unknown_libname_973. It does a complex operation in parts:
int __userpurge unknown_libname_973@<eax>(char *ecx0@<ecx>, int a2@<edx>, int eax0@<eax>, unsigned int a3)
{
Iddnscommon::TIdTextModeResourceRecord *v7; // ecx
int v9[6]; // [esp-Ch] [ebp-20h] BYREF
char a1[8]; // [esp+Ch] [ebp-8h] BYREF
int savedregs; // [esp+14h] [ebp+0h] BYREF
System::__linkproc__ AddRefRecord((int)ecx0, (int)off_54BACC);
v9[2] = (int)&savedregs;
v9[1] = (int)&loc_54D768;
v9[0] = (int)NtCurrentTeb()->NtTib.ExceptionList;
__writefsdword(0, (unsigned int)v9);
sub_54CCB8(eax0, a2, (int)a1);
sub_54D458(a1, ecx0, a3, v9[0]);
FGint_FGIntDestroy_54C684(v7);
__writefsdword(0, v9[0]);
return System::__linkproc__ AddRefRecord(&loc_54D76F);
}
If we look at 0x54CCB8 it's easy to see it multiplies two passed in numbers and stores result in 3rd (again, look at KB_System__llmul, it just multiplies two integers a1 and a2):
int __usercall sub_54CCB8@<eax>(int a1@<eax>, int a2@<edx>, int a3@<ecx>)
{
// decls.
v14 = **(_DWORD **)(a1 + 4);
v4 = **(_DWORD **)(a2 + 4);
v13 = v4 + v14;
KB_System_DynArraySetLength(v4 + v14 + 1);
if ( v4 + v14 )
{
v16 = v4 + v14;
v5 = 1;
do
{
*(_DWORD *)(*(_DWORD *)(a3 + 4) + 4 * v5++) = 0;
--v16;
}
while ( v16 );
}
if ( v4 )
{
v17 = v4;
v6 = 1;
do
{
v7 = 0;
if ( v14 )
{
v18 = v14;
v8 = 1;
do
{
LODWORD(v9) = KB_System__llmul(
*(_DWORD *)(*(_DWORD *)(a1 + 4) + 4 * v8),
*(_DWORD *)(*(_DWORD *)(a2 + 4) + 4 * v6),
0);
v15 = v9 + *(unsigned int *)(*(_DWORD *)(a3 + 4) + 4 * (v6 + v8) - 4) + v7;
*(_DWORD *)(*(_DWORD *)(a3 + 4) + 4 * (v6 + v8) - 4) = v15 & 0x7FFFFFFF;
v7 = v15 >> 31;
++v8;
--v18;
}
while ( v18 );
}
*(_DWORD *)(*(_DWORD *)(a3 + 4) + 4 * (v6 + v14)) = v7;
++v6;
--v17;
}
while ( v17 );
}
**(_DWORD **)(a3 + 4) = v13;
while ( !*(_DWORD *)(*(_DWORD *)(a3 + 4) + 4 * v13) && v13 > 1 )
--v13;
if ( **(_DWORD **)(a3 + 4) != v13 )
{
KB_System_DynArraySetLength(v13 + 1);
**(_DWORD **)(a3 + 4) = v13;
}
result = a1;
LOBYTE(result) = *(_BYTE *)a1;
*(_BYTE *)a3 = *(_BYTE *)a1 == *(_BYTE *)a2;
return result;
}
Next function is 0x54D458, it's pretty messy, but if you'll go into some subroutines of the main loop, and just look at the general structure of what it does - there's a lot of divisions going on and inside of 0x4058C8 you can see that stuff:
int __userpurge sub_4058C8@<eax>(__int64 _RAX@<edx:eax>, unsigned int a2, int a3)
{
char v3; // di
unsigned int v4; // ebx
int v5; // ecx
unsigned int v6; // ebp
int v7; // ecx
char v11; // [esp-4h] [ebp-14h]
v3 = 0;
v4 = a2;
v5 = a3;
if ( a3 || HIDWORD(_RAX) && a2 )
{
if ( _RAX < 0 )
{
_RAX = -_RAX;
v3 = 1;
}
if ( a3 < 0 )
{
v4 = -a2;
v5 = -a3 - (a2 != 0);
v3 ^= 1u;
}
v6 = v5;
v7 = 64;
v11 = v3;
_EDI = 0;
_ESI = 0;
do
{
LODWORD(_RAX) = 2 * _RAX;
__asm
{
rcl edx, 1
rcl esi, 1
rcl edi, 1
}
if ( _EDI >= v6 && (_EDI > v6 || _ESI >= v4) )
{
_EDI = (__PAIR64__(_EDI, _ESI) - __PAIR64__(v6, v4)) >> 32;
_ESI -= v4;
LODWORD(_RAX) = _RAX + 1;
}
--v7;
}
while ( v7 );
if ( (v11 & 1) != 0 )
return -_RAX;
}
else
{
LODWORD(_RAX) = _RAX / (unsigned __int64)a2;
}
return _RAX;
}
Which is also related to division. So we can guess it's either a whole division, or maybe a modulo, as the program works only with integers and does not work with floats. So let's just brute it by checking the arguments using that `read_gint` helper and trying two operations ourselves. You'd find that it's a modulo. So the entire function itself calls multiplication on two numbers, and then takes mod result. So it's something like "MulMod".
After that all the functions are in the clear and the only thing to figure out is the order of MontgomeryModExp (where is the exponent, modulo, etc). Just go into the debugger, read numbers using my provided helper func from the stack, and then we can try to bruteforce our way around, by switching the args order (we can also just think for a bit and look into the code, but that takes too much effort, personally not a fan). We'll find that first argument is base, second is modulo, third is the exponent and the last one is result pointer. Now let's rename every variable correctly and try to make sense of it:
CopyString(first_num_str, (int *)&mulmod_hex);
FGint_Base256StringToGInt_54C290((int)mulmod_hex, (int)first_num);
CopyString(second_num_str, (int *)&mulmod_hex);
FGint_Base256StringToGInt_54C290((int)mulmod_hex, (int)second_num);
SquareNum((int)modulo_root, (int)modulo);
FGint_MontgomeryModExp_54DB34((int)exp_base_1, (int)modulo, (int)first_num, (int)powmod_result);
FGint_MontgomeryModExp_54DB34((int)second_num, (int)modulo, (int)modulo_root, (int)powmod_second_result);
FGint_MulMod(modulo, (int)powmod_second_result, (int)powmod_result, (int)mulmod_result);
FGint_FGIntToBase256String_54C19C((int *)&mulmod_hex, 0, (int)unused, (int)mulmod_result);
FGint_ConvertBase256StringToHexString_54BD80((int)mulmod_hex, (int *)&mulmod_hex, 0, (int)&flag_hex, (int)unused);
Delphi_CompareCall_404A64(flag_hex, mulmod_hex);
here first_num/second_num are the variables derived from our decoded flag. modulo is the number that was squared, while modulo_root is (either from debugging or go into the original flag checker and see which resource it is) 687...757. exp_base_1 is second resource - 7088...643. And then the flag_hex is the derived key from our username. Now let's convert it to python to get a clearer picture and try to solve it.
The final stage of this problem is to solve this crypto problem, let's start by rewriting the flag checker function into Python so it's more clear what it does:
l2 = 68722765697091485723721317211100788462880275370593368117496797396254137498757
l1 = 7088302623238842934503017061266983249110295497723677422985727247787796184548356392037437054001558709620406810910526838807521727206527643
N = l2**2
# x, y - parts from our key parsed as nums.
d1 = pow(l1, x, N)
d2 = pow(y, l2, N)
res = (d1 * d2) % N
if res == 0x03029A1B2D1D2675A04CA0202AADD37DC79C60814D7E5ED8AB63969665B92950:
print('Correct!')
else:
print('Wrong!')
That's where the solving of this task stopped for me. I've tried for hours to actually solve this crypto problem, but my math skills were simply not enough. So after the CTF end I've asked a great crypto-player maximxls, to try and solve that (check him out, he's great). That's what his solution to that problem looked like:
p, q = 213863969755443035524230640901647169597, 321338679795746323343853926877767586281
l2 = p * q
l1 = 7088302623238842934503017061266983249110295497723677422985727247787796184548356392037437054001558709620406810910526838807521727206527643
N = l2**2
phi_N = p * (p - 1) * q * (q - 1)
phi_l2 = (p - 1) * (q - 1)
expected = 0x03029A1B2D1D2675A04CA0202AADD37DC79C60814D7E5ED8AB63969665B92950
def L(v):
return (pow(v, phi_l2, N) - 1) // l2
x = L(expected) * pow(L(l1), -1, l2) % l2
print(hex(x))
d1_current = pow(l1, x, N)
d2_needed = expected * pow(d1_current, -1, N) % N
y = pow(d2_needed, pow(l2, -1, phi_l2), N)
print(hex(y))
After running it we get the pair of numbers that's supposed to be our solve:
x = 0x515547c3729d312593773d9c187993ff2f802f2f35788dbcdc36b16b2c21119e
y = 0x2e238b31b75782735c8ce1e56b0e57992daf40642b5124809868ffeae2b6d666e50c029dd3b7909f817d4334b00f6f4fd24f6673c3884e6e67dbca040719099f
Let's convert them into the format that program waits for. So basically we'll put 4 dashes, 1 between the x, one between the end of x and start of y, and then one between y, and also remove 0x prefix, the key would look somewhat like that:
515547c3729d312593773d9c1879-93ff2f802f2f35788dbcdc36b16b2c21119e-2e238b31b75782735c8ce1e56b0e57992daf40642b5124809868ffe-ae2b6d666e50c029dd3b7909f817d4334b00f6f4fd24f6673c3884e6e67dbca040719099f
After we paste it into the program with username "Securinets" as provided in the task description, we should get this:
That's it. The task is solved.
In conclusion I'd say that I quite liked this task, it was really fun to solve, even though I could not do the crypto part myself, all the reverse engineering steps were pretty fun to do, and also learned quite a lot about how Delphi handles some stuff, so an interesting challenge nonetheless. The rev challenges were really good and fun, so all the thanks also goes to authors - Ad3M (the creator of the challenge in question) and 0xjio (some other fantastic revs on that CTF).