This page looks best with JavaScript enabled

CTF.SG CTF 2022 Writeups

Organized by CTF.SG from 12 March - 13 March

 ·  ☕ 24 min read  ·  🌈🕊️ rainbowpigeon

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) 🎵

Leaderboard with solves graph
Placing announcement

Details Links
Official CTF.SG CTF Discord https://discord.gg/Gn8DXWBV88
Official author writeups for my solved challenges https://link.medium.com/D0dGLaJ3lob

Reverse Engineering

Disappointment

WhY yOu nOt DoCToR yeT?

🚨Important:
Neither CTFSG{H0w_d1d_y0u_ge7_7h1s?} nor CTFSG{s0_e4sy?!} is the correct flag! But if you see them, you are on the right track.

author: mcdulltii

Attached: calc.exe

We are given calc.exe which Exeinfo PE identifies to be a PyInstaller executable.

calc.exe appears to be a calculator

calc.exe detected as PyInstaller executable by Exeinfo PE

PyInstaller Extraction

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.

Python DLLs in %TEMP% folder with version information

Extraction

With the Python version identified, I installed Python 3.7.0 to rerun the extractor with.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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,

1
2
3
4
5
calc.exe_extracted
├── calc.pyc
├── pycalc
│   ├── decryptor.pyd
│   └── randomizer.pyd

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:

1
2
3
4
5
6
7
8
9
calc.exe_extracted
├── PYZ-00.pyz_extracted
│   │   ├── pycalc
│   │   │   ├── __init__.pyc.encrypted
│   │   │   ├── evaluator.pyc.encrypted
│   │   │   └── pyqt5_vc
│   │   │       ├── __init__.pyc.encrypted
│   │   │       ├── qt_controller.pyc.encrypted
│   │   │       └── qt_view.pyc.encrypted

PyInstaller Decryption

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>decompyle3 pyimod00_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 bytes
key = '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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import glob
import zlib
from Crypto.Cipher import AES
from Crypto.Util import Counter
from pathlib import Path

CRYPT_BLOCK_SIZE = 16

# key obtained from pyimod00_crypto_key
key = bytes('h1^F{?5U@X17h)xM', 'utf-8')

for p in Path("PYZ-00.pyz_extracted").glob("**/*.pyc.encrypted"):
	inf = open(p, 'rb') # encrypted file input
	outf = open(p.with_name(p.stem), 'wb') # output file 

	# Initialization vector
	iv = inf.read(CRYPT_BLOCK_SIZE)

	ctr = Counter.new(128, initial_value=int.from_bytes(iv, byteorder='big'))
	cipher = AES.new(key, AES.MODE_CTR, counter=ctr)

	# Decrypt and decompress
	plaintext = zlib.decompress(cipher.decrypt(inf.read()))

	# Write pyc header
	# The header below is for Python 3.7
	outf.write(b'\x42\x0d\x0d\x0a\0\0\0\0\0\0\0\0\0\0\0\0')

	# Write decrypted data
	outf.write(plaintext)

	inf.close()
	outf.close()

	# Delete .pyc.encrypted file
	p.unlink()

Decrypted interesting files:

1
2
3
4
5
6
7
8
9
calc.exe_extracted
├── PYZ-00.pyz_extracted
│   │   ├── pycalc
│   │   │   ├── __init__.pyc
│   │   │   ├── evaluator.pyc
│   │   │   └── pyqt5_vc
│   │   │       ├── __init__.pyc
│   │   │       ├── qt_controller.pyc
│   │   │       └── qt_view.pyc

