Vision - VolgaCTF 2026 Qualifier

March 30, 2026 reverse ctf windows vmprotect

This would be a writeup for task called "Vision" from VolgaCTF 2026 Qualifier. I was the author for the task and it got 0 solves, so here would be main ideas and the intended strategy to actually extract flag from the challenge.

Challenge

The challenge itself starts with archive consisting 4 files: click

Files themselves are basically:

  • Readme
  • Driver (vision.sys)
  • Client (vision.exe)
  • Driver loader (kdmapper.exe)

So that's basically a windows kernel rev challenge, but the catch here is that driver itself is quite strange, as it does not register any proper IoCtl handlers, and the client is also not quite a usual one, it's highly packed and after dumping it from memory die would detect that it's VMProtect, also if we do some debugging we can detect that it really is. So how do we approach this whole thing? Let's start with by far the easiest and smallest binary - the driver itself.

Driver

Let's load it into IDA and try to find out what it does. DriverEntry basically launches two funcs, one for checking security cookie, other that does some logic. Let's go to second one:

__int64 sub_140013F2C()
{
  if ( (unsigned __int8)sub_140013854() )
    return (unsigned __int8)sub_140013B4C(sub_140013430, &qword_140016088) == 0 ? 0xC0000001 : 0;
  else
    return 3221225473LL;
}

It calls two functions and then based on their result returns DriverEntry return code, let's check first one first:

char sub_140013854()
{
  unsigned __int64 v0; // rax

  v0 = sub_140012F10();
  qword_140016080 = v0;
  if ( v0 )
  {
    qword_1400160B8 = sub_140013328(0x30E36D1E897986EFLL);
    qword_140016090 = sub_140013328(0x395B2A089A1451A6LL);
    qword_1400160D0 = sub_140013328(0xDF8BEECAC6C07EC6uLL);
    qword_1400160C0 = sub_140013328(0xF98DCFAF289581B8uLL);
    qword_1400160C8 = sub_140013328(0x8A74ED9FF85F95B8uLL);
    qword_140016190 = sub_140013328(0x8CD9141D23428B07uLL);
    qword_140016198 = sub_140013328(0xD59511FF39773CA6uLL);
    qword_1400160D8 = sub_140013328(0x306328EE8E049A39LL);
    qword_1400160E0 = sub_140013328(0x8B081062775EF4FFuLL);
    qword_1400160E8 = sub_140013328(0x8A9495B5004B3E52uLL);
    qword_1400160F0 = sub_140013328(0xB53A8A673A5397BFuLL);
    qword_1400160F8 = sub_140013328(0x7AB4BEE87ADA0ALL);
    qword_140016100 = sub_140013328(0xB61941B7EB2F33E5uLL);
    qword_140016108 = sub_140013328(0xC51C45581F224A39uLL);
    qword_140016110 = sub_140013328(0x1549DF88D773BFA1LL);
    qword_140016118 = sub_140013328(0x271ECF80D8C9C4B5LL);
    qword_140016120 = sub_140013328(0xD11F30A6B846B0DLL);
    qword_140016128 = sub_140013328(0x8E6C891B6ACC6C0EuLL);
    qword_140016130 = sub_140013328(0xC8ADF83E6FF463F3uLL);
    qword_140016138 = sub_140013328(0x64311A9F7F16D39FLL);
    qword_140016140 = sub_140013328(0x8A0AB57E2BF51C65uLL);
    qword_140016148 = sub_140013328(0x7F6272C86A6EEADCLL);
    qword_140016150 = sub_140013328(0x40ED8EA987B20683LL);
    qword_140016158 = sub_140013328(0x2BE3B7DDA50FF6FELL);
    qword_140016160 = sub_140013328(0x338042AB15F61CCLL);
    qword_140016168 = sub_140013328(0x7F2823D9AC4595A2LL);
    qword_140016170 = sub_140013328(0x47E1CA0E288956C0LL);
    qword_140016178 = sub_140013328(0x9251AEDC7CB1FC62uLL);
    qword_140016180 = sub_140013328(0xF55BEE38F686359CuLL);
    qword_140016188 = sub_140013328(0x8D49244199AFB66FuLL);
    qword_1400160A8 = sub_140013328(0xB9B08032AFEB3A00uLL);
    qword_1400160A0 = sub_140013328(0x96B081538803F1A4uLL);
    qword_140016098 = sub_140013328(0x2A9366C7EA2547B0LL);
    LOBYTE(v0) = 1;
  }
  return v0;
}

The sub_140013328 function basically is finding the functions in some module by their FNV-1A hash:

__int64 __fastcall sub_140013328(__int64 a1)
{
  __int64 v2; // rax
  _DWORD *v3; // rax
  __int64 v4; // r10
  __int64 v5; // rcx
  __int64 v6; // r8
  int v7; // edi
  __int64 v8; // rbx
  int v9; // r11d
  int *v10; // r9
  __int16 *v11; // r10
  _BYTE *v12; // rdx
  __int64 v13; // rcx

  v2 = *(int *)(qword_140016080 + 60);
  if ( *(_WORD *)qword_140016080 != 23117 )
    return 0;
  if ( *(_DWORD *)(v2 + qword_140016080) != 'EP' )
    return 0;
  if ( *(_WORD *)(v2 + qword_140016080 + 24) != 523 )
    return 0;
  v3 = (_DWORD *)(qword_140016080 + *(int *)(v2 + qword_140016080 + 136));
  v4 = (int)v3[7];
  if ( !(_DWORD)v4 )
    return 0;
  v5 = (int)v3[8];
  if ( !(_DWORD)v5 )
    return 0;
  v6 = (int)v3[9];
  if ( !(_DWORD)v6 )
    return 0;
  v7 = v3[6];
  v8 = qword_140016080 + v4;
  v9 = 0;
  v10 = (int *)(qword_140016080 + v5);
  v11 = (__int16 *)(qword_140016080 + v6);
  if ( v7 <= 0 )
    return 0;
  while ( 1 )
  {
    v12 = (_BYTE *)(qword_140016080 + *v10);
    if ( v12 )
    {
      if ( qword_140016080 + *(int *)(v8 + 4LL * *v11) )
      {
        v13 = 0xCBF29CE484222325uLL;
        while ( *v12 )
          v13 = 0x100000001B3LL * ((unsigned __int8)*v12++ ^ (unsigned __int64)v13);
        if ( v13 == a1 )
          break;
      }
    }
    ++v9;
    ++v10;
    ++v11;
    if ( v9 >= v7 )
      return 0;
  }
  return qword_140016080 + *(int *)(v8 + 4LL * *v11);
}

You can see it by the PE magic header check and then identify that it scans ordinal/name table and extracts the funcs addr. Let's skip it at the moment and jump into second func of driver entry:

char __fastcall sub_140013B4C(__int64 a1, _QWORD *a2)
{
  __int128 *v4; // rdx
  int v5; // ecx
  char v6; // al
  __int128 *v7; // r9
  __int64 v8; // r10
  int v9; // ecx
  char v10; // al
  __int64 v11; // rax
  _QWORD *v12; // rcx
  __int64 v13; // rax
  __int64 v14; // rbx
  volatile __int64 *v15; // rax
  __int128 v17; // [rsp+40h] [rbp-28h] BYREF
  int v18; // [rsp+50h] [rbp-18h]
  __int16 v19; // [rsp+54h] [rbp-14h]

  if ( (dword_1400161BC & 1) == 0 )
  {
    dword_1400161BC |= 1u;
    qmemcpy(&xmmword_1400161A0, "ijkijki-,.-kijkijkijkijk", 24);
    dword_1400161B8 = 292252265;
  }
  if ( !a1 )
    return 0;
  if ( !a2 )
    return 0;
  v4 = &v17;
  v18 = 6553711;
  v5 = 0;
  v17 = xmmword_140015270;
  v19 = 24;
  do
  {
    v6 = v5++;
    *(_WORD *)v4 ^= (v6 & 3) + 22;
    v4 = (__int128 *)((char *)v4 + 2);
  }
  while ( v5 < 11 );
  v8 = sub_14001326C(&v17, v4);
  if ( !v8 )
    return 0;
  if ( HIBYTE(dword_1400161B8) )
  {
    v9 = 0;
    v7 = &xmmword_1400161A0;
    do
    {
      v10 = v9++ % 3u;
      *(_BYTE *)v7 ^= v10 + 17;
      v7 = (__int128 *)((char *)v7 + 1);
    }
    while ( v9 < 28 );
  }
  v11 = sub_140013038(v8, &unk_140015230, &xmmword_1400161A0, v7);
  if ( !v11 )
    return 0;
  if ( v11 == -4 )
    return 0;
  v12 = (_QWORD *)(*(int *)(v11 + 7) + v11 + 11);
  if ( !v12 )
    return 0;
  *a2 = *v12;
  v13 = ((__int64 (__fastcall *)(_QWORD *, __int64, _QWORD))qword_140016108)(v12, 8, 0);
  v14 = v13;
  if ( !v13 )
    return 0;
  ((void (__fastcall *)(__int64, _QWORD, _QWORD))qword_140016110)(v13, 0, 0);
  ((void (__fastcall *)(__int64, __int64))qword_1400160A8)(v14, 64);
  v15 = (volatile __int64 *)((__int64 (__fastcall *)(__int64, _QWORD, _QWORD, _QWORD, _QWORD, int))qword_1400160A0)(
                              v14,
                              0,
                              0,
                              0,
                              0,
                              16);
  if ( !v15 )
  {
    ((void (__fastcall *)(__int64))qword_140016120)(v14);
    ((void (__fastcall *)(__int64))qword_140016128)(v14);
    return 0;
  }
  _InterlockedExchange64(v15, a1);
  ((void (__fastcall *)(volatile __int64 *, __int64))qword_140016098)(v15, v14);
  ((void (__fastcall *)(__int64))qword_140016120)(v14);
  ((void (__fastcall *)(__int64))qword_140016128)(v14);
  return 1;
}

