- Prelims
runes.zip
- Extracting
- Decompling the
.pyc
files - Source code analysis
- Running the malware
- Connecting to
34.123.42.200:80
pcapng
file- Conclusion
Prelims
CSIT is a government institution - and whilst they most certainly have the capabilities to see what I’m writing as I’m writing it, no point making it easy. Let’s grab the provided artifact but let’s do this in a container!
Start by installing podman
: brew install podman
. Initialising is easy - podman machine init
followed by podman machine start
(note that if you reboot your host machine you’ll need to re-run podman machine start
). Now let’s use a debian base image. The latest available is trixie
:
~/tmp ❯❯❯ podman run --name sandbox -h sandbox -it docker.io/debian:trixie
Trying to pull docker.io/library/debian:trixie...
Getting image source signatures
Copying blob sha256:e5e40a2b9fe32b2158c946023b700f61f57f567701b6be2e04192bbcc68fb32d
Copying config sha256:e05898b0463444d478837b2a23b58dce9c9848aecdb87e8df8b811338ca26b8b
Writing manifest to image destination
root@sandbox:/#
We now have an isolated environment in which to download the .zip
provided. However it’s totally bare, so let’s add a few packages that will make our live easier:
# apt update
# apt install curl binutils binutils-common unzip file llvm gzip git build-essential vim zlib1g-dev cmake tcpdump libssl-dev
runes.zip
Let’s goooo!
root@sandbox:/tmp# curl -s -L https://go.gov.sg/ancient-runes -o runes.zip
root@sandbox:/tmp# unzip -l runes.zip
Archive: runes.zip
Length Date Time Name
--------- ---------- ----- ----
9144168 2025-01-13 14:23 client
9144888 2025-01-13 14:22 dev_server
29716 2025-01-20 14:24 #U86c7#U5e74#U5409#U7965.pcapng
--------- -------
18318772 3 files
So 2 files and a pcapng
packet capture. That sounds interesting. Let’s extract and see:
root@sandbox:/tmp# mkdir extract
root@sandbox:/tmp# unzip runes.zip -d extract/
Archive: runes.zip
inflating: extract/client
inflating: extract/dev_server
inflating: extract/#U86c7#U5e74#U5409#U7965.pcapng
root@sandbox:/tmp# cd extract/
root@sandbox:/tmp/extract# file client dev_server
client: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=8485f6953c06d12b9865185ba3466fdbf9b4a65c, for GNU/Linux 2.6.32, stripped
dev_server: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=8485f6953c06d12b9865185ba3466fdbf9b4a65c, for GNU/Linux 2.6.32, stripped
root@sandbox:/tmp/extract#
Extracting
So both client
and dev_server
are ELF binaries. As per the hint, those are likely installers from which we need to extract python modules. Let’s start with client
:
root@sandbox:/tmp/extract# llvm-objdump -h client | grep pydata
26 pydata 008a9c93 0000000000000000
Sure enough, it is. Let’s extract it:
root@sandbox:/tmp/extract# llvm-objcopy --dump-section pydata=pydata.client.dump client
root@sandbox:/tmp/extract# file pydata.client.dump
pydata.client.dump: zlib compressed data
The section is essentially a compressed stream. As per the linked document we need a copy of pyinstxtractor
to extract the .pyc
files from the .pydata
section. Thankfully it’s a single python script so we can run it from the default interpreter:
root@sandbox:/tmp/extract# curl -s https://raw.githubusercontent.com/extremecoders-re/pyinstxtractor/refs/heads/master/pyinstxtractor.py -o pyinstxtractor.py
root@sandbox:/tmp/extract# python3 pyinstxtractor.py pydata.client.dump
[+] Processing pydata.client.dump
[+] Pyinstaller version: 2.1+
[+] Python version: 3.10
[+] Length of package: 9084051 bytes
[+] Found 98 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: client.pyc
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python 3.10 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: pydata.client.dump
You can now use a python decompiler on the pyc files within the extracted directory
Mmm there’s a warning - so this was created using python 3.10, which is a fair bit older than the system-installed verison:
root@sandbox:/tmp/extract# python3 --version
Python 3.13.1
For the sake of completeness, let’s get python 3.10 installed. Unfortunately it’s no longer packaged so we’ll have to install this from source. No biggie:
root@sandbox:/tmp/extract# curl -s https://www.python.org/ftp/python/3.10.0/Python-3.10.0.tgz -o Python-3.10.0.tgz
root@sandbox:/tmp/extract# tar xvf Python-3.10.0.tgz
...
root@sandbox:/tmp/extract# cd Python-3.10.0/
root@sandbox:/tmp/extract/Python-3.10.0# ./configure
...
root@sandbox:/tmp/extract/Python-3.10.0# make
...
The make
step will take a a few minutes while depending on how performant your system is. Use -j <number of cores>
to speed this up across multiple cores.
Once it’s done, let’s just create a venv
(this will ensure python3
refers to the one we just installed):
root@sandbox:/tmp/extract/Python-3.10.0# ./python -m venv venv
root@sandbox:/tmp/extract/Python-3.10.0# source venv/bin/activate
(venv) root@sandbox:/tmp/extract/Python-3.10.0# cd ../
(venv) root@sandbox:/tmp/extract# python --version
Python 3.10.0
Now let’s extract the data again:
(venv) root@sandbox:/tmp/extract# python pyinstxtractor.py pydata.client.dump
[+] Processing pydata.client.dump
[+] Pyinstaller version: 2.1+
[+] Python version: 3.10
[+] Length of package: 9084051 bytes
[+] Found 98 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: client.pyc
[+] Found 282 files in PYZ archive
[+] Successfully extracted pyinstaller archive: pydata.client.dump
You can now use a python decompiler on the pyc files within the extracted directory
No warnings! We can see a couple of .pyc
files. The one we’re interested in is client.pyc
. However we can’t peek inside as-is:
(venv) root@sandbox:/tmp/extract/pydata.client.dump_extracted# file client.pyc
client.pyc: Byte-compiled Python module for CPython 3.10, timestamp-based, .py timestamp: Thu Jan 1 00:00:00 1970 UTC, .py size: 0 bytes
(venv) root@sandbox:/tmp/extract/pydata.client.dump_extracted# head -2 client.pyc
o
�@s�ddlmZddlZddlZddlZddlZddlZddlZddZ ddl
For this we’ll need to decompile the bytecode.
Decompling the .pyc
files
pycdc
is a tool that does just that:
(venv) root@sandbox:/tmp/extract# git clone https://github.com/zrax/pycdc.git
Cloning into 'pycdc'...
remote: Enumerating objects: 2914, done.
remote: Total 2914 (delta 0), reused 0 (delta 0), pack-reused 2914 (from 1)
Receiving objects: 100% (2914/2914), 895.05 KiB | 20.34 MiB/s, done.
Resolving deltas: 100% (1837/1837), done.
Just like python
, we’ll need to build the binaries:
(venv) root@sandbox:/tmp/extract# cd pycdc/
(venv) root@sandbox:/tmp/extract/pycdc# mkdir build && cd build && cmake ..
-- The C compiler identification is GNU 14.2.0
-- The CXX compiler identification is GNU 14.2.0
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Found Python3: /tmp/extract/Python-3.10.0/venv/bin/python3 (found suitable version "3.10.0", minimum required is "3.6") found components: Interpreter
-- Configuring done (0.3s)
-- Generating done (0.0s)
-- Build files have been written to: /tmp/extract/pycdc/build
(venv) root@sandbox:/tmp/extract/pycdc/build# make
[ 2%] Building CXX object CMakeFiles/pycxx.dir/bytecode.cpp.o
...
[ 97%] Building CXX object CMakeFiles/pycdc.dir/ASTNode.cpp.o
[100%] Linking CXX executable pycdc
[100%] Built target pycdc
(venv) root@sandbox:/tmp/extract/pycdc/build#
Let’s take it for a spin:
(venv) root@sandbox:/tmp/extract# pycdc/build/pycdc pydata.client.dump_extracted/client.pyc | tail -5
Unsupported opcode: BEFORE_ASYNC_WITH (94)
Unsupported opcode: BEFORE_ASYNC_WITH (94)
asyncio.run(send_commands(uri, headerss, logging))
if __name__ == '__main__':
start_client()
return None
Huh. So it turns out that certain opscode aren’t supported by pycdc
- in other words, it’s unable to decompile a .pyc
that makes use of certain features. We can use pcdas
tos how the actual byte code:
(venv) root@sandbox:/tmp/extract# pycdc/build/pycdas pydata.client.dump_extracted/client.pyc | grep BEFORE_AS
18 BEFORE_ASYNC_WITH
18 BEFORE_ASYNC_WITH
Hint #2 mentions a corgi and issue #234. The issue was easy enough to find but the corgi part had me stumped for a while until I came across that post (which actually links back to issue 234!). Unfortunately the pycdc
project has moved on quite a bit since this was written meaning (1) the code changed and (2) the line numbers no longer line up.
I added the missing codes one by one (don’t forget to call make
again after applying those changes):
(venv) root@sandbox:/tmp/extract/pycdc# git diff
diff --git a/ASTree.cpp b/ASTree.cpp
index 1f419d0..889a8ce 100644
--- a/ASTree.cpp
+++ b/ASTree.cpp
@@ -1036,6 +1036,12 @@ PycRef<ASTNode> BuildFromCode(PycRef<PycCode> code, PycModule* mod)
case Pyc::JUMP_IF_TRUE_A:
case Pyc::JUMP_IF_FALSE_OR_POP_A:
case Pyc::JUMP_IF_TRUE_OR_POP_A:
+ case Pyc::BEFORE_ASYNC_WITH:
+ case Pyc::SETUP_ASYNC_WITH_A:
+ case Pyc::RERAISE:
+ case Pyc::RERAISE_A:
+ case Pyc::JUMP_IF_NOT_EXC_MATCH_A:
+ case Pyc::END_ASYNC_FOR:
case Pyc::POP_JUMP_IF_FALSE_A:
case Pyc::POP_JUMP_IF_TRUE_A:
case Pyc::POP_JUMP_FORWARD_IF_FALSE_A:
Certain things are still unsupported
(venv) root@sandbox:/tmp/extract/pycdc/build# ./pycdc ../../pydata.client.dump_extracted/client.pyc | grep Unsupported
Warning: Stack history is not empty!
Warning: block stack is not empty!
Unsupported Node type: 28
Unsupported Node type: 28
Unsupported Node type: 28
Unsupported Node type: 28
Warning: Stack history is not empty!
Warning: block stack is not empty!
Unsupported Node type: 28
Unsupported Node type: 28
Unsupported Node type: 28
but at least the output resembles something you could almost run through an interpreter!
(venv) root@sandbox:/tmp/extract/pycdc/build# ./pycdc ../../pydata.client.dump_extracted/client.pyc 2>&1 | head -30 | tail -6
from common import validate, generate_client_context, generate_random_string
from constants import LOGGING_VERBOSITY, HINT_3
load_dotenv()
logging.basicConfig(LOGGING_VERBOSITY, '%(asctime)s - %(levelname)s - %(message)s', **('level', 'format'))
SERVER_IP = os.getenv('SERVER_IP', '127.127.127.127')
SERVER_PORT = os.getenv('SERVER_PORT', '9999')
Let’s extract this to a single file:
(venv) root@sandbox:/tmp/extract# ./pycdc/build/pycdc pydata.client.dump_extracted/client.pyc > client.py
Repeating the steps above for the server
binary - TL;DR being:
(venv) root@sandbox:/tmp/extract# llvm-objcopy --dump-section pydata=pydata.server.dump dev_server
(venv) root@sandbox:/tmp/extract# python pyinstxtractor.py pydata.server.dump
(venv) root@sandbox:/tmp/extract# ./pycdc/build/pycdc pydata.server.dump_extracted/dev_server.pyc > server.py
Source code analysis
client.py
The client is pretty basic. It starts by defining a couple of globals:
SERVER_IP = os.getenv('SERVER_IP', '127.127.127.127')
SERVER_PORT = os.getenv('SERVER_PORT', '9999')
WS_IP = os.getenv('WS_IP', '0.0.0.0')
WS_PORT = os.getenv('WS_PORT', '8000')
TIMEOUT = 5
CA_CERT = 'DEV_ca.crt'
CLIENT_CERT = 'DEV_client.crt'
CLIENT_KEY = 'DEV_client.key'
CLIENT_SERVER = f'''http://{SERVER_IP}:{SERVER_PORT}/REVW/'''
before opening to WebSocket connections to a server, one to receive messages and one to send command:
def start_client():
_string = generate_random_string()
headersl = {
'X-ARBOC': f'''{_string}-listener''' }
headerss = {
'X-ARBOC': f'''{_string}-sender''' }
uri = f'''wss://{WS_IP}:{WS_PORT}/'''
listener_thread = None((lambda : asyncio.run(listen_messages(uri, headersl, logging))), **('target',))
listener_thread.daemon = True
listener_thread.start()
asyncio.run(send_commands(uri, headerss, logging))
Parts of the code aren’t readily executable - e.g. await websockets.connect(uri, headers, context, **('additional_headers', 'ssl'))
should actually be await websockets.connect(uri, additional_headers=headers, ssl=context)
. I originally tried to fix those bits but as we’ll see later there’s a much simpler way.
server.py
The server is more interesting - first of all, there’s a flag!
FLAG = '7eb66acfb3652e80ef006143b4e5b6565b84b51355b26e39d2979a3bc873ba394ecae0061bd9522a9639ac4488733ad97d5e5acfb1e3e6f7'
But as we see a few lines below, it’s encrypted:
if command == '88':
try:
decrypted_message = decrypt_3des(bytes.fromhex(FLAG), bytes.fromhex(args1))
if client == senderId:
await clients[client]['listener'].send(f'''s()p3rR00+: {decrypted_message}''')
Looking at the parsing logic, to trigger the above we’d need to pass in 5n@k3#88#<key in hex>
. The rest of the commands are bogus - they only act on the target but never stream the results back to the client:
if command == '01':
os.system('ip addr')
if command == '33':
os.system('hostname')
if command == '27':
os.system('cat /proc/cpuinfo')
constants.py
and common.py
This part looked a little sus, and was present in both client.py
and server.py
in similar forms:
from common import validate, generate_server_context, decrypt_3des
from constants import LOGGING_VERBOSITY
It turns out the local constants
module was in its own _extracted
directory - probably a consequence of how it was packaged as an installer:
(venv) root@sandbox:/tmp/extract# ./pycdc/build/pycdc pydata.server.dump_extracted/PYZ-00.pyz_extracted/constants.pyc
# Source Generated with Decompyle++
# File: constants.pyc (Python 3.10)
import logging
import os
from dotenv import load_dotenv
load_dotenv()
LOGGING_VERBOSITY = os.getenv('LOGGING_VERBOSITY', 'NONE')
LOGGING_MORE_VERBOSITY = os.getenv('LOGGING_MORE_VERBOSITY', 'NONE')
if LOGGING_VERBOSITY == 'REG0D' and LOGGING_MORE_VERBOSITY == 'y0uReOnToSomEThinG':
LOGGING_VERBOSITY = logging.DEBUG
elif LOGGING_VERBOSITY == 'REG0D':
LOGGING_VERBOSITY = logging.INFO
else:
LOGGING_VERBOSITY = logging.ERROR
HINT_1 = '[HINT] Have you tried REading the binaries? Where are you downloading them from?'
HINT_2 = '[HINT] Have you tried REading the binaries? Is your SSL Context malformed or what?'
HINT_3 = "[HINT] Have you tried REading the binaries? Bruh we can't make a connection..."
common.py
is more interesting - among other things it looks like the validate
function will download the certs from a server:
def download_file(url, dest_path, log):
try:
with urllib.request.urlopen(url) as response:
with open(dest_path, 'wb') as f:
f.write(response.read())
It also contains the function that tries to decrypt the flag:
def decrypt_3des(ciphertext = None, key = None):
iv = ciphertext[:DES3.block_size]
cipher = DES3.new(key, DES3.MODE_CBC, iv, **('iv',))
decrypted_data = unpad(cipher.decrypt(ciphertext[DES3.block_size:]), DES3.block_size)
return decrypted_data.decode()
The init
method writes a .env
file with some settings we haven’t seen before:
def init():
if not os.path.exists('.env'):
env_content = '\nLOGGING_VERBOSITY=REG0D\n\nSERVER_IP=34.57.139.144\nSERVER_PORT=80\n\nWS_IP=\nWS_PORT=\n'
with open('.env', 'w') as env_file:
env_file.write(env_content.strip())
...
Reconstructing the URLs we saw earlier (CLIENT_SERVER = f'''http://{SERVER_IP}:{SERVER_PORT}/REVW/'''
) with the SERVER_IP
and SERVER_PORT
defined above, is there… something to be had?
(venv) root@sandbox:/tmp/extract# curl -s http://34.57.139.144:80/REVW/DEV_client.crt
-----BEGIN CERTIFICATE-----
MIID7DCCAtSgAwIBAgIURwHCApjuZfWsUr7uZ5RZJIIiXWswDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTAxMjAxNDA2NThaFw0yNTAy
MTkxNDA2NThaMIGmMQswCQYDVQQGEwJKUDEOMAwGA1UECAwFT3Nha2ExDjAMBgNV
BAcMBUphcGFuMR0wGwYDVQQKDBRZYW1hc2hpdGEgQm9va29zaGl0YTEdMBsGA1UE
CwwUWWFtYXNoaXRhIEJvb2tvc2hpdGExGzAZBgNVBAMMEnRoZWZsYWcuaXNub3Qu
aGVyZTEcMBoGCSqGSIb3DQEJARYNZG8ubm90QGJvdGhlcjCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAOGOVsGBMZI9xpWjOF2+jsjJISLxVLSpcjA89BGr
282Rti2to5A1HiMBanZ2u/wMlMpBf85dPO8WUpsYivvrzR7H2YcbPoH0HZ17cTQS
qRWLVtBx2aLgwlk6XSILNPaw6whqilEaGUjL13+IQDDx+3WCqIhQBg5P4LJWE/2r
wvXg5jK/gpcV2eFoB+CiSOfA0poNboXjtoqKNt1IOJkGHL3djgqAU11sPpcP3bPc
jlWg3wnnn4EKT4jWwjTkBa+M27gs/0tx4PpnPxMTIp395ISzjw7fWlXUFqbB3lu/
sbV/9KSvLE+u3OE1isA2cv9HLEO/PmZdCaHBZYDhcRPifdECAwEAAaNyMHAwCQYD
VR0TBAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYD
VR0OBBYEFG++3czcrELuWqt4FwHVPIhQ4bztMB8GA1UdIwQYMBaAFD+2frPEHQ9T
yP44vK2IQl+4zyKyMA0GCSqGSIb3DQEBCwUAA4IBAQBhbnPrwhc6SmX3MfYqloPi
GAXphAPX+TALzky77d+PWsLnAmnuYaw/G4LfuQY7c1qIB1CYiupVR4dqpY+pOnwG
zjxis1t58aXH0bFVNwTJLjXYxdooqDy4xio1cpFXx6A4tbVeFWut4DJFNkF759Ec
u9ScTsFvmyGOmj1l60TYG7iK9lOjvnX1Sze2AhEOA6La8ph3D9FZx1AJP/iTuTTy
xzjfu1MFVSU9tajgU3NfS2VtI3POPZe9kb0IgNSc6j4ooukgJnDU9KBj8w18+oQh
e1qwJYNpLcHPaX8LaCusNRZZU1b2BkhMwa+kkcn+wqHjcOUkNWH5yKVSF7BhOUOi
-----END CERTIFICATE-----
Ha! Getting somewhere. But let’s pause this for a while whilst we try to run the actual bytecode.
Running the malware
When running a Python script, the interpreter will first convert it to bytecode - precisely what the .pyc
files are. And we can just run them as such:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# python dev_server.pyc
Traceback (most recent call last):
File "dev_server.py", line 1, in <module>
ModuleNotFoundError: No module named 'dotenv'
Or try to. We can’t seem to run the pyc
files alone - we need to install dependencies, even though those were packaged. No trouble, let’s just pip install
them in our venv
:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# pip install python-dotenv websockets pycryptodome
But:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# python dev_server.pyc
Traceback (most recent call last):
File "dev_server.py", line 7, in <module>
ModuleNotFoundError: No module named 'common'
Mmm. Let’s copy the relevant .pyc
s in the same directory (the reason we don’t try to decompile is because not all opscode used by common.py
are supported by pycdc
):
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# cp PYZ-00.pyz_extracted/common.pyc .
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# cp PYZ-00.pyz_extracted/constants.pyc .
Let’s try again:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# python dev_server.pyc
Traceback (most recent call last):
File "dev_server.py", line 7, in <module>
File "common.py", line 11, in <module>
File "constants.py", line 5, in <module>
File "/tmp/extract/Python-3.10.0/venv/lib/python3.10/site-packages/dotenv/main.py", line 346, in load_dotenv
dotenv_path = find_dotenv()
File "/tmp/extract/Python-3.10.0/venv/lib/python3.10/site-packages/dotenv/main.py", line 305, in find_dotenv
assert frame.f_back is not None
AssertionError
Closer, but not there yet. At this point I got a little fed up and decided to edit the dotenv/main.py
file directly:
if True: # usecwd or _is_interactive() or getattr(sys, 'frozen', False):
# Should work without __file__, e.g. in REPL or IPython notebook.
path = os.getcwd()
Trying this again:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# python dev_server.pyc
^CTraceback (most recent call last):
File "dev_server.py", line 134, in <module>
File "/tmp/extract/Python-3.10.0/Lib/asyncio/runners.py", line 44, in run
return loop.run_until_complete(main)
File "/tmp/extract/Python-3.10.0/Lib/asyncio/base_events.py", line 628, in run_until_complete
self.run_forever()
File "/tmp/extract/Python-3.10.0/Lib/asyncio/base_events.py", line 595, in run_forever
self._run_once()
File "/tmp/extract/Python-3.10.0/Lib/asyncio/base_events.py", line 1845, in _run_once
event_list = self._selector.select(timeout)
File "/tmp/extract/Python-3.10.0/Lib/selectors.py", line 469, in select
fd_event_list = self._selector.poll(timeout, max_ev)
KeyboardInterrupt
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# cat .env
LOGGING_VERBOSITY=REG0D
SERVER_IP=34.57.139.144
SERVER_PORT=80
WS_IP=
WS_PORT
It didn’t seem to do anything to I ctrl-c’d it - but it wrote a .env
file! The first thing that stood out was REG0D
- we saw this previously in constants.py
. Now I tried adding LOGGING_MORE_VERBOSITY=yeuReOnToSomEThinG
to the .env
and using source .env
directly:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# python dev_server.pyc
2025-02-08 02:04:10,809 - INFO - DEV_server.crt not found. Downloading...
2025-02-08 02:04:11,322 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_server.crt to DEV_server.crt
2025-02-08 02:04:11,322 - INFO - DEV_server.key not found. Downloading...
2025-02-08 02:04:11,767 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_server.key to DEV_server.key
2025-02-08 02:04:11,767 - INFO - DEV_ca.crt not found. Downloading...
2025-02-08 02:04:12,335 - INFO - Downloaded file from http://34.57.139.144:80/REVW/DEV_ca.crt to DEV_ca.crt
2025-02-08 02:04:12,362 - INFO - server listening on [::]:38265
2025-02-08 02:04:12,362 - INFO - server listening on 0.0.0.0:33215
2025-02-08 02:04:12,362 - INFO - Server started...
Ha! That’s neat - we’re downloading the certs. Let’s delete the WS_IP
and WS_PORT
vars from the .env
to have it default to what’s in code and run this again:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# python dev_server.pyc
2025-02-08 02:10:08,072 - INFO - DEV_server.crt found.
2025-02-08 02:10:08,072 - INFO - DEV_server.key found.
2025-02-08 02:10:08,072 - INFO - DEV_ca.crt found.
2025-02-08 02:10:08,079 - INFO - server listening on 0.0.0.0:8000
2025-02-08 02:10:08,079 - INFO - Server started...
Client-wise we just need to specify WS_IP
and WS_PORT
:
(venv) root@sandbox:/tmp/extract/pydata.client.dump_extracted# export WS_IP=0.0.0.0
(venv) root@sandbox:/tmp/extract/pydata.client.dump_extracted# export WS_PORT=8000
(venv) root@sandbox:/tmp/extract/pydata.client.dump_extracted# python client.pyc
2025-02-08 02:12:01,455 - INFO - DEV_client.crt found.
2025-02-08 02:12:01,455 - INFO - DEV_client.key found.
2025-02-08 02:12:01,455 - INFO - DEV_ca.crt found.
>> Welcome to the chat!
Success!
Connecting to 34.123.42.200:80
Presumably there’s something similar running on 34.123.42.200:80
but setting WS_IP
an WS_PORT
to this doesn’t seem to connect. Looking a bit more closely, the server and client above use mTLS - meaning each of the client and the server have their own certificates, both sied by the same root CA (DEV_ca.crt
). Running openssl s_client -connect 34.123.42.200:80
we see:
Certificate chain
0 s:CN=34.123.42.200
i:C=JP, ST=Okohamaya Prefecture, L=Mount GXFC, O=Some Legit Company Name Pty Ltd, OU=Snake, Cobras, Serpents , CN=www.venom.com, emailAddress=obanai@venom.com
a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
v:NotBefore: Jan 20 14:11:49 2025 GMT; NotAfter: Feb 19 14:11:49 2025 GMT
1 s:C=JP, ST=Okohamaya Prefecture, L=Mount GXFC, O=Some Legit Company Name Pty Ltd, OU=Snake, Cobras, Serpents , CN=www.venom.com, emailAddress=obanai@venom.com
i:C=JP, ST=Okohamaya Prefecture, L=Mount GXFC, O=Some Legit Company Name Pty Ltd, OU=Snake, Cobras, Serpents , CN=www.venom.com, emailAddress=obanai@venom.com
a:PKEY: rsaEncryption, 2048 (bit); sigalg: RSA-SHA256
v:NotBefore: Jan 20 14:11:25 2025 GMT; NotAfter: Feb 19 14:11:25 2025 GMT
Heh. Someone is having fun - obanai
is a character in the Demon Slayer anime. We can pass in -showcerts
to get hold of those 2 certificates (one for the server, one for the root):
This is the cert for the server, which we’ll call server.crt
:
-----BEGIN CERTIFICATE-----
MIIGZzCCBU+gAwIBAgIUZGu6VKcsReWy88S63KR7m222XtQwDQYJKoZIhvcNAQEL
BQAwgccxCzAJBgNVBAYTAkpQMR0wGwYDVQQIDBRPa29oYW1heWEgUHJlZmVjdHVy
ZTETMBEGA1UEBwwKTW91bnQgR1hGQzEoMCYGA1UECgwfU29tZSBMZWdpdCBDb21w
YW55IE5hbWUgUHR5IEx0ZDEhMB8GA1UECwwYU25ha2UsIENvYnJhcywgU2VycGVu
dHMgMRYwFAYDVQQDDA13d3cudmVub20uY29tMR8wHQYJKoZIhvcNAQkBFhBvYmFu
YWlAdmVub20uY29tMB4XDTI1MDEyMDE0MTE0OVoXDTI1MDIxOTE0MTE0OVowGDEW
MBQGA1UEAwwNMzQuMTIzLjQyLjIwMDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBAKv81Xp7+i+so+XgAGyWmUTv7R8D7k+u0w/U1g/vPRVA/49JQyBHr4Tr
vHyWSM4iYJD9MUPrx+tN6VfYaeUVcNalFaB0i1qlrrbpT0P7KrZFp6pJ6WgYWP7I
5Hz899UVuH5delSEu4ynVtEVKDADhfRCqJg1MRvKJ1Bv195NYhXcrNl13DlKOHo8
ttZzQu3IOT63Rug4kvg7Q5Bdw9l8ZumrYLwbyR3z56cwOLTMDqtBDqgsdyIhS6ZL
pp5Nbr7KfVuxsMmIuLtCnRyjXKa700IRZQuIvrsj9dTMdqe8nl8NvLcXk3V6ejcf
fuxXYffOFb4+dIPNsKxsJVd9Ipm9Zz8CAwEAAaOCAvcwggLzMAkGA1UdEwQCMAAw
CwYDVR0PBAQDAgXgMBUGA1UdEQQOMAyHBCJ7KsiHBAAAAAAwEwYDVR0lBAwwCgYI
KwYBBQUHAwEwgY0GAyoMBQSBhQyBglBCRkVKSkRNUE1GSk5NSEpKQURGUEJGRUlD
Q0hQR0ZETkdIREpBREZQSkZNSVBDS09LRVBNS0dQSUlDTlBCRkVJRkNBT0FFRkpE
REdMREJHUENGSElBQ0ZPRkVBTUZHQUpDREhPQUVGSVBDS09CRUVJR0NES0dBRFBD
RkhKS0RQUFAwgY0GAyoMBgSBhQyBgkZLTlBIS0pNREpQREZHSUJDRVBERkdKR0RE
UEZGQUlCQ0VLQkFFT0RFR0pLRFBPT0VMSUxDT1BJRk5OSUhOSkxET1BFRkJKSkRN
UExGT0pDREhQTUZKSkpETVBORklOTkhJSUpDTU9HRURJQkNFT0VFQkpBREZQSUZO
Sk5ESU9QRUswgY0GAyoMBwSBhQyBgk1QR0tJR0NEUEZGQU5GSEFKRURCUEhGQ0lE
Q0dQR0ZESkhEQ1BMRk9KSERDT09FTE1PR0xKS0RQUENGSEpIRENMSEJDUENGSElD
Q0hPTkVJSU9DTE9HRURNR0dESkRER1BORklKRURCT01FSk1NR0pKSUROUEJGRUpN
REpQSkZNSUswgY0GAyoMCASBhQyBgkNQUE9GTEpQREtQQ0ZISUNDSEtDQUhPTEVP
SUZDQUtGQUFQR0ZESkRER1BBRkZKUERLUEJGRUpGREFPR0VETUdHRElBQ0ZPUEVL
Sk5ESUxOQklQT0ZMSkdERFBQRktKQkRFUEVGQklIQ0NPQ0VITUNHSElNQ0pPSkVN
Sk9ETExPQkwwLQYDKgwJBCYMJE9IRUNJQ0NIT0RFR0pCREVMQkJFSURDR0xEQkdJ
QkNFTEVCQjAdBgNVHQ4EFgQUrFFHik5aTqGqWCOklLxaHO3UcMkwHwYDVR0jBBgw
FoAUYsWlIRiB4ZO1XDXydCFM0g8V+pMwDQYJKoZIhvcNAQELBQADggEBAC862Xv9
VX5P7BA5pN9BSucEd+BZ/I43vX4I7iqxBjIW5s1E/AIpnx9baESSHo8iirmEyx4e
J/mhommFFfFIO/PzgUsJX2f+Y/3aNAUCNSk9fkMb0Izxhxp7A/HcXV41vTtmTxQI
Zqrlb1zz4VrXxzAQBxIh/NTYgLjVLB65LRzDgswO7K1pn2c6ksI1HSzl6mGZRIK/
IOib+dlRSf7aCtNRENO7LxCt9kcdQE5gr/9tyDqzIcgm6OwpBlNvZtExICaIxWGf
Nnmz1EZkYzoYAa5HyxPlxeJ8/7lBbdSJhKkcx1NiHvV5qC0+By9EqH1pUfuUEC2k
cCJhq8E16/1cfZI=
-----END CERTIFICATE-----
and for the CA - ca.crt
:
-----BEGIN CERTIFICATE-----
MIIEcTCCA1mgAwIBAgIUfL+s0KwbC9FBIW+eHwNQiAgJPlowDQYJKoZIhvcNAQEL
BQAwgccxCzAJBgNVBAYTAkpQMR0wGwYDVQQIDBRPa29oYW1heWEgUHJlZmVjdHVy
ZTETMBEGA1UEBwwKTW91bnQgR1hGQzEoMCYGA1UECgwfU29tZSBMZWdpdCBDb21w
YW55IE5hbWUgUHR5IEx0ZDEhMB8GA1UECwwYU25ha2UsIENvYnJhcywgU2VycGVu
dHMgMRYwFAYDVQQDDA13d3cudmVub20uY29tMR8wHQYJKoZIhvcNAQkBFhBvYmFu
YWlAdmVub20uY29tMB4XDTI1MDEyMDE0MTEyNVoXDTI1MDIxOTE0MTEyNVowgccx
CzAJBgNVBAYTAkpQMR0wGwYDVQQIDBRPa29oYW1heWEgUHJlZmVjdHVyZTETMBEG
A1UEBwwKTW91bnQgR1hGQzEoMCYGA1UECgwfU29tZSBMZWdpdCBDb21wYW55IE5h
bWUgUHR5IEx0ZDEhMB8GA1UECwwYU25ha2UsIENvYnJhcywgU2VycGVudHMgMRYw
FAYDVQQDDA13d3cudmVub20uY29tMR8wHQYJKoZIhvcNAQkBFhBvYmFuYWlAdmVu
b20uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqvpqXYyctGs0
cs9v4KBStpYuNcLPlf0bb6Ml6vJ8A2TcARc7nILQz4Cz0RfV4VNxhUaPypVTvEHc
w41mEP3o4GKH5A6il8MzY7/C32pMfDbsW7Nrl0lLyOfiCJ+zARv2xlWkh3N62AIK
ScUg18PBeBUm4E3w4WPG9ibCA4EkLEtIAV1bnIflMy3X9JOh0TehBf0I6xST3x96
FUy2H2FBlWqDGYxX0tPc7vwCcxmlCXB+oIZn2RJycBhXUSLD7RtrUDuZjbG/Fnc7
7Fli8Sw3B0YtjxSG+aYhQc+TitOEko9A7QcvPwKxQwVIaYuHnv7srCMY5F7jX0fE
IT1XvdnenQIDAQABo1MwUTAdBgNVHQ4EFgQUYsWlIRiB4ZO1XDXydCFM0g8V+pMw
HwYDVR0jBBgwFoAUYsWlIRiB4ZO1XDXydCFM0g8V+pMwDwYDVR0TAQH/BAUwAwEB
/zANBgkqhkiG9w0BAQsFAAOCAQEARHhVscPzvqF+CbScXWz8DHlremDYacTvnTYI
eZIqgVq4zDbKrkuRuNaYiyNeB2V04LD8jUct7UNbNInEMGpoTGiCOiUdksXFlClD
WEwM9fRhwB/RhEd8YIFcrlNcDvHdGbg7MPvFl7d5dR59YIBm5ftoIXUz5GvT1sV3
8CpJdO6uppqlKI1CgXLOCvTQFhbXr53VmuD04uh4671VNNIgL8weXUa/l7enYWmg
CA/jmrr4oRzt2AkKZqVDwFsUphILt89wyDnxFlaz0v52RB88YzUHzRL7ZR6V10Fl
hLh3LGvCEfrdUtMUx+RrSpT4FTW9sBGXmKRuCh9EatfdNAHgMg==
-----END CERTIFICATE-----
For good measure:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# openssl x509 -in server.crt -text | grep -e "Subject:" -e "CA:"
Subject: CN=34.123.42.200
CA:FALSE
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# openssl x509 -in ca.crt -text | grep -e "Subject:" -e "CA:"
Subject: C=JP, ST=Okohamaya Prefecture, L=Mount GXFC, O=Some Legit Company Name Pty Ltd, OU=Snake, Cobras, Serpents , CN=www.venom.com, emailAddress=obanai@venom.com
CA:TRUE
At that point I wasn’t really getting anywhere. My view was that perhaps I needed to generate my own certificate for my IP address. I dug around and found that curl -s http://34.57.139.144:80/REVW/DEV_ca.key
did indeed yield the CA’s root key! However that winning feeling was short-lived:
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# openssl verify -CAfile DEV_ca.crt DEV_server.crt
DEV_server.crt: OK
(venv) root@sandbox:/tmp/extract/pydata.server.dump_extracted# openssl verify -CAfile DEV_ca.crt server.crt
CN=34.123.42.200
error 20 at 0 depth lookup: unable to get local issuer certificate
error server.crt: verification failed
ca.crt
isn’t the same as DEV_ca.crt
- so even having the root CA key does not allow me to make a certificate that will be accepted by the server. Cue sad trombone…
pcapng
file
I originally dismissed the pcapng
- I couldn’t get much out of it using tcpdump
:
(venv) root@sandbox:/tmp/extract# tcpdump -A -r '#U86c7#U5e74#U5409#U7965.pcapng' | head -5
reading from file #U86c7#U5e74#U5409#U7965.pcapng, link-type EN10MB (Ethernet), snapshot length 262144
14:23:03.749464 IP localhost.57728 > localhost.1234: Flags [S], seq 854060939, win 65495, options [mss 65495,sackOK,TS val 3991594378 ecr 0,nop,wscale 7], length 0
E..<r.@.@...............2............0.........
............
14:23:03.749466 IP localhost.57730 > localhost.1234: Flags [S], seq 3700194746, win 65495, options [mss 65495,sackOK,TS val 3991594378 ecr 0,nop,wscale 7], length 0
E..<r.@.@.................}..........0.........
tcpdump: Unable to write output: Broken pipe
The data looked compressed, encrypted or obth. I was hoping I’d see the command required for key but no dice. On a whim I loaded this up in Wireshark. Sure enough the data was encrypted. But now that we had keys at our disposal, could we decrypt it? In newer versions of TLS we have forward secrecy. Having keys are not sufficient to decrypt a communication that happened in the past. But given the SSL context in the client specified context.options |= ssl.OP_NO_TLSv1_3
, I had hope. Sure enough:
Here’s the text decrypted:
Welcome to the chat!
hi therewelcomeso you are here because nian is coming to kill us all?i seewell i can help you with thatno worries :)i hope you have REad enough into the ancient runesthose are your keys to successas for the key itself, it is the weakness of nian: 红 火 热闹take the 汉语拼音 of those 4 characters, then separate them with a "_" and pad them with 4 plusesoh? missing some bytes?well...oh darn, nian has comeit has bit off both my legsim dying...i dont have time to tell you the last part of the key... but maybe if you look into the SSL certificates...encoding???? i cant remember... it sounds like the acid found in mandarin oranges and other citrus fruits...urgh... im sorry... im losing too much bloodwe are counting on you...
My Chinese is rudimentary at best, but from what I could decipher we are asked to take the pinyin of 红 火 热闹, add _
in between and pad it with 4 pluses, giving us hóng_ huǒ_ rè_ nào++++
. A triple DES key is 24 bytes, and if we include the accents we have:
>>> len('hóng_huǒ_rè_nào++++'.encode('utf-8'))
23
Which gives us one missing byte. If we only include ASCII we have:
>>> len('hong_huo_re_nao++++'.encode('utf-8'))
19
meaning we’d be 5 bytes short.
I searched high and low for the missing bytes. The hint given is the SSL cert, and the only thing that looked vaguly related was the Subject
in the root CA of the target (not the DEV server): L=Mount GXFC
. GXFC could be an abbreviation for 恭喜发财, or gōng xǐ fā cái. But the latter would be more than 5 bytes. Brute-forcing was also not an option - that’s 255^5 possibilities
Could be something to do with the encoding? The only one that (perhaps?) rhymes with citric acid is cyrillic but that’s not a valid encoding game. Ugh.
Conclusion
I couldn’t decode the flag :( - the hint about pinyin and the cert weren’t enough for me to figure this out. When I found DEV_ca.key
on the test server I was all excited about generating my own cert for my IP but no dice (that would have been nice!). I did toy with the idea of brute-forcing the Triple DES key but given I wasn’t even sure about the prefix (accents on the pinyin? no accents?) and the encoding (UTF-8 made sense, the rest not so much), it felt like a lost cause.
That said it was a whole lot of fun - learnt a ton with pycdc
, mTLS and more. Well worth the (non-trivial) amount of time spent and looking forward to the next one!