Decompiling to Python

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
C:\Users\a\Desktop\ctfsg\calc\calc.exe_extracted>decompyle3 calc.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."""
import sys
from pycalc.evaluator import Evaluator

def main():
    """Main function."""
    pyqt5_app()


def pyqt5_app():
    """PyQt5 implementation."""
    from PyQt5.QtWidgets import QApplication
    from pycalc.pyqt5_vc.qt_view import PyCalcUi
    from pycalc.pyqt5_vc.qt_controller import PyCalcCtrl
    pycalc = 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
C:\Users\a\Desktop\ctfsg\calc\calc.exe_extracted>decompyle3 PYZ-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__ import annotations
from . import ERROR_MSG
from .randomizer import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from .decryptor import decrypt_flag
from dataclasses import dataclass
COUNT = 50
OBTAIN_STRING = 'CTFSG{H0w_d1d_y0u_ge7_7h1s?}'
FAKE_FLAG = 'CTFSG{s0_e4sy?!}'

@recursive_rand_mode
@dataclass
class Evaluator:
    __doc__ = 'Evaluator class.'

    def __init__(self):
        self.count = 0

    def evaluate(self, expression):
        """Evaluate an expression."""
        if self.count >= COUNT:
            if expression == OBTAIN_STRING:
                return decrypt_flag(self)
        try:
            result = str(eval(expression, {'__import__': {}}, {}))
        except Exception:
            result = ERROR_MSG

        self.count += 1
        return result

    def evaluate1(self, expression):
        return self.evaluate(expression)

    ...
    # evaluate2-9 omitted for brevity
    ...

    def evaluate10(self, expression):
        return self.evaluate(expression)

    def openPopup(self, expression):
        return True

    def flag(self, expression):
        if self.count >= COUNT // 10:
            self.count = 0
            return FAKE_FLAG
        return self.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).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
>>> import decryptor
>>> from pprint import pprint
>>> import inspect
>>> dir(decryptor)
['AES', 'COUNT', 'FAKE_FLAG', 'HASH', 'IV', '__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '__test__', 'decrypt_flag', 'unpad']
>>> pprint(inspect.getmembers(decryptor))
[('AES',
  <module 'Crypto.Cipher.AES' from 'C:\\Users\\a\\Desktop\\Python37\\lib\\site-packages\\Crypto\\Cipher\\AES.py'>),
 ('COUNT', 366105),
 ('FAKE_FLAG', b'CTFSG{s0_e4sy?!}'),
 ('HASH',
  b'[\xaf\xa3\x97\x97l\x1c\xfcA&K\xb7\x95wt\x12\xf0\xa1\x927\xec\xd1\xdaJ'
  b'\x0f/\xa0\xf3\xd6\xa3`?C _\xb2\x17"%\xd2\xf9?\x8c}-\'\x1f\x8a'),
 ('IV', b'\x94@\x96\x80\x12\xf7\xd5\xcf\xda\xaf\x99mw\x82\x11\x16'),
 ('__builtins__', <module 'builtins' (built-in)>),
 ('__doc__', None),
 ('__file__',
  'C:\\Users\\a\\Desktop\\ctfsg\\calc\\calc.exe_extracted\\pycalc\\decryptor.pyd'),
 ('__loader__',
  <_frozen_importlib_external.ExtensionFileLoader object at 0x0000014A57434CC0>),
 ('__name__', 'decryptor'),
 ('__package__', ''),
 ('__spec__',
  ModuleSpec(name='decryptor', loader=<_frozen_importlib_external.ExtensionFileLoader object at 0x0000014A57434CC0>, origin='C:\\Users\\a\\Desktop\\ctfsg\\calc\\calc.exe_extracted\\pycalc\\decryptor.pyd')),
 ('__test__', {}),
 ('decrypt_flag', <cyfunction decrypt_flag at 0x0000014A5748DD90>),
 ('unpad', <function unpad at 0x0000014A595270D0>)]

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,

1
2
3
4
5
>>> decryptor.decrypt_flag(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "decryptor.py", line 12, in decryptor.decrypt_flag
AttributeError: 'int' object has no attribute 'count'

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!

1
2
3
4
5
6
7
8
9
>>> class Test:
...     def __init__(self):
...             self.count = 366105
...
>>> test = Test()
>>> test.count
366105
>>> decryptor.decrypt_flag(test)
b'CTFSG{I_ne3d_my_gr4ph1c_c4lcul4t0r}'

Flag: CTFSG{I_ne3d_my_gr4ph1c_c4lcul4t0r}

Wordle

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.

Playing wordle.exe in the terminal

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 __cdecl main(int argc, const char **argv, const char **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
  }
  return game_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.

Return value is the number of 'Total tries' taken to guess the word in the Wordle game

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int __cdecl main(int argc, const char **argv, const char **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);
    return result;
  }
  return game_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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
__int64 __fastcall write_pipe(__int64 kernel32, int game_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 )
    return 1i64;
    // 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$.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
    // 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:

  1. SizeOfResource is used to obtain the size of BLOCK_BIN
  2. LoadResource is used to load BLOCK_BIN into memory
  3. VirtualAlloc allocates a new memory space the size of the resource obtained from step 1
  4. 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.

pestudio detects 'BLOCK_BIN' resource as executable file

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 __cdecl main(int argc, const char **argv, const char **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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    // 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int __cdecl main(int argc, const char **argv, const char **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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
    // 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
enc_arr = list(range(31))
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

pipe_vals = [39, 79, 119, 159, 199, 239]

for long_from_pipe in pipe_vals:
    flag = ""
    for number_string in enc_arr:
        for c in range(0x21, 0x7f):
            result = (c + long_from_pipe) % c
            if result == number_string:
                flag += chr(c)
    print("{}: {}".format(long_from_pipe, flag))

Output:

1
2
3
4
5
6
7
C:\ctfsg\rev>wordle_solver.py
39: %
79: )25M,"D/;G,/,"D/
119: Q-Z]'u*T$6lWF!cF%oK*TWFNFK*T$6lW
159: yXAV^T>|%J7nV7ns>|7n;vb7n^sT>|%J
199: "U~C|)R/^2K~2K)R2KOE2KC|)R/^
239: CT#*FiSG{WOR3Df&9LrE_IS_!MA3DfE_BcY_WAR3Df&9LrE}

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 omitted
for long_from_pipe in pipe_vals:
    flag = ""
    for number_string in enc_arr:
        for c in range(0x41, 0x7f):
            result = (c + long_from_pipe) % c
            if result == number_string and not chr(c).islower():
                flag += chr(c)
    print("{}: {}".format(long_from_pipe, flag))

Output:

C:\ctfsg\rev>wordle_solver.py
39:
79: MDGD
119: QZ]TWFFKTWFNFKTW
159: XAV^T|JV|^T|J
199: U~C|R^K~KRKOEKC|R^
239: CTFSG{WORDLE_IS_MADE_BY_WARDLE}

Flag: CTFSG{WORDLE_IS_MADE_BY_WARDLE}


Thanks for reading :)

Share on

rainbowpigeon
WRITTEN BY
rainbowpigeon
OSCP | OSED | Burp Suite Certified Practitioner