Here there is an interesting string that is xor'ed, if we recover it we would get: xxxxxxx????xxxxxxxxxxxxxxxx. Which looks like pattern mask, if we track where it lands after it: 

v11 = sub_140013038(v8, &unk_140015230, &xmmword_1400161A0, v7);

And we look at this func, we can see it's really a pattern matcher (inside func checks for bytes and checks if in mask there is a ? symbol). So by recovering pattern from unk_140015230 and mask we would get the following ida pattern:

48 83 EC 48 48 8B 05 ?? ?? ?? ?? 45 8B D9 48 85 C0 74 2A 4C 8B 94 24 80 00 00 00

Another string is: win32k.sys

Now by extracting this file from Windows 11 22H2 (mentioned in README), we can do pattern scan and check what the driver tries to get, we'll get into here:

.text:FFFFF97FFF00A3FC                                     NtUserInitializeInputDeviceInjection proc near
.text:FFFFF97FFF00A3FC                                                                             ; DATA XREF: .rdata:__imp_NtUserInitializeInputDeviceInjection↓o
.text:FFFFF97FFF00A3FC                                                                             ; .pdata:FFFFF97FFF07470C↓o
.text:FFFFF97FFF00A3FC
.text:FFFFF97FFF00A3FC                                     var_28          = qword ptr -28h
.text:FFFFF97FFF00A3FC                                     var_20          = dword ptr -20h
.text:FFFFF97FFF00A3FC                                     var_18          = qword ptr -18h
.text:FFFFF97FFF00A3FC                                     arg_20          = qword ptr  28h
.text:FFFFF97FFF00A3FC                                     arg_28          = dword ptr  30h
.text:FFFFF97FFF00A3FC                                     arg_30          = qword ptr  38h
.text:FFFFF97FFF00A3FC
.text:FFFFF97FFF00A3FC 48 83 EC 48                                         sub     rsp, 48h
.text:FFFFF97FFF00A400 48 8B 05 C9 66 06 00                                mov     rax, cs:qword_FFFFF97FFF070AD0
.text:FFFFF97FFF00A407 45 8B D9                                            mov     r11d, r9d
.text:FFFFF97FFF00A40A 48 85 C0                                            test    rax, rax
.text:FFFFF97FFF00A40D 74 2A                                               jz      short loc_FFFFF97FFF00A439
.text:FFFFF97FFF00A40F 4C 8B 94 24 80 00 00 00                             mov     r10, [rsp+48h+arg_30]
.text:FFFFF97FFF00A417 4C 8B 4C 24 70                                      mov     r9, [rsp+48h+arg_20]
.text:FFFFF97FFF00A41C 4C 89 54 24 30                                      mov     [rsp+48h+var_18], r10
.text:FFFFF97FFF00A421 44 8B 54 24 78                                      mov     r10d, [rsp+48h+arg_28]
.text:FFFFF97FFF00A426 44 89 54 24 28                                      mov     [rsp+48h+var_20], r10d
.text:FFFFF97FFF00A42B 4C 89 4C 24 20                                      mov     [rsp+48h+var_28], r9
.text:FFFFF97FFF00A430 45 8B CB                                            mov     r9d, r11d
.text:FFFFF97FFF00A433 FF 15 AF 65 07 00                                   call    cs:__guard_dispatch_icall_fptr
.text:FFFFF97FFF00A439
.text:FFFFF97FFF00A439                                     loc_FFFFF97FFF00A439:                   ; CODE XREF: NtUserInitializeInputDeviceInjection+11↑j
.text:FFFFF97FFF00A439 48 83 C4 48                                         add     rsp, 48h
.text:FFFFF97FFF00A43D C3                                                  retn
.text:FFFFF97FFF00A43D                                     ; ---------------------------------------------------------------------------
.text:FFFFF97FFF00A43E CC                                                  db 0CCh

 

After that we can see that driver calculates position of qword_FFFFF97FFF070AD0 and then patches this pointer to one of its own functions:

InterlockedExchange64(v15, a1);

And also saves original pointer to a2. Now let's go back to where the function is called and see the handler:

sub_140013B4C(sub_140013430, &qword_140016088)

So the driver hooks InitializeInputDeviceInjection -> sub_140013430. Now let's go to sub_140013430 to analyze it. We'll see a couple of checks for the hook:

  if ( (unsigned __int8)((__int64 (*)(void))qword_140016090)() == 1
    && VirtualAddress
    && MmIsAddressValid(VirtualAddress)
    && *VirtualAddress == 0xC3FF4166LL )

