We got 3rd place which was quite an improvement from the result of our previous year’s participation! I also managed to get a First Blood for Wordle.
Anyway I apologize for my slight inactivity at the start of the year — I was quite busy so I didn’t participate in any CTFs or events to write about.
🎵 Coldplay X Selena Gomez - Let Somebody Go (Kygo Remix) 🎵
The executable can be extracted with PyInstaller Extractor.
But first, we need to find out what Python version the executable uses because the same version should be used to run the extractor script.
Python Version Identification
By first running the extractor script once with any Python version, the output will tell us what Python version is detected to be used in the executable. This is mentioned in PyInstaller Extractor’s FAQ as well as my older writeup for CSAW CTF 2021.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
C:\Downloads\ctfsg\rev>pyinstextractor.py calc.exe[+] Processing calc.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 307
[+] Length of package: 29103488 bytes
[+] Found 167 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pyqt5.pyc
[+] Possible entry point: calc.pyc
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python307 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: calc.exe
You can now use a python decompiler on the pyc files within the extracted directory
For this executable, the output tells us that 307 is the Python version used which seems to mean Python 3.7.
For additional confirmation, we can also check the Python DLLs created in and loaded from %TEMP% when you run the PyInstaller-created executable. In this case when running calc.exe, we can see both python3.dll and python37.dll with the exact version information of 3.7.8.
There’s also strings in calc.exe for python3.dll and python37.dll.
Extraction
With the Python version identified, I installed Python 3.7.0 to rerun the extractor with.
C:\Users\a\Desktop\ctfsg\calc>python3 pyinstextractor.py calc.exe[+] Processing calc.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 307
[+] Length of package: 29103488 bytes
[+] Found 167 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pyqt5.pyc
[+] Possible entry point: calc.pyc
[+] Found 186 files in PYZ archive
[!] Error: Failed to decompress PYZ-00.pyz_extracted\Crypto\__init__.pyc, probably encrypted. Extracting as is.
...
[!] Error: Failed to decompress PYZ-00.pyz_extracted\py_compile.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pycalc\__init__.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pycalc\evaluator.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pycalc\pyqt5_vc\__init__.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pycalc\pyqt5_vc\qt_controller.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pycalc\pyqt5_vc\qt_view.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pydoc.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pydoc_data\__init__.pyc, probably encrypted. Extracting as is.
[!] Error: Failed to decompress PYZ-00.pyz_extracted\pydoc_data\topics.pyc, probably encrypted. Extracting as is.
...
[!] Error: Failed to decompress PYZ-00.pyz_extracted\zipfile.pyc, probably encrypted. Extracting as is.
[+] Successfully extracted pyinstaller archive: calc.exe
You can now use a python decompiler on the pyc files within the extracted directory
While we do get interesting files like calc.pyc, pycalc\decryptor.pyd, and pycalc\randomizer.pyd in the root extraction folder calc.exe_extracted,
the other modules’ .pyc files could not be extracted nicely from PYZ-00.pyz as they were encrypted by PyInstaller. Thus, we are left with encrypted files in the PYZ-00.pyz_extracted folder like the following interesting ones:
But do not fear! We can actually decrypt those since there’s a pyimod00_crypto_key.pyc file in the calc.exe_extracted folder. This file stores the key used for the PyInstaller encryption. We can recover this key by decompiling the .pyc file with decompyle3.
1
2
3
4
5
6
7
8
9
C:\Desktop\ctfsg\calc\calc.exe_extracted>decompyle3pyimod00_crypto_key.pyc# decompyle3 version 3.9.0a1# Python bytecode version base 3.7.0 (3394)# Decompiled from: Python 3.7.0 (v3.7.0:1bf9cc5093, Jun 27 2018, 04:59:51) [MSC v.1914 64 bit (AMD64)]# Embedded file name: build\calc\pyimod00_crypto_key.py# Compiled at: 1995-09-28 00:18:56# Size of source mod 2**32: 51 byteskey='h1^F{?5U@X17h)xM'# okay decompiling pyimod00_crypto_key.pyc
With the encryption key recovered to be h1^F{?5U@X17h)xM, we can then use the decryption script provided once again in PyInstaller Extractor’s FAQ to recursively decrypt all encrypted .pyc files in the PYZ-00.pyz_extracted folder.
Do note that we have to modify the script to write the appropriate .pyc header for the Python version that we are using. Since we are using Python 3.7.0, the header will be \x42\x0d\x0d\x0a\0\0\0\0\0\0\0\0\0\0\0\0.
The recursive decryption script below is also modified to use PyCryptodome instead of tinyaes since I already had PyCryptodome installed.
Now that we have the decrypted interesting pycalc.pyc files as well as calc.pyc, we can use decompyle3 again on all of them to recover the original Python source code. We’ll handle the .pyd files pycalc\decryptor.pyd and pycalc\randomizer.pyd later differently because they cannot be decompiled this way.
Source Code Analysis
Of the decompiled files, calc.py and pycalc\evaluator.py are the important ones. The rest appear to be unmodified supporting files from this open-sourced PyCalc project.
C:\Users\a\Desktop\ctfsg\calc\calc.exe_extracted>decompyle3calc.pyc# decompyle3 version 3.9.0a1# Python bytecode version base 3.7.0 (3394)# Decompiled from: Python 3.7.0 (v3.7.0:1bf9cc5093, Jun 27 2018, 04:59:51) [MSC v.1914 64 bit (AMD64)]# Embedded file name: calc.py"""PyCalc is a simple calculator built using Python."""importsysfrompycalc.evaluatorimportEvaluatordefmain():"""Main function."""pyqt5_app()defpyqt5_app():"""PyQt5 implementation."""fromPyQt5.QtWidgetsimportQApplicationfrompycalc.pyqt5_vc.qt_viewimportPyCalcUifrompycalc.pyqt5_vc.qt_controllerimportPyCalcCtrlpycalc=QApplication(sys.argv)view=PyCalcUi()model=Evaluator()controller=PyCalcCtrl(model=model,view=view)controller.run()sys.exit(pycalc.exec_())if__name__=='__main__':main()# okay decompiling calc.pyc
C:\Users\a\Desktop\ctfsg\calc\calc.exe_extracted>decompyle3PYZ-00.pyz_extracted\pycalc\evaluator.pyc# decompyle3 version 3.9.0a1# Python bytecode version base 3.7.0 (3394)# Decompiled from: Python 3.7.0 (v3.7.0:1bf9cc5093, Jun 27 2018, 04:59:51) [MSC v.1914 64 bit (AMD64)]# Embedded file name: pycalc\evaluator.py"""PyCalc is a simple calculator built using Python and the MVC pattern."""from__future__importannotationsfrom.importERROR_MSGfrom.randomizerimport*fromCrypto.CipherimportAESfromCrypto.Util.Paddingimportunpadfrom.decryptorimportdecrypt_flagfromdataclassesimportdataclassCOUNT=50OBTAIN_STRING='CTFSG{H0w_d1d_y0u_ge7_7h1s?}'FAKE_FLAG='CTFSG{s0_e4sy?!}'@recursive_rand_mode@dataclassclassEvaluator:__doc__='Evaluator class.'def__init__(self):self.count=0defevaluate(self,expression):"""Evaluate an expression."""ifself.count>=COUNT:ifexpression==OBTAIN_STRING:returndecrypt_flag(self)try:result=str(eval(expression,{'__import__':{}},{}))exceptException:result=ERROR_MSGself.count+=1returnresultdefevaluate1(self,expression):returnself.evaluate(expression)...# evaluate2-9 omitted for brevity...defevaluate10(self,expression):returnself.evaluate(expression)defopenPopup(self,expression):returnTruedefflag(self,expression):ifself.count>=COUNT//10:self.count=0returnFAKE_FLAGreturnself.evaluate(expression)# okay decompiling PYZ-00.pyz_extracted\pycalc\evaluator.pyc
calc.py contains main() and uses an Evaluator from pycalc.evaluator to handle the calculation of expressions.
In pycalc\evaluator.py, def flag(self, expression) appears to just print out a fake flag so we can ignore that.
The code in def evaluate(self, expression) is more noteworthy: if the expression to evaluate matches the string CTFSG{H0w_d1d_y0u_ge7_7h1s?} (which is technically impossible to achieve just by operating calc.exe’s GUI), decrypt_flag(self) will be called which is imported from .decryptor.
Another thing to notice is that the Evaluator class is decorated with @recursive_rand_mode which is imported from .randomizer.
So how do we find out the functionalities of .decryptor and .randomizer? Their code lies in pycalc\decryptor.pyd and pycalc\randomizer.pyd but unfortunately as mentioned earlier we cannot decompile them the same way we do for Python bytecode.
Inspecting Live Modules
Fortunately, even though .pyd files are basically Windows DLLs and cannot be decompiled by decompyle3, we can still directly import them in Python as a module and inspect their functionality as described in Python’s documentation.
After importing decryptor, we can use dir and inspect.getmembers() to print out the constants and functions that the module contains. You can also do a decryptor.decrypt_flag.func_globals instead of inspect.getmembers(decryptor).
There’s a COUNT constant initialized to 366105, a FAKE_FLAG string, a 48-byte HASH and a 16-byte IV. The AES module is imported, and there are also the functions decrypt_flag and unpad.
Black-box Testing
From the source code we saw earlier in pycalc\evaluator.py we know that decrypt_flag takes in a Evaluator object as an argument. If I try to pass in an integer 1 into decrypt_flag,
the traceback shows that the function tries to access the count attribute of the object we provided. This made me suspect that the decrypt_flag function is checking if the object’s count attribute is equals to the COUNT constant of 366105 before proceeding to the actual flag decryption.
As such, I made a simple test class and object with a count attribute of 366105 and passed it into decrypt_flag. To my delight, it worked!
NY Times bought Wordle for seven figures. But I have the game right here!
author: mcdulltii
Attached: wordle.exe
wordle.exe is a 64-bit command-line version of the popular Wordle game. For the uninitiated, it’s a game where you try to guess a particular 5-letter word.
Initial Analysis
From the pseudocode generated by IDA Pro with variables and functions renamed by me, we can see that there’s a function play_game_main being executed at the start of main before anything else.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int__cdeclmain(intargc,constchar**argv,constchar**envp){IMAGE_DOS_HEADER*kernel32_dll;// rbx
LDR_DATA_TABLE_ENTRY*i;// rcx
// variable declarations omitted
game_result_tries=play_game_main(*&argc,argv,envp);game_result_tries_=game_result_tries;if(game_result_tries>0){// proceeds to do other stuff here which we'll cover later
}returngame_result_tries;}
If the return value is more than 0, the code will continue to do other things in main. We’ll explore that later in Main Function and instead look into play_game_main first to find out how the return value is determined.
Game Function
The play_game_main function is huge, but basically it handles everything required for the Wordle game like the game logic and the colored terminal output you eventually see.
The return value is initialized to 0 near the start of the function. And towards the end of the function, we can see that the return value is actually modified by a variable that keeps track of the “Total tries” taken to guess the word in the Wordle game. The maximum number of tries possible is 6.
If the key “q” is entered as your guess, the game will quit and the return value will be -1.
1
2
3
4
5
QUIT_GAME:v89=std::ostream::operator<<(std::cout,flush);v90=sub_7FF64E611210(v89," You quit the game!");std::ostream::operator<<(v90,flush);return_value=-1;
Now that we know the return value essentially ranges from -1 to 6, let’s continue analyzing the rest of the main function.
Main Function
If the return value is more than 0 from the play_game_main function, the code proceeds to resolve for the address of kernel32.dll. This is done by walking the doubly-linked InLoadOrderModuleList pointed to by PEB->Ldr, generating a custom hash of each loaded DLL’s lowercased name, and comparing it with 0x454A4141.
int__cdeclmain(intargc,constchar**argv,constchar**envp){// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
game_result_tries=play_game_main(*&argc,argv,envp);game_result_tries_=game_result_tries;if(game_result_tries>0){kernel32_dll=::kernel32_dll;if(!::kernel32_dll){for(i=NtCurrentPeb()->Ldr->InLoadOrderModuleList.Flink;;i=i->InLoadOrderLinks.Flink){wchar_buf=i->BaseDllName.Buffer;checksum=0x3E9F4C3;end=wchar_buf+2*(i->BaseDllName.Length>>1);if(wchar_buf!=end){do{wchar=*wchar_buf|' ';if((*wchar_buf-'A')>25u)wchar=*wchar_buf;wchar_buf+=2i64;// move to next wchar
checksum=0x1000193*(checksum^wchar);}while(wchar_buf!=end);if(checksum==0x454A4141)break;}}kernel32_dll=i->DllBase;::kernel32_dll=kernel32_dll;}Thread=CreateThread(0i64,0i64,thread_block_bin,0i64,0,0i64);result=write_pipe(kernel32_dll,game_result_tries_);WaitForSingleObject(Thread,0xFFFFFFFF);returnresult;}returngame_result_tries;}
Once the address of kernel32.dll has been resolved, a thread is created to execute the function thread_block_bin.
Another function write_pipe is also executed right after that. The resolved address of kernel32.dll and the return value of play_game_main are passed in as arguments.
Let’s first analyze the write_pipe function.
Pipe Function
The write_pipe function creates a named pipe and formulates data written to the pipe while using some anti-analysis techniques.
Dynamic WinAPI Resolution
Some WinAPIs are resolved dynamically in a way similar to how the address of kernel32.dll was resolved in the Main Function. The first example of the resolution routine used is seen at the top of the function where CreatedNamedPipeA is resolved.
__int64__fastcallwrite_pipe(__int64kernel32,intgame_result_tries){// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
export_dir=*(*(kernel32+offsetof(IMAGE_DOS_HEADER,e_lfanew))+kernel32+0x88);iterator_name=0;names=*(&export_dir->AddressOfNames+kernel32);while(1){name=(kernel32+*(names+4i64*iterator_name+kernel32));hash=0x94A9E28;char_=*name;iterator_char=name+1;if(char_){do{lowered_char=char_|' ';if((char_-'A')>25u)lowered_char=char_;hash=0x1000193*(hash^lowered_char);char_=*iterator_char++;}while(char_);if(hash==0xA4E7844C)break;}++iterator_name;}CreateNamedPipeA=(kernel32+*(kernel32+*(&export_dir->AddressOfFunctions+kernel32)+4i64**(kernel32+*(&export_dir->AddressOfNameOrdinals+kernel32)+2i64*iterator_name)));// rest of function omitted
}
The AddressOfNames array in the export directory table of kernel32.dll is iterated over for export names. Each export name is lowercased, hashed, and compared with pre-defined constant to locate the desired function name. Once located, the array index will be used for the AddressOfNameOrdinals and AddressOfFunctions arrays to retrieve the actual function address which in this case is the address of CreateNamedPipeA.
Since there weren’t too many functions being resolved, I just set a breakpoint at each function call to find out what the resolved WinAPI functions were.
String Obfuscation
Simple string obfuscation is employed next: \\.\pipe\crack is XOR-decoded and then passed into our resolved CreateNamedPipeA to create a duplex named pipe.
1
2
3
4
5
6
7
8
9
10
11
// omitted start of function where CreateNamedPipeA is resolved
pipe_name.m128i_i64[0]=0xD4629F24391E66D3ui64;pipe_name.m128i_i64[1]=0x83537A5E1527B560ui64;xor_key.m128i_i64[0]=0xB112F65465303A8Fui64;*pbDebuggerPresent=0x8353113D7455D63Cui64;xor_key.m128i_i64[1]=0x8353113D7455D63Cui64;pipe_name=_mm_xor_si128(_mm_load_si128(&pipe_name),xor_key);// xor_decoded: \\.\pipe\crack
hPipe=CreateNamedPipeA(&pipe_name,PIPE_ACCESS_DUPLEX);if((hPipe-1)>0xFFFFFFFFFFFFFFFDui64)return1i64;// resolve more functions next
Writing Pipe Data
ConnectNamedPipe is then resolved and called on the created pipe. WriteFile is also resolved but not yet used as the data to be sent on the pipe will be formulated first as described next.
In short, the return value from play_game_main (described in Game Function) which represents the total number of tries in the Wordle game is passed into this write_pipe function and calculated into a new value with the simple formula:
$ value = 40 * game\_result\_tries - 1$.
// omitted start of function where pipe is created and connected to
value=40*game_result_tries-1;start=end;if(value>=0)// convert number to string
{do{*--start=value%10u+'0';// limits digit chars to be '0'-'9'
value/=10u;// iterate over each digit place
}while(value);}else{value_=1-40*game_result_tries;do{*--start=value_%10+'0';value_/=10u;}while(value_);*--start='-';}string.contents.data=0i64;string.size=0i64;reserved=15i64;string.reserved=15i64;if(start!=end){strcpy_maybe(&string,start,end-start);reserved=string.reserved;}p_string=&string;if(reserved>=0x10)p_string=string.contents.data;zero=0;string_len=-1i64;do++string_len;while(p_string->contents.buffer[string_len]);WriteFile(hPipe,p_string);CloseHandle(hPipe);// omitted cleanup code
The new formulated value is then converted into a string character by character, and finally written into the named pipe with the resolved WriteFile.
Where will this value be received and used? Let’s look back at the thread function that was created from main.
Created Thread
The thread function repeats the same procedure seen in main to resolve the base address of kernel32.dll.
Then, it gets a handle to the resource BLOCK_BIN with FindResourceW and executes multiple WinAPI functions that are dynamically resolved the same way as the ones in Pipe Function.
In summary, this is what happens:
SizeOfResource is used to obtain the size of BLOCK_BIN
LoadResource is used to load BLOCK_BIN into memory
VirtualAlloc allocates a new memory space the size of the resource obtained from step 1
RtlCopyMemory copies the in-memory BLOCK_BIN into the new allocated memory space
Lastly, execution continues at the new memory space. This makes sense because the resource BLOCK_BIN is actually a PE file as detected by pestudio. You can see that the first few bytes are “4D 5A” which is the “MZ” header.
Let’s dump out this resouce and analyze it on its own.
Resource Executable
A function get_long_from_pipe is called at the start of main.
1
2
3
4
5
6
7
8
9
10
int__cdeclmain(intargc,constchar**argv,constchar**envp){// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
result=get_long_from_pipe(argc,(int)argv,(_locale_t)envp);// rest of code omitted
if(!result){// rest of code omitted
}}
The return value result is checked before continuing into the rest of the code so let’s check this function out first.
Receiving Pipe Data
The get_long_from_pipe function XOR-decodes the same pipe name we saw earlier in Pipe Function’s string obfuscation — \\.\pipe\crack — and reads from that named pipe.
// some stuff omitted
if(needs_decoding){for(i=0i64;i<0xF;++i)aPipeCrack[i]^=0x89F52B3945A9B135ui64>>(8*((unsigned__int8)i&7u));// xor decode pipe name
needs_decoding=0;}hPipe=(char*)CreateFileA(aPipeCrack,FILE_SHARE_READ,FILE_SHARE_READ,0i64,OPEN_EXISTING,FILE_ATTRIBUTE_NORMAL,0i64);// open existing pipe \\.\pipe\crack
if((unsigned__int64)(hPipe-1)>0xFFFFFFFFFFFFFFFDui64||!ReadFile(hPipe,response,3u,&NumberOfBytesRead,0i64))// read max 3 bytes
{return-1;}pipe_response.contents.data=0i64;pipe_response.size=0i64;pipe_response.reserved=15i64;response_len=-1i64;do++response_len;while(response[response_len]);strcpy_maybe((void**)&pipe_response,response,response_len);p_pipe_response=&pipe_response;if(pipe_response.reserved>=0x10)p_pipe_response=(string*)pipe_response.contents.data;converted_response=strtol(p_pipe_response->contents.buffer,&EndPtr,10);// convert string to long, base 10
// error handling omitted
long_from_pipe=converted_response;
The data read is converted into a long and stored in a global variable which I named long_from_pipe. 0 will be returned if everything is successful and -1 will be returned if there are errors.
Main Function
Back in the main function after get_long_from_pipe is called, an array of 31 mysterious number strings is initialized.
int__cdeclmain(intargc,constchar**argv,constchar**envp){// [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL-"+" TO EXPAND]
result=get_long_from_pipe(argc,(int)argv,(_locale_t)envp);enc_arr[0]="38";enc_arr[1]="71";enc_arr[2]="29";enc_arr[3]="73";enc_arr[4]="26";enc_arr[5]="116";enc_arr[6]="65";enc_arr[7]="2";enc_arr[8]="75";enc_arr[9]="35";enc_arr[10]="11";enc_arr[11]="32";enc_arr[12]="49";enc_arr[13]="20";enc_arr[14]="73";enc_arr[15]="49";enc_arr[16]="8";enc_arr[17]="44";enc_arr[18]="35";enc_arr[19]="32";enc_arr[20]="49";enc_arr[21]="41";enc_arr[22]="61";enc_arr[23]="49";enc_arr[24]="65";enc_arr[25]="44";enc_arr[26]="75";enc_arr[27]="35";enc_arr[28]="11";enc_arr[29]="32";enc_arr[30]="114";if(!result){input.size=0i64;input.reserved=15i64;input.contents.data=0i64;take_input(std::cin,&input);flag_len=0;input_data=input.contents.data;input_len=input.size;if(input.size){// rest of code omitted and shifted below
}// rest of code omitted
}
If the return value from get_long_from_pipe is 0, a string is read from standard input.
Each character of our input string is then modified by long_from_pipe (which holds the data received earlier from the named pipe) using this formula:
$ result = (chr + long\_from\_pipe) \ \% \ chr$.
Each modified result is converted into a string, and the ith modified result will be compared with the ith number string in the array of 31 mysterious strings.
// code omitted
if(input.size){arr_i=0i64;do{p_input=&input;if(input.reserved>=0x10)p_input=(string*)input_data;result_=(p_input->contents.buffer[arr_i]+long_from_pipe)%p_input->contents.buffer[arr_i];// modify input
end_result_str=start_result_str;if(result_>=0)// convert modified result into string
{do{*--end_result_str=result_%10u+48;result_/=10u;}while(result_);}// case for converting negative number into string omitted since it is unused
// some string initializations omitted for brevity
if(end_result_str!=start_result_str)// copy modified result string into a new stirng
{strcpy_maybe((void**)&final_result_str,end_result_str,start_result_str-end_result_str);reserved=final_result_str.reserved;final_result_len=final_result_str.size;final_result_data=final_result_str.contents.data;}enc_string=enc_arr[arr_i];enc_string_len=-1i64;do++enc_string_len;while(enc_string[enc_string_len]);// take each number string from the encoded array
p_final_result_str=&final_result_str;if(reserved>=0x10)p_final_result_str=(string*)final_result_data;not_equal=final_result_len!=enc_string_len||memcmp(p_final_result_str,enc_string,final_result_len);// string cleanup omitted
if(not_equal)break;++flag_len;++arr_i;}while((int)flag_len<input_len);if(flag_len>=31)// win
{v20=print_congrats(std::cout);std::ostream::operator<<(v20,flush);}}// string cleanup omitted
If all the 31 modified results derived from our input match their corresponding number strings in the array of 31 mysterious strings, a "Congrats!" message is printed.
So what input should we give?
Solution
To recap, our input to the resource executable is modified by the data received from the pipe, long_from_pipe, which is in turn modified from the total tries taken in the Wordle game returned by Game Function.
Though the total tries value returned can be 0 or -1 in some cases, the possible total tries for the solution can only range from [1-6] because the main function in wordle.exe will check if it is more than 0 before proceeding with the rest of the code as seen in Initial Analysis.
This means that the possible long_from_pipe values used to modify our input are 39, 79, 119, 159, 199, 239 based on the formula $ value = 40 * game\_result\_tries - 1$.
With this, we can bruteforce for what our input should be:
The long_from_pipe value of 239 seems to be the correct one, but the flag output appears to have extra characters caused by extra solutions to (c + long_from_pipe) % c == number_string. To fix this, I reduced the character set of c from [0x21-0x7f) to [0x41-0x7f) and also rejected lowercase letters as I suspected that the flag only used capital letters.
Revised script snippet:
1
2
3
4
5
6
7
8
9
# initialization of values omittedforlong_from_pipeinpipe_vals:flag=""fornumber_stringinenc_arr:forcinrange(0x41,0x7f):result=(c+long_from_pipe)%cifresult==number_stringandnotchr(c).islower():flag+=chr(c)print("{}: {}".format(long_from_pipe,flag))