Which checks for magic (0xC3FF4166), and that the address is valid. So driver waits for InitializeInputDeviceInjection call with first arg as a pointer and that pointer's first field should be an int (8 bytes) with that magic value. Then goes the switch for different operations of some kind, the operation int is at +0x8 from the cmd start. Then it just handles different arguments and calls the operation handlers. I won't go over all of them as the client does not call anything rather than 0x5 and 0x6 (you can find it out by dynamic instrumentation of the client, I'll explain a great way to do it a bit later).

So let's go to 0x5 first:

        VirtualAddress[8] = sub_1400130F4(*((unsigned int *)VirtualAddress + 8));
        break;

So it uses field from +0x20 and outputs to field located at +0x40. The function itself gets CR3 of the process based on its pid (it's pretty easy to spot but you'd need to educate yourself on what CR3 register yourself, I won't otherwise it would be too long of a writeup). At the moment we only need the offsets for fields themselves and the meaning.

Next up 0x6:

        sub_140014100(v26, 0, 192);
        sub_140014100(v25, 0, 192);
        sub_140014100(v24, 0, 128);
        v11 = VirtualAddress[10];
        v12 = 64;
        if ( v11 > 0x106 )
        {
          if ( v11 != 263 && v11 != 264 )
          {
            if ( v11 == 265 )
            {
LABEL_17:
              v13 = 64;
              goto LABEL_24;
            }
            if ( v11 - 266 >= 2 )
              goto LABEL_22;
          }
        }
        else if ( v11 != 262 )
        {
          if ( v11 == 257 || v11 == 258 || v11 == 259 || v11 - 260 <= 1 )
            goto LABEL_17;
LABEL_22:
          v13 = 0;
          goto LABEL_24;
        }
        v13 = 128;
LABEL_24:
        if ( v11 <= 0x106 )
        {
          if ( v11 == 262 || v11 == 257 || v11 == 258 || v11 - 259 <= 1 )
            goto LABEL_29;
LABEL_35:
          v14 = 0;
          goto LABEL_37;
        }
        if ( v11 != 263 )
        {
          if ( v11 == 264 )
            goto LABEL_29;
          if ( v11 == 265 )
            goto LABEL_35;
          if ( v11 != 266 )
          {
            if ( v11 != 267 )
              goto LABEL_35;
LABEL_29:
            v14 = 64;
LABEL_37:
            if ( v11 - 266 <= 1 )
              v12 = 128;
            v15 = VirtualAddress[4];
            v16 = VirtualAddress + 14;
            VirtualAddress[14] = 2;
            if ( v15 && VirtualAddress[9] && VirtualAddress[13] && v13 )
            {
              v17 = VirtualAddress[11];
              if ( v17
                && (unsigned __int8)sub_1400137F8((unsigned int)v26, v13, v17, v15, VirtualAddress[9])
                && (!v14
                 || (v18 = VirtualAddress[12]) != 0
                 && (unsigned __int8)sub_1400137F8((unsigned int)v25, v14, v18, VirtualAddress[4], VirtualAddress[9])) )
              {
                if ( sub_140001000(
                       VirtualAddress[10],
                       (__int64)v26,
                       (unsigned __int64)v25 & -(__int64)(v14 != 0),
                       (__int64)v24,
                       VirtualAddress + 14) )
                {
                  if ( !(unsigned __int8)sub_140013E94(
                                           (unsigned int)v24,
                                           VirtualAddress[13],
                                           VirtualAddress[4],
                                           v12,
                                           VirtualAddress[9]) )
                    *v16 = 6;
                }
              }
              else
              {
                *v16 = 5;
              }
            }
            return 0;
          }
        }
        v14 = 128;
        goto LABEL_37;

It's a lot of code but basically it decides how much space certain args should take based on another integer which is some kind of opcode, allocates space for bigints, and parses some other fields like status ptr and whatnot. We would get the idea of the layout from checking the inner function itself, which is sub_140001000. If we go into there we would be able to see the VM for bigints, that has opcodes from 0x101 up to 0x10B. Now we just need to slowly analyze the structure of the driver command given all of that info, after some poking around you'll end up with:

struct DriverCommand {
    unsigned long long magic = 0xC3FF4166ull;
    int operation = 0x6;
    void* user_buffer = nullptr;
    void* address = nullptr;
    void* pid = nullptr;
    unsigned long long size = 0;
    void* write_value = nullptr;
    void* base_return = nullptr;
    unsigned long long cr3_return = 0;
    unsigned long long cr3 = 0;
    unsigned long long vm_opcode = 0;
    unsigned long long vm_arg0_pointer = 0;
    unsigned long long vm_arg1_pointer = 0;
    unsigned long long vm_output_pointer = 0;
    unsigned long long vm_status = 0;
};

After which we can finally start working some dynamic magic with the driver. To figure out opcodes the easiest way would be to just "guess" based on inputs & outputs, as that VM clearly does some math functions, so with a simple trick we should be able to figure out at least basic stuff. Here's an example of how we can poke into the driver:

#include <iostream>
#include <array>

#include <Windows.h>


using DriverFn = std::int64_t(__fastcall*)(std::intptr_t);
DriverFn call_drv;

using Word512 = std::array<std::uint8_t, 64>;

template <typename Int>
Word512 MakeWordFromInt(Int value) {
    using Unsigned = std::make_unsigned_t<Int>;
    Unsigned u = static_cast<Unsigned>(value);

    Word512 bytes{};
    for (std::size_t byte_index = 0; byte_index < sizeof(Unsigned); ++byte_index) {
        bytes[byte_index] =
            static_cast<std::uint8_t>((u >> (byte_index * 8)) & 0xFFu);
    }
    return bytes;
}

std::uint64_t MakeIntFromWord(const Word512& bytes) {
    std::uint64_t value = 0;
    for (std::size_t byte_index = 0; byte_index < sizeof(value); ++byte_index) {
        value |= (static_cast<std::uint64_t>(bytes[byte_index]) << (byte_index * 8));
    }
    return value;
}

struct DriverCommand {
    unsigned long long magic = 0xC3FF4166ull;
    int operation = 0x6;
    void* user_buffer = nullptr;
    void* address = nullptr;
    void* pid = nullptr;
    unsigned long long size = 0;
    void* write_value = nullptr;
    void* base_return = nullptr;
    unsigned long long cr3_return = 0;
    unsigned long long cr3 = 0;
    unsigned long long vm_opcode = 0;
    unsigned long long vm_arg0_pointer = 0;
    unsigned long long vm_arg1_pointer = 0;
    unsigned long long vm_output_pointer = 0;
    unsigned long long vm_status = 0;
};



bool GetCR3( std::uint64_t* cr3 ) {
    if ( !cr3 ) return false;
    DriverCommand cmd;

    cmd.operation = 0x5; // get_cr3
    cmd.pid = reinterpret_cast<void*>( GetCurrentProcessId( ) );

    call_drv( reinterpret_cast<std::intptr_t>( &cmd ) );

    if ( !cmd.cr3_return ) {
        return false;
    }

    *cr3 = cmd.cr3_return;
    return true;
}


bool RunVM( int opcode, const std::uint8_t* arg0,
    const std::uint8_t* arg1, std::uint8_t* output, std::size_t output_bytes,
    unsigned long long* vm_status ) {
    std::uint64_t cr3;
    if ( !GetCR3( &cr3 ) ) {
        return false;
    }

    DriverCommand cmd;

    cmd.pid = reinterpret_cast<void*>( GetCurrentProcessId( ) );
    cmd.cr3 = cr3;
    cmd.vm_opcode = opcode;
    cmd.vm_arg0_pointer = reinterpret_cast<std::uint64_t>( arg0 );
    cmd.vm_arg1_pointer = reinterpret_cast<std::uint64_t>( arg1 );
    cmd.vm_output_pointer = reinterpret_cast<std::uint64_t>( output );

    call_drv( reinterpret_cast<std::intptr_t>( &cmd ) );

    return cmd.vm_status == 0x1 || cmd.vm_status == 0x9;
}


int main()
{
    HMODULE mod = LoadLibraryW( L"user32.dll" );
    FARPROC initialize_input_device_injection = GetProcAddress( mod, "InitializeInputDeviceInjection" );
    if ( !initialize_input_device_injection ) {
        std::cout << "What?" << std::endl;
        return -1;
    }
    call_drv = reinterpret_cast<DriverFn>( initialize_input_device_injection );

    const int kAddMod = 0x101;
    const int kSubMod = 0x102;

    const Word512 a = MakeWordFromInt( 0x1 );
    const Word512 b = MakeWordFromInt( 0x2 );

    Word512 out{ };
    std::uint64_t status;

    RunVM( kAddMod, a.data( ), b.data( ), out.data( ), 64, &status );
    std::cout << "Result is: " << MakeIntFromWord( out ) << std::endl; // 3

    return 0;
}

After that we'd need to figure out at least some operations, add, sub and mul are pretty easy to identify. Others we can return back to our driver and analyze further after discovering at least those 3 using our experimental method. Then you would have enough to statically analyze the rest as they're just composite of each other, e.g. opcode 0x106:

char __fastcall sub_1400044C0(__int64 a1, __int64 a2, __int64 a3, _QWORD *a4)
{
  __int128 v4; // kr00_16
  __int128 v5; // kr10_16
  __int128 v6; // kr20_16
  __int128 v7; // kr30_16
  __int64 v8; // rax
  __int64 v9; // r9
  __int128 v10; // rt0
  int k; // [rsp+10Ch] [rbp-3FCh]
  _OWORD v16[4]; // [rsp+110h] [rbp-3F8h] BYREF
  char v17; // [rsp+163h] [rbp-3A5h]
  unsigned int m; // [rsp+164h] [rbp-3A4h]
  _BYTE v19[64]; // [rsp+168h] [rbp-3A0h]
  char v20; // [rsp+1CBh] [rbp-33Dh]
  int j; // [rsp+1CCh] [rbp-33Ch]
  _OWORD v22[4]; // [rsp+1D0h] [rbp-338h] BYREF
  char v23; // [rsp+223h] [rbp-2E5h]
  int i; // [rsp+224h] [rbp-2E4h]
  _OWORD v25[4]; // [rsp+228h] [rbp-2E0h] BYREF
  __int128 v26[4]; // [rsp+280h] [rbp-288h] BYREF
  __int64 v27[8]; // [rsp+2C0h] [rbp-248h] BYREF
  _OWORD v28[2]; // [rsp+300h] [rbp-208h] BYREF
  __int64 v29; // [rsp+320h] [rbp-1E8h]
  __int64 v30; // [rsp+328h] [rbp-1E0h]
  __int128 v31; // [rsp+330h] [rbp-1D8h]
  _OWORD v32[4]; // [rsp+340h] [rbp-1C8h] BYREF
  _OWORD v33[4]; // [rsp+380h] [rbp-188h] BYREF
  _QWORD v34[8]; // [rsp+3C0h] [rbp-148h] BYREF
  __int128 v35; // [rsp+400h] [rbp-108h] BYREF
  __int128 v36; // [rsp+410h] [rbp-F8h]
  __int128 v37; // [rsp+420h] [rbp-E8h]
  __int128 v38; // [rsp+430h] [rbp-D8h]
  __int128 v39; // [rsp+440h] [rbp-C8h] BYREF
  __int128 v40; // [rsp+450h] [rbp-B8h]
  __int128 v41; // [rsp+460h] [rbp-A8h]
  __int128 v42; // [rsp+470h] [rbp-98h]
  __int128 v43; // [rsp+480h] [rbp-88h] BYREF
  __int128 v44; // [rsp+490h] [rbp-78h]
  __int128 v45; // [rsp+4A0h] [rbp-68h]
  __int128 v46; // [rsp+4B0h] [rbp-58h]

  v46 = 0u;
  v45 = 0u;
  v44 = 0u;
  v43 = 0u;
  v42 = 0u;
  v41 = 0u;
  v40 = 0u;
  v39 = 0u;
  v38 = 0u;
  v37 = 0u;
  v36 = 0u;
  v35 = 0u;
  if ( !a2 )
    goto LABEL_25;
  if ( a1 && &v43 && &v39 )
  {
    memset(v25, 0, sizeof(v25));
    for ( i = 63; i >= 0; --i )
    {
      v25[3] = *(_OWORD *)((char *)&v25[2] + 15);
      v25[2] = *(_OWORD *)((char *)&v25[1] + 15);
      v25[1] = *(_OWORD *)((char *)v25 + 15);
      *((_QWORD *)&v25[0] + 1) = *(_QWORD *)((char *)v25 + 7);
      *(_QWORD *)&v25[0] = *(unsigned __int8 *)(a1 + i) | (*(_QWORD *)&v25[0] << 8);
    }
    v46 = v25[3];
    v45 = v25[2];
    v44 = v25[1];
    v43 = v25[0];
    if ( a1 != -64 && &v39 )
    {
      memset(v22, 0, sizeof(v22));
      for ( j = 63; j >= 0; --j )
      {
        v22[3] = *(_OWORD *)((char *)&v22[2] + 15);
        v22[2] = *(_OWORD *)((char *)&v22[1] + 15);
        v22[1] = *(_OWORD *)((char *)v22 + 15);
        *((_QWORD *)&v22[0] + 1) = *(_QWORD *)((char *)v22 + 7);
        *(_QWORD *)&v22[0] = *(unsigned __int8 *)(a1 + 64 + j) | (*(_QWORD *)&v22[0] << 8);
      }
      v42 = v22[3];
      v41 = v22[2];
      v40 = v22[1];
      v39 = v22[0];
      v23 = 1;
    }
    else
    {
      v23 = 0;
    }
    v20 = v23;
  }
  else
  {
    v20 = 0;
  }
  if ( (v20 & 1) == 0 )
    goto LABEL_25;
  if ( &v35 )
  {
    memset(v16, 0, sizeof(v16));
    for ( k = 63; k >= 0; --k )
    {
      v16[3] = *(_OWORD *)((char *)&v16[2] + 15);
      v16[2] = *(_OWORD *)((char *)&v16[1] + 15);
      v16[1] = *(_OWORD *)((char *)v16 + 15);
      *((_QWORD *)&v16[0] + 1) = *(_QWORD *)((char *)v16 + 7);
      *(_QWORD *)&v16[0] = *(unsigned __int8 *)(a2 + k) | (*(_QWORD *)&v16[0] << 8);
    }
    v38 = v16[3];
    v37 = v16[2];
    v36 = v16[1];
    v35 = v16[0];
    v17 = 1;
  }
  else
  {
    v17 = 0;
  }
  if ( (v17 & 1) != 0 )
  {
    v4 = v35;
    v5 = v36;
    v6 = v37;
    v7 = v38;
    v33[3] = v46;
    v33[2] = v45;
    v33[1] = v44;
    v33[0] = v43;
    v32[3] = v42;
    v32[2] = v41;
    v32[1] = v40;
    v32[0] = v39;
    MulMod_inner(v34, v33, v32);
    v27[7] = v34[7];
    v27[6] = v34[6];
    v27[5] = v34[5];
    v27[4] = v34[4];
    v27[3] = v34[3];
    v27[2] = v34[2];
    v27[1] = v34[1];
    v27[0] = v34[0];
    v26[3] = v7;
    v26[2] = v6;
    v26[1] = v5;
    v26[0] = v4;
    AddMod((__int64)v28, v27, v26);
    *(_OWORD *)&v19[48] = v31;
    *(_QWORD *)&v19[40] = v30;
    *(_QWORD *)&v19[32] = v29;
    *(_OWORD *)&v19[16] = v28[1];
    *(_OWORD *)v19 = v28[0];
    for ( m = 0; m < 0x40; ++m )
    {
      *(_BYTE *)(a3 + m) = v19[0];
      v8 = *(__int128 *)&v19[24] >> 8;
      v9 = *(__int128 *)&v19[32] >> 8;
      v10 = *(_OWORD *)&v19[40];
      *(_OWORD *)&v19[48] >>= 8;
      *(_QWORD *)&v19[40] = v10 >> 8;
      *(_QWORD *)&v19[32] = v9;
      *(_OWORD *)v19 = *(_OWORD *)&v19[1];
      *(_QWORD *)&v19[16] = *(_QWORD *)&v19[17];
      *(_QWORD *)&v19[24] = v8;
    }
    *a4 = 1;
    return 1;
  }
  else
  {
LABEL_25:
    *a4 = 2;
    return 0;
  }
}

Is just doing AddMod(MulMod(...), ...). And that way you can analyze all the other functions, the interesting part would be 0x107, 0x108, 0x10A, 0x10B, which are way more complex and instead of one 512-bit int they accept two, and return two as well, so something like coordinates. It's not hard to see they're different ECC helpers, It's still very easy to analyze them after you went through all the easy ones the way described above. So after all the analyzing you should end up with that opcode table:

enum class VmOpcode : unsigned long long {
  kAddMod = 0x101ull,
  kSubMod = 0x102ull,
  kMulMod = 0x103ull,
  kPowMod = 0x104ull,
  kModInv = 0x105ull,
  kMulAddMod = 0x106ull,
  kAffineAddSlope = 0x107ull,
  kAffineDoubleSlope = 0x108ull,
  kCheck = 0x109ull,
  kAffineAddPoint = 0x10Aull,
  kAffineDoublePoint = 0x10Bull,
};

Also we'd need to find the modulus in the driver itself, it's basically encoded in constants that are used with opcodes. You can do it by dynamically calling driver functions and using any math trick of your liking to recover modulus by using the VM as a blackbox-oracle, but here's what you should end up with:

0xa8505c94911b17f0b8f567ea473c45e5265362f2bea50b2a61408cd963e1593d9a1ca1253d87ba9ab95c06925eae71eacd8759607052ce8bede3511128721dd5

And while analyzing ECC-helpers you also should've been able to extract the curve parameters, the curve itself is:

y² = x³ + 7 (mod N)

That's the whole driver done. Now we need to somehow extract opcodes from the client-side that's protected. Let's jump into it.

Client

So how do we analyze the client? It has VMProtect, and the version is also not old enough to just bypass using ScyllaHide. Static analysis is instant death as it's heavily virtualized.

The are a few ways:

1) Inject our own DLL and use MinHook or anything else to hook InitializeInputDeviceInjection from user32.dll;

2) Do the same thing as p. 1, but use Frida for hooking;

3) Debug kernel using this (would require some dancing around VMP still) and write an IDA script to extract instructions.

So all of those ways are basically different way of injecting a middle-man between the client and the driver, which allows us to see what's getting sent to the driver and what output we get from the driver, meaning we can effectively dump the whole algo from client without touching the client itself. The easiest by far is method 2. So let's just use it, as I'm too lazy to explain other two, besides that method 3 would work with a speed of a turtle that had some drinks. So let's follow-through with 2nd one.

We'd need to setup our VM, load the driver, and then we will use this script:

 

'use strict';

const MAGIC32 = 0xC3FF4166;
const OP_GET_CR3 = 0x5;
const OP_RUN_VM = 0x6;

const LOG_PATH = 'C:\\vm_trace.txt';

const VmOpcode = {
  0x101: 'AddMod',
  0x102: 'SubMod',
  0x103: 'MulMod',
  0x104: 'PowMod',
  0x105: 'ModInv',
  0x106: 'MulAddMod',
  0x107: 'AffineAddSlope',
  0x108: 'AffineDoubleSlope',
  0x109: 'Check',
  0x10a: 'AffineAddPoint',
  0x10b: 'AffineDoublePoint',
};

const OFF = {
  magic: 0x00,
  operation: 0x08,
  user_buffer: 0x10,
  address: 0x18,
  pid: 0x20,
  size: 0x28,
  write_value: 0x30,
  base_return: 0x38,
  cr3_return: 0x40,
  cr3: 0x48,
  vm_opcode: 0x50,
  vm_arg0_pointer: 0x58,
  vm_arg1_pointer: 0x60,
  vm_output_pointer: 0x68,
  vm_status: 0x70
};

let logFile = null;
let alreadyAttached = false;

function initLogFile() {
  if (logFile !== null)
    return;

  logFile = new File(LOG_PATH, 'ab');
}

function logLine(s) {
  initLogFile();
  logFile.write(s + '\n');
  logFile.flush();
}

function u64num(p) {
  return parseInt(p.readU64().toString(), 10);
}

function getArg0Bytes(op) {
  switch (op) {
    case 0x101: // AddMod
    case 0x102: // SubMod
    case 0x103: // MulMod
    case 0x104: // PowMod
    case 0x105: // ModInv
    case 0x109: // Check
      return 64;

    case 0x106: // MulAddMod
    case 0x107: // AffineAddSlope
    case 0x108: // AffineDoubleSlope
    case 0x10a: // AffineAddPoint
    case 0x10b: // AffineDoublePoint
      return 128;

    default:
      return 0;
  }
}

function getArg1Bytes(op) {
  switch (op) {
    case 0x101: // AddMod
    case 0x102: // SubMod
    case 0x103: // MulMod
    case 0x104: // PowMod
      return 64;

    case 0x106: // MulAddMod
    case 0x108: // AffineDoubleSlope
    case 0x10b: // AffineDoublePoint
      return 64;

    case 0x107: // AffineAddSlope
    case 0x10a: // AffineAddPoint
      return 128;

    case 0x105: // ModInv
    case 0x109: // Check
    default:
      return 0;
  }
}

function getOutputBytes(op) {
  switch (op) {
    case 0x10a: // AffineAddPoint
    case 0x10b: // AffineDoublePoint
      return 128;
    default:
      return 64;
  }
}

function readBytes(ptrValue, length) {
  if (ptrValue.isNull() || length === 0)
    return null;

  const raw = ptrValue.readByteArray(length);
  if (raw === null)
    return null;

  return new Uint8Array(raw);
}

function leBytesToBigInt(bytes, offset, length) {
  let v = 0n;
  for (let i = length - 1; i >= 0; i--) {
    v = (v << 8n) | BigInt(bytes[offset + i]);
  }
  return v;
}

function formatBigIntDecHex(v) {
  return `${v.toString(10)} (0x${v.toString(16)})`;
}

function parseWord512(ptrValue) {
  const bytes = readBytes(ptrValue, 64);
  if (bytes === null)
    throw new Error('failed to read 64-byte buffer');
  return leBytesToBigInt(bytes, 0, 64);
}

function parsePair128(ptrValue) {
  const bytes = readBytes(ptrValue, 128);
  if (bytes === null)
    throw new Error('failed to read 128-byte buffer');

  return {
    first: leBytesToBigInt(bytes, 0, 64),
    second: leBytesToBigInt(bytes, 64, 64)
  };
}

function dumpWord512(ptrValue, label) {
  if (ptrValue.isNull())
    return;

  try {
    const v = parseWord512(ptrValue);
    logLine(`[${label}] @ ${ptrValue}`);
    logLine(`  ${formatBigIntDecHex(v)}`);
  } catch (e) {
    logLine(`[${label}] parse failed: ${e}`);
  }
}

function dumpAffinePoint(ptrValue, label) {
  if (ptrValue.isNull())
    return;

  try {
    const p = parsePair128(ptrValue);
    logLine(`[${label}] @ ${ptrValue}`);
    logLine(`  x = ${formatBigIntDecHex(p.first)}`);
    logLine(`  y = ${formatBigIntDecHex(p.second)}`);
  } catch (e) {
    logLine(`[${label}] parse failed: ${e}`);
  }
}

function dumpScalarPair(ptrValue, label) {
  if (ptrValue.isNull())
    return;

  try {
    const p = parsePair128(ptrValue);
    logLine(`[${label}] @ ${ptrValue}`);
    logLine(`  a = ${formatBigIntDecHex(p.first)}`);
    logLine(`  b = ${formatBigIntDecHex(p.second)}`);
  } catch (e) {
    logLine(`[${label}] parse failed: ${e}`);
  }
}

function dumpVmBuffer(ptrValue, opcode, which) {
  if (ptrValue.isNull())
    return;

  switch (opcode) {
    case 0x101: // AddMod
    case 0x102: // SubMod
    case 0x103: // MulMod
    case 0x104: // PowMod
    case 0x105: // ModInv
    case 0x109: // Check
      dumpWord512(ptrValue, which);
      return;

    case 0x106: // MulAddMod
      if (which === 'arg0') {
        dumpScalarPair(ptrValue, which); // lhs, rhs
      } else {
        dumpWord512(ptrValue, which); // addend / output
      }
      return;

    case 0x107: // AffineAddSlope
      if (which === 'arg0' || which === 'arg1') {
        dumpAffinePoint(ptrValue, which);
      } else {
        dumpWord512(ptrValue, which); // slope output
      }
      return;

    case 0x108: // AffineDoubleSlope
      if (which === 'arg0') {
        dumpAffinePoint(ptrValue, which); // point
      } else {
        dumpWord512(ptrValue, which); // curve_a / output slope
      }
      return;

    case 0x10a: // AffineAddPoint
      if (which === 'arg0' || which === 'arg1' || which === 'output') {
        dumpAffinePoint(ptrValue, which);
      } else {
        dumpWord512(ptrValue, which);
      }
      return;

    case 0x10b: // AffineDoublePoint
      if (which === 'arg0' || which === 'output') {
        dumpAffinePoint(ptrValue, which); // point / point result
      } else {
        dumpWord512(ptrValue, which); // curve_a
      }
      return;

    default:
      logLine(`[${which}] @ ${ptrValue}`);
      logLine('  <unknown opcode layout>');
      return;
  }
}

function attachThunk(target) {
  if (alreadyAttached)
    return;
  alreadyAttached = true;

  logLine(`[+] Hooking InitializeInputDeviceInjection at ${target}`);

  Interceptor.attach(target, {
    onEnter(args) {
      const cmd = args[0];
      this.cmd = cmd;

      if (cmd.isNull())
        return;

      let magic;
      try {
        magic = cmd.add(OFF.magic).readU32();
      } catch (e) {
        return;
      }

      if (magic !== MAGIC32)
        return;

      const operation = cmd.add(OFF.operation).readS32();
      this.operation = operation;

      if (operation === OP_GET_CR3) {
        const pidAsPtr = cmd.add(OFF.pid).readPointer();
        logLine('');
        logLine(`[CR3] request pid=${pidAsPtr}`);
        return;
      }

      if (operation !== OP_RUN_VM)
        return;

      const opcode = u64num(cmd.add(OFF.vm_opcode));
      this.opcode = opcode;

      const arg0Ptr = cmd.add(OFF.vm_arg0_pointer).readPointer();
      const arg1Ptr = cmd.add(OFF.vm_arg1_pointer).readPointer();
      const outPtr = cmd.add(OFF.vm_output_pointer).readPointer();

      this.outPtr = outPtr;
      this.outLen = getOutputBytes(opcode);

      logLine('');
      logLine(`[VM] opcode=0x${opcode.toString(16)} (${VmOpcode[opcode] || 'Unknown'})`);
      logLine(`     arg0=${arg0Ptr} arg1=${arg1Ptr} out=${outPtr}`);

      if (!arg0Ptr.isNull() && getArg0Bytes(opcode) !== 0)
        dumpVmBuffer(arg0Ptr, opcode, 'arg0');

      if (!arg1Ptr.isNull() && getArg1Bytes(opcode) !== 0)
        dumpVmBuffer(arg1Ptr, opcode, 'arg1');
    },

    onLeave(retval) {
      if (!this.cmd || this.cmd.isNull())
        return;

      if (this.operation !== OP_RUN_VM)
        return;

      try {
        const status = this.cmd.add(OFF.vm_status).readU64();
        logLine(`[VM] status=${status}`);

        const statusNum = parseInt(status.toString(), 10);
        if ((statusNum === 1 || statusNum === 9) && this.outPtr && !this.outPtr.isNull()) {
          dumpVmBuffer(this.outPtr, this.opcode, 'output');
        }
      } catch (e) {
        logLine(`[VM] status/output read failed: ${e}`);
      }
    }
  });
}

function directHook() {
  const p = Process.getModuleByName('user32.dll').getExportByName('InitializeInputDeviceInjection');
  if (p !== null) {
    attachThunk(p);
    return true;
  }
  return false;
}

directHook();

What it does is exactly what we described above - hook into function that the client uses to interact with VM, and output everything into file. After that we launch our app using Frida:

frida -l script.js C:\FullPath\To\vision.exe

After that enter some flag and press enter... But it won't work, at least yet. In README it's stated that app takes a while to process a flag, but when we click submit we see that the response is instant. It's hinting at the fact that the app does not really process our flag and has some gate before it, the easiest and the most probable is the flag length and flag format. So let's try entering valid flag format and adding one symbol at a time and pressing submit until app stutters and we see some logs. This moment would happen at exactly 57 symbols. After that we should be able to trace all the interaction between client and vm.

Here's the file I've got after tracing, if you want to follow along: click

So now let's analyze the algorithm that client has. First things we would see is a big amount of AffineDoublePoint with some AffineAddPoint in between them. It's almost a dead giveaway that the app does some scalar multiplication on ECC, if you don't know how scalar multiplication is usually implemented, here it is:

function SCALAR_MUL(k, P):
    R = INF

    for bit in bits_of(k) from most_significant to least_significant:
        R = POINT_DOUBLE(R)

        if bit == 1:
            R = POINT_ADD(R, P)

    return R

As you can see it's exactly what we see - constant doubling, and sometimes we see add point, and if we look closer it really does match this construction. After a lot of doubling and adding points, we can see next operations, they would start at line 438:

[VM] opcode=0x102 (SubMod)
     arg0=0x1c591888000 arg1=0x1c591889000 out=0x1c59188a000
[arg0] @ 0x1c591888000
  1642323798380335312038437321944993423584363893491033736980157835701203854699158892938386638081790723572062244320272993647652900608970542710764156517027423 (0x1f5b82f22b2dc2986c36aa14001184efd33f1a3801782d03272a9169591a5b301683e8d058037ae43824a7c956dd2cdd11546186f47930a37cac5ac4fc3f0e5f)
[arg1] @ 0x1c591889000
  1642323798380335312038437321944993423584363893491033736980157835701203854699158892938386638081790723572062244320272993647652900608970542710764156517027423 (0x1f5b82f22b2dc2986c36aa14001184efd33f1a3801782d03272a9169591a5b301683e8d058037ae43824a7c956dd2cdd11546186f47930a37cac5ac4fc3f0e5f)
[VM] status=1
[output] @ 0x1c59188a000
  0 (0x0)

[VM] opcode=0x106 (MulAddMod)
     arg0=0x1c591888000 arg1=0x1c591889000 out=0x1c59188a000
[arg0] @ 0x1c591888000
  a = 1178457522619486396540431700769939586822928521626758161992636938965113890543081263108580435338780213745041731542539523506409173644384791996172822730698795 (0x16802e2d8229f7c6f5151b4e4e948fad82562d1221b4805c06d65f282cb77a9aafdb93868a08c6c50c264da83d512177e50f8712d1c7ba1812a834f5b3bef02b)
  b = 4290436122824892843475303291021140915142992860256320791969078109507980044224632517933778516364032923220536106597090903041636457809003936158411559369415614 (0x51eb370531ff626f51a82a7bd8fbbb9b69799879cf79a8db51eda75f63e4672eb1f96a9b063ee54a1ee9d3c9b2714b43e827550be8b39744ef6a7153e7079bbe)
[arg1] @ 0x1c591889000
  0 (0x0)
[VM] status=1
[output] @ 0x1c59188a000
  3594582161921598590572368617269726211691263726457618693855614449628759028895796461701451637608806518334579450765291011017622927224926575943769058870263011 (0x44a1f39ce69a5f979b371074eee91cc6ca5c3d7b8b91aa26cefd635506db6f36ad9561445ecae38434fa74ecfe0ae7cf96c982458feb17d9edda64b028537ce3)

[VM] opcode=0x102 (SubMod)
     arg0=0x1c591888000 arg1=0x1c591889000 out=0x1c59188a000
[arg0] @ 0x1c591888000
  1278972560141167293692526037079019424238603075637564831555536786905734381844226991135928018834565617258283726006280758197228045185579841304562569671831707 (0x186b7cd6b2a78f68e0617737a8a3550276e46c12f469b8983d129eae33a47abfe8ed0eeaa5865fd06b7510c6e525057ea927428575a83986329583e3ad8f709b)
[arg1] @ 0x1c591889000
  1278972560141167293692526037079019424238603075637564831555536786905734381844226991135928018834565617258283726006280758197228045185579841304562569671831707 (0x186b7cd6b2a78f68e0617737a8a3550276e46c12f469b8983d129eae33a47abfe8ed0eeaa5865fd06b7510c6e525057ea927428575a83986329583e3ad8f709b)
[VM] status=1
[output] @ 0x1c59188a000
  0 (0x0)

[VM] opcode=0x106 (MulAddMod)
     arg0=0x1c591888000 arg1=0x1c591889000 out=0x1c59188a000
[arg0] @ 0x1c591888000
  a = 3594582161921598590572368617269726211691263726457618693855614449628759028895796461701451637608806518334579450765291011017622927224926575943769058870263011 (0x44a1f39ce69a5f979b371074eee91cc6ca5c3d7b8b91aa26cefd635506db6f36ad9561445ecae38434fa74ecfe0ae7cf96c982458feb17d9edda64b028537ce3)
  b = 4290436122824892843475303291021140915142992860256320791969078109507980044224632517933778516364032923220536106597090903041636457809003936158411559369415614 (0x51eb370531ff626f51a82a7bd8fbbb9b69799879cf79a8db51eda75f63e4672eb1f96a9b063ee54a1ee9d3c9b2714b43e827550be8b39744ef6a7153e7079bbe)
[arg1] @ 0x1c591889000
  0 (0x0)
[VM] status=1
[output] @ 0x1c59188a000
  4348579974773663251423969543958536785050748144099522837673659554787862039493146129868049994529238008020299479776285906620979403400179637782588302586187451 (0x53076a88e32b24b422696f5662993702f5974d8ef55a3874ddb0e63bb127a9fe32f41dceee3be240b57b13dfc7b3f684fdea72d2c613d56a21958fa3eaba8ebb)

After that we return back to scalar multiplication. And the code above basically substracts the point it got by scalar multiplication from other point, then doing MulAddMod to some kinda accumulator variable.

What's interesting to notice is that when entering a valid flag format (I did in that trace) we can see that substraction returns 0, which means we probably should get 0s across all those subs in order for a flag to be correct.

Now let's see what scalar value does application use in order to calculate the point itself. (Another good thing we can do before that is changing the input and checking second trace to see what changes - the answer would be scalar value only changes). We can do that by recovering bits of the integer (each AddPoint means bit in this pos was 1, AffineDouble is used on each bit so if there was no addpoint it's 0). You should be able to recover that first scalar value is 0x6C6F56 (which is 'Vol' integer-encoded) by using the trick described earlier, by doing so with each scalar multiplication you would get your whole input chunked by 3 symbols. So the whole algorithm is basically:

k_i*Q_i == G_i, where i goes from 0 to 18.

Where Q, G are const points and k is your 3-byte chunk from flag. So that's a simple discrete log problem, where you want to solve for scalar. Now we just extract all Q and G points from VM logs (you can separate the scalar multiplication from eq logic by separating on SubMod opcodes) and solve the task.

Solve

So I won't really explain what's going on here, you can consult google and chatgpt because it's pretty basic cryptography, but here's what you should get in the final solve script:

#!/usr/bin/env python3
from typing import Optional

PARAMS = {
  'chunks': [
    {
      'g': {
        'x': '0x1339',
        'y': '0xaf56e176ee80077af9c20f6f7ca530a3c65ec6a9325ea07150173726067ccc288b2ed6c57f549e99efd31918ba1d26d4665cb2997535bad21d3def83ad4a0a0'
      },
      'target': {
        'x': '0x1f5b82f22b2dc2986c36aa14001184efd33f1a3801782d03272a9169591a5b301683e8d058037ae43824a7c956dd2cdd11546186f47930a37cac5ac4fc3f0e5f',
        'y': '0x186b7cd6b2a78f68e0617737a8a3550276e46c12f469b8983d129eae33a47abfe8ed0eeaa5865fd06b7510c6e525057ea927428575a83986329583e3ad8f709b'
      }
    },
    {
      'g': {
        'x': '0x100133a',
        'y': '0x4a13f419cb9ef6ac90502029bec55c8738f41a1227076b83753c43ec637c0859062b66dba94be93a013d0b693ec0aa2c6cc2f2e78338718c2958b4c69f4df03a'
      },
      'target': {
        'x': '0x2df4565a5dddc2f49a2d5c38902d37181f188278bfdecd3ed8b23b3f129730f01eec8a81057680953607dca70f1af1135a2de21b9bca703b9b64117f6def29d9',
        'y': '0xf93f3664a8d5657769196fe4a5951e52fa8fd9da3a2e762705a781f5f0fab011f28651e890d72ff7f99719ff4c5b36c1b03e254fc2a77b93425cba9caf938a1'
      }
    },
    {
      'g': {
        'x': '0x2001341',
        'y': '0x275eee2152ceebeaf382bf6485f9fef20a51d162d804996e4920df29bbfdda8376a4fc036e8f9a82ce9f0f320c52c3621962b75e5237c08091a75d79ae00617d'
      },
      'target': {
        'x': '0x4fa71578ae9728415aada133a8d0189a3a8c417c06ca88c3b8878ca03a738a4d841d6348d735923cc96499ad4b72e531eaa51639365f3493de61c0116058df52',
        'y': '0x574d4533ae1b3c1fdf1c67c84d58b1ba3af7ad41e932444af63cb145259a1c51b52b5733d9976fbc819560523a6e36571430cd8d98e3c3895fffc944a146b87b'
      }
    },
    {
      'g': {
        'x': '0x3001341',
        'y': '0x1ad77f25db005ad9186543d9f67588e4bfacc1f1720232550569e7f8a81ba4e76e44367e823ca0b62246ef8759db0571a3c113b0d4c75759d7f790bf9ee2ae46'
      },
      'target': {
        'x': '0x7ed22703b8a43db61aebe6af923fccb7418c65f239fc1eccc0f5c427ac094813da45047a1961846592b99438005db7249a2fb7ec88702e2d3bf275e7a7cf72ed',
        'y': '0x1dca4f4d70dfa55832e1beb8fc73138194b2cd8b37a9f348639faa3b657c832878012cd413885fb3a2fed8268370144958faf00855ebe61cc7e6d898650f34bc'
      }
    },
    {
      'g': {
        'x': '0x4001343',
        'y': '0x3f23a75caa8ebd5249fbc0d2ac0a3ecaa0a52810b842e54be7b01768fb84a330134b6a2cb38e2146c7b1a6f654a77482d11cfd76568ba2579eb7d38772459'
      },
      'target': {
        'x': '0x93ad4612c5de20241a2a2081487202464f154ae03d350ebbe80dacb66d3ac33e2f6182c135bfbcc5d025af42ee5d6bdca39c84c4bf74bf81502fe5716c69f155',
        'y': '0x34ee70ce5a7eec4e9c8f40504e7afc7f039997a3ee1af20cfa1a16807f9cfee37bd37c1ec2f94c1f565ab25813c5cb9f802a1bf5272bbb7d572ff16265b04175'
      }
    },
    {
      'g': {
        'x': '0x5001346',
        'y': '0x50e4e8981ec8b6f290aa7321dbbfce5dab738358388583ef9a6c17c6abade73141530561a9350e01186eb0ed069782729a4ecc78b62717d32b6b22158488d920'
      },
      'target': {
        'x': '0x963f7dc77309564f3a1479df74ade0860b83d47bb8de04f78cf6fe3dc599d72a5f2f7717ec0e6d9909cdf8f002770b9152ce5270849cff81a136777bf2413dd5',
        'y': '0x1e3090f81b855ccb51d4411da2004aac788de92091703b80a2ca7878a9633226a5acc49d665cfaeec337aa25c94993315eeda307b57d163b1b9d20eb871f5eab'
      }
    },
    {
      'g': {
        'x': '0x6001349',
        'y': '0x60cc033f969787ea6cec28ec4e005b9d72aca1d4ad80ad0317dbd6263a1261022e80254e263132af3b1685276250aeb0ddeb99fc58d164af3dc75534cc60629'
      },
      'target': {
        'x': '0x3c6c4192fdd21641b15901253154d791799ff0b6a314c4b02b30cee7a45ba934be95aed5b858538e89f6bcc480b91440a0b7ad09e45918fc9c387755af0f90a6',
        'y': '0x86bee9d44b43a33fd26108f52326c0a6d90fbcc19ae40c1afd6703111b90105dc6a4eddc9a8fcfbe1dcb4b1fc6d90a9d15a72c538024d03b5049f84c8b68d213'
      }
    },
    {
      'g': {
        'x': '0x700134c',
        'y': '0x4bdf1f0fad37de807d321d66da29ec55c26e97517d929969cb69d35b50134e1c8b749fe01b2e4fc2376fb106eb12abfccbe73fd50077511a6b88e88455d2cff0'
      },
      'target': {
        'x': '0x94d2dd410efc44c7faae3412b55ec3a4b2152aad800bcb15a1ae60d0794dbdb25f6762354b344d3307a8be6dc8d6bbb7411e67f2ef62855a19bbf8bbd7c255cd',
        'y': '0x8187a07f0fe0dc3204bc87626afbf5be802b432541dc21b5098cdceb2f02787d6bedb17d2f0d2812fb1e2a010bc34c9036c2e250e83f4effe47d207956ffc7c'
      }
    },
    {
      'g': {
        'x': '0x8001350',
        'y': '0x4173b1c7d9c172b7a2eb2cab95890f0eafc9cba9d53f18d5ca4b3c5f3e8fa57e1aaa07c8d4c9b0df31aea1df60f3344aafcb0b116e9b9607c2e10f4ea40ca4f6'
      },
      'target': {
        'x': '0x5c50575f16cb5a58016414ef6b97b65cf30dc7afcb3503b449f01677ce51c6ffd5a24de63f8bd5a0a1f308f55a522839318fd7f44d46e9bd9f3c560bf019934e',
        'y': '0x36c3ef12babc710586c5b26bc54f15d1f4e5b7181effedc69172c46ed2def6efb62e42715b0024f2a4e998f4b3197417f2c997fa8dbc8fe02aeb67b251fd1318'
      }
    },
    {
      'g': {
        'x': '0x9001355',
        'y': '0x94aa17c2bfe582367206134e7b070636e8ea812c9ae43bd4f18b1190864b1460bfc7c611cbbb7d4fe7f62e2e515a51de979a4fa1103c82cfc39e162116351d2'
      },
      'target': {
        'x': '0x707e6855ffc5a6ea1c2541a008cc8606dab0fc5446ffee1ab9ec1d55383c750f3ac9b08ccb6565e6a11c3e5b1895d8ed42ffae4b7c1ee2487050f82d97a112c7',
        'y': '0x5767b8cda736ebd72e92f3cb646587d221d9846f2c60212ccdd1026b57100e6ac3da05546f697e07af8265ae4f1cc41ed6fa55f59bfb1b7855afded5a8b23a8b'
      }
    },
    {
      'g': {
        'x': '0xa001355',
        'y': '0x2b880315c19297e7eb2fa2f735a0471e57c227cae2538a33b69f9277f4850029c0ac50d38b90ce659f0fb3ce9a057255ae45ff52af6fea90fd7f9488dd4bdb04'
      },
      'target': {
        'x': '0x92b2444ca1584afb2c39f1ee72aae4ed90f131c5cc194da56a2522c9ae97365f53390cfc59cf626a0d2b68634ce47a47ea348d828845779dacabea239ce4f24b',
        'y': '0x8ed266c0b9ae331ee7ca5e23dd2c2571bd3142e515c176c8d25413baaa720c084761ada35933566defdbdaa454c0a8eec8f88241640b04798b088907fd46331b'
      }
    },
    {
      'g': {
        'x': '0xb001359',
        'y': '0x137302fb4f591713e5025c55cb636b8eba7bf7c8c30ac1bd1c6d6bb92870b8c50b77327ab22cc1fdcc3bcd2a848fce644b34db955795ac10eb2df04bbf38ae33'
      },
      'target': {
        'x': '0xb4a64531c3a05710a0ceea6662544afdc2a0694367d76f22a89c4158d9ed878688789e01e21b5120e8866a13cec0f0c25e3f70bffe1fd18c7771e9761521689',
        'y': '0x4b8eb8d1b0a139d3cb48b04e67141a2cb9e33f7432ee9b86fea990d6ed169db6ae578f663e02d30e8327d214556279032f0987e09d5962b189ecb98ce9a08c2a'
      }
    },
    {
      'g': {
        'x': '0xc00135b',
        'y': '0x53e3344f547666c27109f26f4e2f620744515987b881dca674dd58244ae82c16fc78d2b1e177b0eefc950ec5e2c7244acc63b27091ecdd7d724171ce34daa902'
      },
      'target': {
        'x': '0x6e985bc18473ad4f2147d2934f506fbc594bc62899cb7dbf40f9cf4365f3d6e4bf3b9757da3a8f1fa010d352a6913c644dd1894a641e48a692248473724993f0',
        'y': '0x4beb50b00b1829a6bc7bd2d35d8f26162ece091a6599b336f3f431c46aff3bfa1bc6027ca4a2e0dcc884895579942e00e62c478fbfca2f1d0eb56102d6a62cfe'
      }
    },
    {
      'g': {
        'x': '0xd001363',
        'y': '0x1a35023c18fed7b4c9d65775d46d23047ddce05c4ac85d076284a4e4bc44cfc035f5658c9b63f1611a3c707c0ea634847ab644b9c09bf6c5ae574b408e54a925'
      },
      'target': {
        'x': '0x8e643ab7dfa27475afdeed119fe09b53034c7490cd20fb7aca6de229ce9f2d9db7fd6504ff37f1d1739b25649f091cce1ff556d64f0b27efa5c9595d713bb203',
        'y': '0x4ab4dbe082684cfeb184dfc9efc2a05af0b551ba8a26b8cecc1ce6e616e94fd976e1a34c2d24f1f10019f7c12fd316d51c95074fd1399b1cf72a6fe3190b05a3'
      }
    },
    {
      'g': {
        'x': '0xe001364',
        'y': '0x1bc49baca0bf9d6bb584dc1a18aaf64c99c6ed5adbc173b5d82627b3a72fbfdfc7396bc00472cf7703e58517dd12eecc62b1c6eb1a3e966050f5d84903e6f6f9'
      },
      'target': {
        'x': '0x269bcf43578d029d777c8db9e4e55a2673fe4d6ac24738c04d99dfb8c7608d0688b58887febfb0dbb0b586daa65f988faa386701aa7b2bd284661f48a6c25b7d',
        'y': '0x29e544dffc5937bb86759950072ba1a779941231e23bdd0f2146f7504045b0f3c8524e3de3759179f2bca82a38191aa615d4807903dd09026bed701d4ee0492'
      }
    },
    {
      'g': {
        'x': '0xf001365',
        'y': '0x3c81364c6dce9bddb804634b625bbe0b1b7a4f216f13dffd1c90341e252b12e89c60818a3f76e5f7ce0304f46efc1e79f9833b03331a27d29947dd85d197d426'
      },
      'target': {
        'x': '0x7d9f11be6003cda59dfa03c6b3b9e7fddf7af40b21f21b784eb307d6b00f0fd90859681235fdf00b1eba6bee59f77578e624de55df754215a6c61fbc16759e09',
        'y': '0x1007124e3acfffe7e6809799c4d9e72f0a7a46f4d862a0c499b8d360517fdb35f8133e733e26d031a5e7ffd40c98c9252dbbe47f89632d3c75c3905d965a5b69'
      }
    },
    {
      'g': {
        'x': '0x10001367',
        'y': '0x12408a3f7844714ebe5a5f934a49dc5354e39b80e24d2acc1a276d7978767209942d3f593d610a32098bf5dd7bea5777263942551c033fc6899a4366e1a70a6f'
      },
      'target': {
        'x': '0xa12b0461215151769252da8534e9a71d86a245c4b65479ce19add541b8f330e515bb13761da44e35de8255b32ee6650eb277ffe9321365ddfc75f3ff327f854a',
        'y': '0x6c54357b434439b67018570d5105841c2a62619e0cf78473b48c1e04cce62e661bac6aead15cec10bc1d4f1c832d1c6ce0880315d0c46c34f91817f3652c8b38'
      }
    },
    {
      'g': {
        'x': '0x1100136c',
        'y': '0x50650c3ed398cc216cc109015d291f61effd293be200943ffd351eea6005de379c8a5064244a45135555e2cb558fcdfe210980bec291d6309825954f84d121f8'
      },
      'target': {
        'x': '0x919e5803a417a61f589afe7c09a32d2d6b672e92aea8b8fb5f2bb9f59360704fc94e3b5b8e86acd70871d376c7d287778b3f6d8a260e30ba22313c9de032ee3d',
        'y': '0x4c49d7a07f2557a552d8388f4c2c40c9ac7b55de48d78fbb0ee37c63a8f6c4f88259c8fb7e4e8c91ee51c64adf1562bfa1170a7f9b47462e84c6e5aded459a90'
      }
    },
    {
      'g': {
        'x': '0x1200136e',
        'y': '0x3f00230755b981485bed4080ab4341e0d09336c62a14df3fbf497a2bbd1e5f150fcb356c4ab1259d60b693fd882908f5f121f56c12aa61175ad0b46fcca4d8bd'
      },
      'target': {
        'x': '0x3db3438a00171c1cbae7dbf64a7b1e0939861037860ba5e55249d7257aa65776199f9462a467487e701fd59aaf12f49e45a2f617e0e3696fbe97fe4796906888',
        'y': '0x2ca3a8a7caf39f04de74519a37f6b32e4b4a3b54d71bc20602679be3d96872561042f4e2c06c61a694c6ee906074ebbff4ae0870b41b8c7f68d5f38e22814fd0'
      }
    }
  ]
}

MODULUS = 0xa8505c94911b17f0b8f567ea473c45e5265362f2bea50b2a61408cd963e1593d9a1ca1253d87ba9ab95c06925eae71eacd8759607052ce8bede3511128721dd5
CHUNK_SIZE = 3
SEARCH_LIMIT = 1 << 24


class Point:
    __slots__ = ('x', 'y')

    def __init__(self, x: int, y: int) -> None:
        self.x = x
        self.y = y

    def key(self) -> tuple[int, int]:
        return (self.x, self.y)


def decode_scalar(scalar: int) -> bytes:
    return scalar.to_bytes(CHUNK_SIZE, 'little')


def encode_chunk(chunk: bytes) -> int:
    return int.from_bytes(chunk, 'little')


def inv_mod(value: int) -> int:
    return pow(value % MODULUS, MODULUS - 2, MODULUS)


def point_neg(point: Point) -> Point:
    return Point(point.x, (-point.y) % MODULUS)


def point_add(lhs: Optional[Point], rhs: Optional[Point]) -> Optional[Point]:
    if lhs is None:
        return rhs
    if rhs is None:
        return lhs

    if lhs.x == rhs.x:
        if (lhs.y + rhs.y) % MODULUS == 0:
            return None
        slope = ((3 * lhs.x * lhs.x) * inv_mod(2 * lhs.y)) % MODULUS
    else:
        slope = ((rhs.y - lhs.y) * inv_mod(rhs.x - lhs.x)) % MODULUS

    x3 = (slope * slope - lhs.x - rhs.x) % MODULUS
    y3 = (slope * (lhs.x - x3) - lhs.y) % MODULUS
    return Point(x3, y3)


def point_sub(lhs: Optional[Point], rhs: Optional[Point]) -> Optional[Point]:
    if rhs is None:
        return lhs
    return point_add(lhs, point_neg(rhs))


def point_mul(scalar: int, point: Point) -> Optional[Point]:
    result: Optional[Point] = None
    addend: Optional[Point] = point
    value = scalar
    while value:
        if value & 1:
            result = point_add(result, addend)
        value >>= 1
        if value:
            addend = point_add(addend, addend)
    return result


def parse_point(blob: dict) -> Point:
    return Point(int(blob['x'], 16), int(blob['y'], 16))


def solve_discrete_log_24(target: Point, base: Point) -> int:
    baby_step_count = 1 << (CHUNK_SIZE * 4)
    baby_steps: dict[tuple[int, int], int] = {}

    current: Optional[Point] = None
    for j in range(baby_step_count):
        if current is not None:
            baby_steps[current.key()] = j
        current = point_add(current, base)

    giant_step = point_mul(baby_step_count, base)
    if giant_step is None:
        raise RuntimeError
    giant_step = point_neg(giant_step)

    current = target
    for i in range(baby_step_count + 1):
        if current is not None and current.key() in baby_steps:
            value = i * baby_step_count + baby_steps[current.key()]
            if value < SEARCH_LIMIT:
                return value
        current = point_add(current, giant_step)
    raise RuntimeError

def main() -> int:
    recovered_chunks: list[bytes] = []
    for index, chunk_blob in enumerate(PARAMS['chunks']):
        g = parse_point(chunk_blob['g'])
        target = parse_point(chunk_blob['target'])
        scalar = solve_discrete_log_24(target, g)
        chunk = decode_scalar(scalar)
        print(f'chunk {index:02d}: {chunk.decode()}')
        recovered_chunks.append(chunk)

    print()
    recovered_flag = b''.join(recovered_chunks)
    print(recovered_flag.decode())


if __name__ == '__main__':
    main()

Which after running would print you the whole flag.

Conclusion

Honestly I was surprised that this task got 0 solves, but nonetheless hope you learned something new from this task and had fun trying it out if you did. Thanks to everybody who tried to solve it without slopping the shit out of it <3