skip to content
Site header image Minh Ton

Flare-On 12 Write-Up: #6 Chain of Demands

Some interesting Cryptography and Web3 stuff in the 6th challenge from Flare-On 12!


Challenge Exploration

The challenge gives us an ELF executable called chat_client.

I couldn't run the binary file, so I used the file command to check what I was working with. It turned out to be an x86-64 binary, and since my machine has an ARM processor, I can't execute it directly without using emulation or translation software.

$ file chat_client
chat_client: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=81544629ae0a32249a48b0bc5134fb7b1455adea, stripped
Click to expand

The only thing I can do now is to solve this problem without knowing what the binary does.

After opening the binary in IDA and digging through a bunch of functions, I was still lost. Then I checked the Strings view and noticed a lot of strings starting with py...

Seems like this could be a Python program packed using PyInstaller! But first, let’s check if this binary has a pydata section using the readelf command:

$ readelf -S chat_client
There are 29 section headers, starting at offset 0x1e86c80:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000400238  00000238
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.ABI-tag     NOTE             0000000000400254  00000254
       0000000000000020  0000000000000000   A       0     0     4
  [ 3] .note.gnu.bu[...] NOTE             0000000000400274  00000274
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .gnu.hash         GNU_HASH         0000000000400298  00000298
       0000000000000028  0000000000000000   A       5     0     8
  [ 5] .dynsym           DYNSYM           00000000004002c0  000002c0
       0000000000000810  0000000000000018   A       6     1     8
  [ 6] .dynstr           STRTAB           0000000000400ad0  00000ad0
       0000000000000335  0000000000000000   A       0     0     1
  [ 7] .gnu.version      VERSYM           0000000000400e06  00000e06
       00000000000000ac  0000000000000002   A       5     0     2
  [ 8] .gnu.version_r    VERNEED          0000000000400eb8  00000eb8
       00000000000000b0  0000000000000000   A       6     3     8
  [ 9] .rela.dyn         RELA             0000000000400f68  00000f68
       0000000000000060  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             0000000000400fc8  00000fc8
       0000000000000798  0000000000000018  AI       5    23     8
  [11] .init             PROGBITS         0000000000401760  00001760
       0000000000000017  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000401780  00001780
       0000000000000520  0000000000000010  AX       0     0     16
  [13] .text             PROGBITS         0000000000401ca0  00001ca0
       00000000000083d2  0000000000000000  AX       0     0     16
  [14] .fini             PROGBITS         000000000040a074  0000a074
       0000000000000009  0000000000000000  AX       0     0     4
  [15] .rodata           PROGBITS         000000000040a080  0000a080
       00000000000024f9  0000000000000000   A       0     0     8
  [16] .eh_frame_hdr     PROGBITS         000000000040c57c  0000c57c
       000000000000030c  0000000000000000   A       0     0     4
  [17] .eh_frame         PROGBITS         000000000040c888  0000c888
       0000000000001558  0000000000000000   A       0     0     8
  [18] .init_array       INIT_ARRAY       000000000060ed90  0000ed90
       0000000000000008  0000000000000008  WA       0     0     8
  [19] .fini_array       FINI_ARRAY       000000000060ed98  0000ed98
       0000000000000008  0000000000000008  WA       0     0     8
  [20] .data.rel.ro      PROGBITS         000000000060eda0  0000eda0
       0000000000000048  0000000000000000  WA       0     0     16
  [21] .dynamic          DYNAMIC          000000000060ede8  0000ede8
       0000000000000200  0000000000000010  WA       6     0     8
  [22] .got              PROGBITS         000000000060efe8  0000efe8
       0000000000000010  0000000000000008  WA       0     0     8
  [23] .got.plt          PROGBITS         000000000060f000  0000f000
       00000000000002a0  0000000000000008  WA       0     0     8
  [24] .data             PROGBITS         000000000060f2a0  0000f2a0
       0000000000000010  0000000000000000  WA       0     0     8
  [25] .bss              NOBITS           000000000060f2c0  0000f2b0
       00000000000040b0  0000000000000000  WA       0     0     32
  [26] .comment          PROGBITS         0000000000000000  0000f2b0
       0000000000000029  0000000000000001  MS       0     0     1
  [27] pydata            PROGBITS         0000000000000000  0000f2d9
       0000000001e7789e  0000000000000000           0     0     1
  [28] .shstrtab         STRTAB           0000000000000000  01e86b77
       0000000000000107  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)
Click to expand

Seeing the pydata section, this binary is definitely PyInstaller-generated. Let’s try to revert this back to the original Python source code!

Unpacking The Executable

For this, I’m going to use the pyinstxtractor-ng tool found here:

With some simple commands, we can easily unpack the executable:

$ git clone https://github.com/pyinstxtractor/pyinstxtractor-ng.git
$ cd pyinstxtractor-ng
$ pip install -r requirements.txt
$ python pyinstxtractor_ng.py chat_client
Click to expand

After extraction, a folder named chat_client_extracted should appear beside chat_client. Opening it reveals two suspicious files: challenge_to_compile.pyc and chat_log.json.

Upon inspecting the JSON file, we can know that this is some kind of chatting app with some cryptography algorithms.

[
  {
    "conversation_time": 0,
    "mode": "LCG-XOR",
    "plaintext": "Hello",
    "ciphertext": "e934b27119f12318fe16e8cd1c1678fd3b0a752eca163a7261a7e2510184bbe9"
  },
  {
    "conversation_time": 4,
    "mode": "LCG-XOR",
    "plaintext": "How are you?",
    "ciphertext": "25bf2fd1198392f4935dcace7d747c1e0715865b21358418e67f94163513eae4"
  },
  {
    "conversation_time": 11,
    "mode": "LCG-XOR",
    "plaintext": "Terrible...",
    "ciphertext": "c9f20e5561acf172305cf8f04c13e643c988aa5ab29b5499c93df112687c8c7c"
  },
  {
    "conversation_time": 13,
    "mode": "LCG-XOR",
    "plaintext": "Is this a secure channel?",
    "ciphertext": "3ab9c9f38e4f767a13b12569cdbf13db6bbb939e4c8a57287fb0c9def0288e46"
  },
  {
    "conversation_time": 16,
    "mode": "LCG-XOR",
    "plaintext": "Yes, it's on the blockchain.",
    "ciphertext": "3f6de0c2063d3e8e875737046fef079d73cc9b1b7a4b7b94da2d2867493f6fc5"
  },
  {
    "conversation_time": 24,
    "mode": "LCG-XOR",
    "plaintext": "Erm enable super safe mode",
    "ciphertext": "787cf6c0be39caa21b7908fcd1beca68031b7d11130005ba361c5d361b106b6d"
  },
  {
    "conversation_time": 30,
    "mode": "LCG-XOR",
    "plaintext": "Ok, activating now",
    "ciphertext": "632ab61849140655e0ee6f90ab00b879a3a3da241d4b50bab99f74f169d456db"
  },
  {
    "conversation_time": 242,
    "mode": "RSA",
    "plaintext": "[ENCRYPTED]",
    "ciphertext": "680a65364a498aa87cf17c934ab308b2aee0014aee5b0b7d289b5108677c7ad1eb3bcfbcad7582f87cb3f242391bea7e70e8c01f3ad53ac69488713daea76bb3a524bd2a4bbbc2cfb487477e9d91783f103bd6729b15a4ae99cb93f0db22a467ce12f8d56acaef5d1652c54f495db7bc88aa423bc1c2b60a6ecaede2f4273f6dce265f6c664ec583d7bd75d2fb849d77fa11d05de891b5a706eb103b7dbdb4e5a4a2e72445b61b83fd931cae34e5eaab931037db72ba14e41a70de94472e949ca3cf2135c2ccef0e9b6fa7dd3aaf29a946d165f6ca452466168c32c43c91f159928efb3624e56430b14a0728c52f2668ab26f837120d7af36baf48192ceb3002"
  },
  {
    "conversation_time": 249,
    "mode": "RSA",
    "plaintext": "[ENCRYPTED]",
    "ciphertext": "6f70034472ce115fc82a08560bd22f0e7f373e6ef27bca6e4c8f67fedf4031be23bf50311b4720fe74836b352b34c42db46341cac60298f2fa768f775a9c3da0c6705e0ce11d19b3cbdcf51309c22744e96a19576a8de0e1195f2dab21a3f1b0ef5086afcffa2e086e7738e5032cb5503df39e4bf4bdf620af7aa0f752dac942be50e7fec9a82b63f5c8faf07306e2a2e605bb93df09951c8ad46e5a2572e333484cae16be41929523c83c0d4ca317ef72ea9cde1d5630ebf6c244803d2dc1da0a1eefaafa82339bf0e6cf4bf41b1a2a90f7b2e25313a021eafa6234643acb9d5c9c22674d7bc793f1822743b48227a814a7a6604694296f33c2c59e743f4106"
  }
]
Click to expand

It’s obvious that we’ll be dealing with a cryptography challenge. But first, we need to disassemble the .pyc file into bytecode, and maybe decompile it into human-readable code.

Decompiling The Bytecode

While common tools like uncompyle6 can perform this task, they no longer support Python 3.9 or newer. After some research, I found Decompyle++, which is actively maintained and likely capable of handling this.

Uhh the build guide isn’t very clear I guess… but let’s try to figure it out by intuition:

$ git clone https://github.com/zrax/pycdc
$ cd pycdc
$ mkdir build
$ cd build
$ cmake ..
$ make
Click to expand

Alright, the project’s built! Now run one last command — sudo make install — so we can call pycdc from anywhere. Since pycdc is the decompiler for these Python bytecode, we can use it to recover the source code of challenge_to_compile.pyc:

$ pycdc challenge_to_compile.pyc > challenge.py
Unsupported opcode: PUSH_EXC_INFO (105)
Unsupported opcode: PUSH_EXC_INFO (105)
Unsupported opcode: BEFORE_WITH (108)
Unsupported opcode: BEFORE_WITH (108)
Unsupported opcode: MAKE_CELL (225)
Click to expand

You might see some Unsupported opcode errors during decompilation. That’s normal and usually happens because of differences in bytecode between Python versions. I knew that this can be resolved easily, but it’s not worth doing since we can always look at the original bytecode for parts where decompilation fails later!

In the generated source code, some parts are only partially recovered, but they seem unrelated to the cryptography part we need to focus on, which is great I guess…

# Source Generated with Decompyle++
# File: challenge_to_compile.pyc (Python 3.12)

import tkinter as tk
from tkinter import scrolledtext, messagebox, simpledialog, Checkbutton, BooleanVar, Toplevel
import platform
import hashlib
import time
import json
from threading import Thread
import math
import random
from Crypto.PublicKey import RSA
from Crypto.Util.number import bytes_to_long, long_to_bytes, isPrime
import os
import sys
from web3 import Web3
from eth_account import Account
from eth_account.signers.local import LocalAccount

def resource_path(relative_path):
    '''
    Get the absolute path to a resource, which works for both development
    and for a PyInstaller-bundled executable.
    '''
    base_path = sys._MEIPASS
    return os.path.join(base_path, relative_path)
# WARNING: Decompyle incomplete

class SmartContracts:
    rpc_url = ''
    private_key = ''
    
    def deploy_contract(contract_bytes, contract_abi):
        w3 = Web3(Web3.HTTPProvider(SmartContracts.rpc_url))
        if not w3.is_connected():
            raise ConnectionError(f'''[!] Failed to connect to Ethereum network at {SmartContracts.rpc_url}''')
        print(f'''[+] Connected to Sepolia network at {SmartContracts.rpc_url}''')
        print(f'''[+] Current block number: {w3.eth.block_number}''')
        if not SmartContracts.private_key:
            raise ValueError('Please add your private key.')
        account = Account.from_key(SmartContracts.private_key)
        w3.eth.default_account = account.address
        print(f'''[+] Using account: {account.address}''')
        balance_wei = w3.eth.get_balance(account.address)
        print(f'''[+] Account balance: {w3.from_wei(balance_wei, 'ether')} ETH''')
        if balance_wei == 0:
            print('[!] Warning: Account has 0 ETH. Deployment will likely fail. Get some testnet ETH from a faucet (e.g., sepoliafaucet.com)!')
        Contract = w3.eth.contract(abi = contract_abi, bytecode = contract_bytes)
        gas_estimate = Contract.constructor().estimate_gas()
        print(f'''[+] Estimated gas for deployment: {gas_estimate}''')
        gas_price = w3.eth.gas_price
        print(f'''[+] Current gas price: {w3.from_wei(gas_price, 'gwei')} Gwei''')
        transaction = Contract.constructor().build_transaction({
            'from': account.address,
            'nonce': w3.eth.get_transaction_count(account.address),
            'gas': gas_estimate + 200000,
            'gasPrice': gas_price })
        signed_txn = w3.eth.account.sign_transaction(transaction, private_key = SmartContracts.private_key)
        print('[+]  Deploying contract...')
        tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
        print(f'''[+] Deployment transaction sent. Hash: {tx_hash.hex()}''')
        print('[+] Waiting for transaction to be mined...')
        tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout = 300)
        print(f'''[+] Transaction receipt: {tx_receipt}''')
        if tx_receipt.status == 0:
            print('[!] Transaction failed (status 0). It was reverted.')
            return None
        contract_address = tx_receipt.contractAddress
        print(f'''[+] Contract deployed at address: {contract_address}''')
        deployed_contract = w3.eth.contract(address = contract_address, abi = contract_abi)
        return deployed_contract
    # WARNING: Decompyle incomplete

class LCGOracle:
    def __init__(self, multiplier, increment, modulus, initial_seed):
        self.multiplier = multiplier
        self.increment = increment
        self.modulus = modulus
        self.state = initial_seed
        self.contract_bytes = '6080604052348015600e575f5ffd5b506102e28061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063115218341461002d575b5f5ffd5b6100476004803603810190610042919061010c565b61005d565b6040516100549190610192565b60405180910390f35b5f5f848061006e5761006d6101ab565b5b86868061007e5761007d6101ab565b5b8987090890505f5f8411610092575f610095565b60015b60ff16905081816100a69190610205565b858260016100b49190610246565b6100be9190610205565b6100c89190610279565b9250505095945050505050565b5f5ffd5b5f819050919050565b6100eb816100d9565b81146100f5575f5ffd5b50565b5f81359050610106816100e2565b92915050565b5f5f5f5f5f60a08688031215610125576101246100d5565b5b5f610132888289016100f8565b9550506020610143888289016100f8565b9450506040610154888289016100f8565b9350506060610165888289016100f8565b9250506080610176888289016100f8565b9150509295509295909350565b61018c816100d9565b82525050565b5f6020820190506101a55f830184610183565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601260045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61020f826100d9565b915061021a836100d9565b9250828202610228816100d9565b9150828204841483151761023f5761023e6101d8565b5b5092915050565b5f610250826100d9565b915061025b836100d9565b9250828203905081811115610273576102726101d8565b5b92915050565b5f610283826100d9565b915061028e836100d9565b92508282019050808211156102a6576102a56101d8565b5b9291505056fea2646970667358221220c7e885c1633ad951a2d8168f80d36858af279d8b5fe2e19cf79eac15ecb9fdd364736f6c634300081e0033'
        self.contract_abi = [
            {
                'inputs': [
                    {
                        'internalType': 'uint256',
                        'name': 'LCG_MULTIPLIER',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': 'LCG_INCREMENT',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': 'LCG_MODULUS',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': '_currentState',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': '_counter',
                        'type': 'uint256' }],
                'name': 'nextVal',
                'outputs': [
                    {
                        'internalType': 'uint256',
                        'name': '',
                        'type': 'uint256' }],
                'stateMutability': 'pure',
                'type': 'function' }]
        self.deployed_contract = None
    
    def deploy_lcg_contract(self):
        self.deployed_contract = SmartContracts.deploy_contract(self.contract_bytes, self.contract_abi)
    
    def get_next(self, counter):
        print(f'''\n[+] Calling nextVal() with _currentState={self.state}''')
        self.state = self.deployed_contract.functions.nextVal(self.multiplier, self.increment, self.modulus, self.state, counter).call()
        print(f'''  _counter = {counter}: Result = {self.state}''')
        return self.state

class TripleXOROracle:
    def __init__(self):
        self.contract_bytes = '61030f61004d600b8282823980515f1a6073146041577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe7300000000000000000000000000000000000000003014608060405260043610610034575f3560e01c80636230075614610038575b5f5ffd5b610052600480360381019061004d919061023c565b610068565b60405161005f91906102c0565b60405180910390f35b5f5f845f1b90505f845f1b90505f61007f85610092565b9050818382181893505050509392505050565b5f5f8290506020815111156100ae5780515f525f5191506100b6565b602081015191505b50919050565b5f604051905090565b5f5ffd5b5f5ffd5b5f819050919050565b6100df816100cd565b81146100e9575f5ffd5b50565b5f813590506100fa816100d6565b92915050565b5f5ffd5b5f5ffd5b5f601f19601f8301169050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b61014e82610108565b810181811067ffffffffffffffff8211171561016d5761016c610118565b5b80604052505050565b5f61017f6100bc565b905061018b8282610145565b919050565b5f67ffffffffffffffff8211156101aa576101a9610118565b5b6101b382610108565b9050602081019050919050565b828183375f83830152505050565b5f6101e06101db84610190565b610176565b9050828152602081018484840111156101fc576101fb610104565b5b6102078482856101c0565b509392505050565b5f82601f83011261022357610222610100565b5b81356102338482602086016101ce565b91505092915050565b5f5f5f60608486031215610253576102526100c5565b5b5f610260868287016100ec565b9350506020610271868287016100ec565b925050604084013567ffffffffffffffff811115610292576102916100c9565b5b61029e8682870161020f565b9150509250925092565b5f819050919050565b6102ba816102a8565b82525050565b5f6020820190506102d35f8301846102b1565b9291505056fea26469706673582212203fc7e6cc4bf6a86689f458c2d70c565e7c776de95b401008e58ca499ace9ecb864736f6c634300081e0033'
        self.contract_abi = [
            {
                'inputs': [
                    {
                        'internalType': 'uint256',
                        'name': '_primeFromLcg',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': '_conversationTime',
                        'type': 'uint256' },
                    {
                        'internalType': 'string',
                        'name': '_plaintext',
                        'type': 'string' }],
                'name': 'encrypt',
                'outputs': [
                    {
                        'internalType': 'bytes32',
                        'name': '',
                        'type': 'bytes32' }],
                'stateMutability': 'pure',
                'type': 'function' }]
        self.deployed_contract = None
    
    def deploy_triple_xor_contract(self):
        self.deployed_contract = SmartContracts.deploy_contract(self.contract_bytes, self.contract_abi)

    def encrypt(self, prime_from_lcg, conversation_time, plaintext_bytes):
        print(f'''\n[+] Calling encrypt() with prime_from_lcg={prime_from_lcg}, time={conversation_time}, plaintext={plaintext_bytes}''')
        ciphertext = self.deployed_contract.functions.encrypt(prime_from_lcg, conversation_time, plaintext_bytes).call()
        print(f'''  _ciphertext = {ciphertext.hex()}''')
        return ciphertext

class ChatLogic:
    def __init__(self):
        self.lcg_oracle = None
        self.xor_oracle = None
        self.rsa_key = None
        self.seed_hash = None
        self.super_safe_mode = False
        self.message_count = 0
        self.conversation_start_time = 0
        self.chat_history = []
        self._initialize_crypto_backend()

    def _get_system_artifact_hash(self):
        artifact = platform.node().encode('utf-8')
        hash_val = hashlib.sha256(artifact).digest()
        seed_hash = int.from_bytes(hash_val, 'little')
        print(f'''[SETUP]  - Generated Seed {seed_hash}...''')
        return seed_hash

    def _generate_primes_from_hash(self, seed_hash):
        primes = []
        current_hash_byte_length = (seed_hash.bit_length() + 7) // 8
        current_hash = seed_hash.to_bytes(current_hash_byte_length, 'little')
        print('[SETUP] Generating LCG parameters from system artifact...')
        iteration_limit = 10000
        iterations = 0
        if len(primes) < 3 and iterations < iteration_limit:
            current_hash = hashlib.sha256(current_hash).digest()
            candidate = int.from_bytes(current_hash, 'little')
            iterations += 1
            if candidate.bit_length() == 256 and isPrime(candidate):
                primes.append(candidate)
                print(f'''[SETUP]  - Found parameter {len(primes)}: {str(candidate)[:20]}...''')
            if len(primes) < 3 and iterations < iteration_limit:
                continue
        if len(primes) < 3:
            error_msg = '[!] Error: Could not find 3 primes within iteration limit.'
            print('Current Primes: ', primes)
            print(error_msg)
            exit()
        return (primes[0], primes[1], primes[2])

    def _initialize_crypto_backend(self):
        self.seed_hash = self._get_system_artifact_hash()
        (m, c, n) = self._generate_primes_from_hash(self.seed_hash)
        self.lcg_oracle = LCGOracle(m, c, n, self.seed_hash)
        self.lcg_oracle.deploy_lcg_contract()
        print('[SETUP] LCG Oracle is on-chain...')
        self.xor_oracle = TripleXOROracle()
        self.xor_oracle.deploy_triple_xor_contract()
        print('[SETUP] Triple XOR Oracle is on-chain...')
        print('[SETUP] Crypto backend initialized...')

    def generate_rsa_key_from_lcg(self):
        print('[RSA] Generating RSA key from on-chain LCG primes...')
        lcg_for_rsa = LCGOracle(self.lcg_oracle.multiplier, self.lcg_oracle.increment, self.lcg_oracle.modulus, self.seed_hash)
        lcg_for_rsa.deploy_lcg_contract()
        primes_arr = []
        rsa_msg_count = 0
        iteration_limit = 10000
        iterations = 0
        if len(primes_arr) < 8 and iterations < iteration_limit:
            candidate = lcg_for_rsa.get_next(rsa_msg_count)
            rsa_msg_count += 1
            iterations += 1
            if candidate.bit_length() == 256 and isPrime(candidate):
                primes_arr.append(candidate)
                print(f'''[RSA]  - Found 256-bit prime #{len(primes_arr)}''')
            if len(primes_arr) < 8 and iterations < iteration_limit:
                continue
        print('Primes Array: ', primes_arr)
        if len(primes_arr) < 8:
            error_msg = '[RSA] Error: Could not find 8 primes within iteration limit.'
            print('Current Primes: ', primes_arr)
            print(error_msg)
            return error_msg
        n = None
        for p_val in primes_arr:
            n *= p_val
        phi = 1
        for p_val in primes_arr:
            phi *= p_val - 1
        e = 65537
        if math.gcd(e, phi) != 1:
            error_msg = '[RSA] Error: Public exponent e is not coprime with phi(n). Cannot generate key.'
            print(error_msg)
            return error_msg
        self.rsa_key = None.construct((n, e))
    # WARNING: Decompyle incomplete

    def process_message(self, plaintext):
        if self.conversation_start_time == 0:
            self.conversation_start_time = time.time()
        conversation_time = int(time.time() - self.conversation_start_time)
        if self.super_safe_mode and self.rsa_key:
            plaintext_bytes = plaintext.encode('utf-8')
            plaintext_enc = bytes_to_long(plaintext_bytes)
            _enc = pow(plaintext_enc, self.rsa_key.e, self.rsa_key.n)
            ciphertext = _enc.to_bytes(self.rsa_key.n.bit_length(), 'little').rstrip(b'\x00')
            encryption_mode = 'RSA'
            plaintext = '[ENCRYPTED]'
        else:
            prime_from_lcg = self.lcg_oracle.get_next(self.message_count)
            ciphertext = self.xor_oracle.encrypt(prime_from_lcg, conversation_time, plaintext)
            encryption_mode = 'LCG-XOR'
        log_entry = {
            'conversation_time': conversation_time,
            'mode': encryption_mode,
            'plaintext': plaintext,
            'ciphertext': ciphertext.hex() }
        self.chat_history.append(log_entry)
        self.save_chat_log()
        return (f'''[{conversation_time}s] {plaintext}''', f'''[{conversation_time}s] {ciphertext.hex()}''')
    
    def save_chat_log(self):
        pass
    # WARNING: Decompyle incomplete

class ChatApp(tk.Tk):
    pass
# WARNING: Decompyle incomplete

if __name__ == '__main__':
    app = ChatApp()
    app.mainloop()
    return None
Click to expand

Breaking Down The Challenge

Let’s go through the chat log JSON and the main components of the generated Python source code to understand how it works and possibly find a way to solve the challenge.

Chat Log JSON File

Inspecting the chat log JSON file reveals how the chatting system works and suggests where the flag might be hidden.

Initially the system uses LCG-XOR to encrypt messages, but the unencrypted plaintext is still given to us along with the ciphertext. After the seventh message it switches to RSA, at which point the last two messages display plaintext as [ENCRYPTED].

I’m going to make a guess that the flag is hidden in those RSA-encrypted message, so we need to find a way to decrypt these messages.

Chat App Components

SmartContracts

At first glance, I figured this would be tough. I don’t know much about blockchain! However, with the help of Grok, I’m starting to make sense of it.

Well this SmartContracts class simply handles the deployment of smart contracts to Etherium, which is a blockchain. It connects to a testnet (Sepolia), signs transactions with a private key, and deploys contracts.

Once again, I don’t know anything about smart contracts or testnet anyways, so if you don’t understand any of this, it’s fine! After some conversations with Grok, it told me that this is just for offloading computations to the blockchain, so I guess we can safely skip this class.

class SmartContracts:
    rpc_url = ''
    private_key = ''
    
    def deploy_contract(contract_bytes, contract_abi):
        w3 = Web3(Web3.HTTPProvider(SmartContracts.rpc_url))
        if not w3.is_connected():
            raise ConnectionError(f'''[!] Failed to connect to Ethereum network at {SmartContracts.rpc_url}''')
        print(f'''[+] Connected to Sepolia network at {SmartContracts.rpc_url}''')
        print(f'''[+] Current block number: {w3.eth.block_number}''')
        if not SmartContracts.private_key:
            raise ValueError('Please add your private key.')
        account = Account.from_key(SmartContracts.private_key)
        w3.eth.default_account = account.address
        print(f'''[+] Using account: {account.address}''')
        balance_wei = w3.eth.get_balance(account.address)
        print(f'''[+] Account balance: {w3.from_wei(balance_wei, 'ether')} ETH''')
        if balance_wei == 0:
            print('[!] Warning: Account has 0 ETH. Deployment will likely fail. Get some testnet ETH from a faucet (e.g., sepoliafaucet.com)!')
        Contract = w3.eth.contract(abi = contract_abi, bytecode = contract_bytes)
        gas_estimate = Contract.constructor().estimate_gas()
        print(f'''[+] Estimated gas for deployment: {gas_estimate}''')
        gas_price = w3.eth.gas_price
        print(f'''[+] Current gas price: {w3.from_wei(gas_price, 'gwei')} Gwei''')
        transaction = Contract.constructor().build_transaction({
            'from': account.address,
            'nonce': w3.eth.get_transaction_count(account.address),
            'gas': gas_estimate + 200000,
            'gasPrice': gas_price })
        signed_txn = w3.eth.account.sign_transaction(transaction, private_key = SmartContracts.private_key)
        print('[+]  Deploying contract...')
        tx_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
        print(f'''[+] Deployment transaction sent. Hash: {tx_hash.hex()}''')
        print('[+] Waiting for transaction to be mined...')
        tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout = 300)
        print(f'''[+] Transaction receipt: {tx_receipt}''')
        if tx_receipt.status == 0:
            print('[!] Transaction failed (status 0). It was reverted.')
            return None
        contract_address = tx_receipt.contractAddress
        print(f'''[+] Contract deployed at address: {contract_address}''')
        deployed_contract = w3.eth.contract(address = contract_address, abi = contract_abi)
        return deployed_contract
    # WARNING: Decompyle incomplete
Click to expand

LCGOracle

With this class, we’re given a super long smart contract bytecode in hexadecimal form and an ABI interface. Looking at the ABI and the class name as well, I’m pretty sure that this implements a Linear Congruential Generator (LCG).

class LCGOracle:
    def __init__(self, multiplier, increment, modulus, initial_seed):
        self.multiplier = multiplier
        self.increment = increment
        self.modulus = modulus
        self.state = initial_seed
        self.contract_bytes = '6080604052348015600e575f5ffd5b506102e28061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063115218341461002d575b5f5ffd5b6100476004803603810190610042919061010c565b61005d565b6040516100549190610192565b60405180910390f35b5f5f848061006e5761006d6101ab565b5b86868061007e5761007d6101ab565b5b8987090890505f5f8411610092575f610095565b60015b60ff16905081816100a69190610205565b858260016100b49190610246565b6100be9190610205565b6100c89190610279565b9250505095945050505050565b5f5ffd5b5f819050919050565b6100eb816100d9565b81146100f5575f5ffd5b50565b5f81359050610106816100e2565b92915050565b5f5f5f5f5f60a08688031215610125576101246100d5565b5b5f610132888289016100f8565b9550506020610143888289016100f8565b9450506040610154888289016100f8565b9350506060610165888289016100f8565b9250506080610176888289016100f8565b9150509295509295909350565b61018c816100d9565b82525050565b5f6020820190506101a55f830184610183565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601260045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61020f826100d9565b915061021a836100d9565b9250828202610228816100d9565b9150828204841483151761023f5761023e6101d8565b5b5092915050565b5f610250826100d9565b915061025b836100d9565b9250828203905081811115610273576102726101d8565b5b92915050565b5f610283826100d9565b915061028e836100d9565b92508282019050808211156102a6576102a56101d8565b5b9291505056fea2646970667358221220c7e885c1633ad951a2d8168f80d36858af279d8b5fe2e19cf79eac15ecb9fdd364736f6c634300081e0033'
        self.contract_abi = [
            {
                'inputs': [
                    {
                        'internalType': 'uint256',
                        'name': 'LCG_MULTIPLIER',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': 'LCG_INCREMENT',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': 'LCG_MODULUS',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': '_currentState',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': '_counter',
                        'type': 'uint256' }],
                'name': 'nextVal',
                'outputs': [
                    {
                        'internalType': 'uint256',
                        'name': '',
                        'type': 'uint256' }],
                'stateMutability': 'pure',
                'type': 'function' }]
        self.deployed_contract = None
    
    def deploy_lcg_contract(self):
        self.deployed_contract = SmartContracts.deploy_contract(self.contract_bytes, self.contract_abi)
    
    def get_next(self, counter):
        print(f'''\n[+] Calling nextVal() with _currentState={self.state}''')
        self.state = self.deployed_contract.functions.nextVal(self.multiplier, self.increment, self.modulus, self.state, counter).call()
        print(f'''  _counter = {counter}: Result = {self.state}''')
        return self.state
Click to expand

I spent a little while searching on Google and discovered that these bytecode strings are Ethereum Virtual Machine (EVM) bytecode and can be decompiled into human-readable Solidity-like code. A great decompiler for this would be the one from Dedaub.

I pasted the entire contract_bytes string into the decompiler and it gave me this:

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__() public payable { 
    MEM[64] = 128;
    require(!msg.value);
    MEM[0:738] = 0x608060405234801561000f575f5ffd5b5060043610610029575f3560e01c8063115218341461002d575b5f5ffd5b6100476004803603810190610042919061010c565b61005d565b6040516100549190610192565b60405180910390f35b5f5f848061006e5761006d6101ab565b5b86868061007e5761007d6101ab565b5b8987090890505f5f8411610092575f610095565b60015b60ff16905081816100a69190610205565b858260016100b49190610246565b6100be9190610205565b6100c89190610279565b9250505095945050505050565b5f5ffd5b5f819050919050565b6100eb816100d9565b81146100f5575f5ffd5b50565b5f81359050610106816100e2565b92915050565b5f5f5f5f5f60a08688031215610125576101246100d5565b5b5f610132888289016100f8565b9550506020610143888289016100f8565b9450506040610154888289016100f8565b9350506060610165888289016100f8565b9250506080610176888289016100f8565b9150509295509295909350565b61018c816100d9565b82525050565b5f6020820190506101a55f830184610183565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601260045260245ffd5b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61020f826100d9565b915061021a836100d9565b9250828202610228816100d9565b9150828204841483151761023f5761023e6101d8565b5b5092915050565b5f610250826100d9565b915061025b836100d9565b9250828203905081811115610273576102726101d8565b5b92915050565b5f610283826100d9565b915061028e836100d9565b92508282019050808211156102a6576102a56101d8565b5b9291505056fea2646970667358221220c7e885c1633ad951a2d8168f80d36858af279d8b5fe2e19cf79eac15ecb9fdd364736f6c634300081e0033;
    return MEM[0:738];
}
Click to expand

Weird, am I missing something? It turns out that I have to copy the 0x6080... from this code into the decompiler, and it now produces some useful code now LOL.

function _SafeMul(uint256 varg0, uint256 varg1) private { 
    require(!varg0 | (varg1 == varg0 * varg1 / varg0), Panic(17)); // arithmetic overflow or underflow
    return varg0 * varg1;
}

function _SafeSub(uint256 varg0, uint256 varg1) private { 
    require(varg0 - varg1 <= varg0, Panic(17)); // arithmetic overflow or underflow
    return varg0 - varg1;
}

function _SafeAdd(uint256 varg0, uint256 varg1) private { 
    require(varg0 <= varg0 + varg1, Panic(17)); // arithmetic overflow or underflow
    return varg0 + varg1;
}

function fallback() public payable { 
    revert();
}

function 0x11521834(uint256 varg0, uint256 varg1, uint256 varg2, uint256 varg3, uint256 varg4) public payable { 
    require(4 + (msg.data.length - 4) - 4 >= 160);
    require(varg2, Panic(18)); // division by zero
    require(varg2, Panic(18)); // division by zero
    if (varg4 > 0) {
        v0 = v1 = 1;
    } else {
        v0 = v2 = 0;
    }
    v3 = _SafeMul(uint8(v0), (varg3 * varg0 % varg2 + varg1) % varg2);
    v4 = _SafeSub(1, uint8(v0));
    v5 = _SafeMul(v4, varg3);
    v6 = _SafeAdd(v5, v3);
    return v6;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__( function_selector) public payable { 
    MEM[64] = 128;
    require(!msg.value);
    if (msg.data.length >= 4) {
        if (0x11521834 == function_selector >> 224) {
            0x11521834();
        }
    }
    fallback();
}
Click to expand

Upon reading the code, we’re pretty sure that this is indeed an implementation of the LCG. Basically it’s a formula to generate "random" numbers in a predictable pattern:

Xn+1=(aXn+c)modmX_{n+1} = (aX_n + c) \bmod m

Here, Xn+1X_{n+1} represents the new state, XnX_n the current state, aa the multiplier, cc the increment, and mm the modulus. When mapping these the decompiled function 0x11521834(), the parameters varg0, varg1, varg2, and varg3 correspond to the multiplier, increment, modulus, and current state, respectively. The variable varg4 appears to be some counter, though its exact purpose is not yet clear.

If we look at the ABI interface, this 0x11521834() function is defined as nextVal, and varg4 is identified as a counter variable, though its specific role remains unknown at this stage. There’s also the get_next(counter) function which calls the nextVal function to update the state of the LCG.

TripleXOROracle

With this class, we’re going to use the same procedure above to decompile the smart contract.

class TripleXOROracle:
    def __init__(self):
        self.contract_bytes = '61030f61004d600b8282823980515f1a6073146041577f4e487b71000000000000000000000000000000000000000000000000000000005f525f60045260245ffd5b305f52607381538281f3fe7300000000000000000000000000000000000000003014608060405260043610610034575f3560e01c80636230075614610038575b5f5ffd5b610052600480360381019061004d919061023c565b610068565b60405161005f91906102c0565b60405180910390f35b5f5f845f1b90505f845f1b90505f61007f85610092565b9050818382181893505050509392505050565b5f5f8290506020815111156100ae5780515f525f5191506100b6565b602081015191505b50919050565b5f604051905090565b5f5ffd5b5f5ffd5b5f819050919050565b6100df816100cd565b81146100e9575f5ffd5b50565b5f813590506100fa816100d6565b92915050565b5f5ffd5b5f5ffd5b5f601f19601f8301169050919050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b61014e82610108565b810181811067ffffffffffffffff8211171561016d5761016c610118565b5b80604052505050565b5f61017f6100bc565b905061018b8282610145565b919050565b5f67ffffffffffffffff8211156101aa576101a9610118565b5b6101b382610108565b9050602081019050919050565b828183375f83830152505050565b5f6101e06101db84610190565b610176565b9050828152602081018484840111156101fc576101fb610104565b5b6102078482856101c0565b509392505050565b5f82601f83011261022357610222610100565b5b81356102338482602086016101ce565b91505092915050565b5f5f5f60608486031215610253576102526100c5565b5b5f610260868287016100ec565b9350506020610271868287016100ec565b925050604084013567ffffffffffffffff811115610292576102916100c9565b5b61029e8682870161020f565b9150509250925092565b5f819050919050565b6102ba816102a8565b82525050565b5f6020820190506102d35f8301846102b1565b9291505056fea26469706673582212203fc7e6cc4bf6a86689f458c2d70c565e7c776de95b401008e58ca499ace9ecb864736f6c634300081e0033'
        self.contract_abi = [
            {
                'inputs': [
                    {
                        'internalType': 'uint256',
                        'name': '_primeFromLcg',
                        'type': 'uint256' },
                    {
                        'internalType': 'uint256',
                        'name': '_conversationTime',
                        'type': 'uint256' },
                    {
                        'internalType': 'string',
                        'name': '_plaintext',
                        'type': 'string' }],
                'name': 'encrypt',
                'outputs': [
                    {
                        'internalType': 'bytes32',
                        'name': '',
                        'type': 'bytes32' }],
                'stateMutability': 'pure',
                'type': 'function' }]
        self.deployed_contract = None
    
    def deploy_triple_xor_contract(self):
        self.deployed_contract = SmartContracts.deploy_contract(self.contract_bytes, self.contract_abi)

    def encrypt(self, prime_from_lcg, conversation_time, plaintext_bytes):
        print(f'''\n[+] Calling encrypt() with prime_from_lcg={prime_from_lcg}, time={conversation_time}, plaintext={plaintext_bytes}''')
        ciphertext = self.deployed_contract.functions.encrypt(prime_from_lcg, conversation_time, plaintext_bytes).call()
        print(f'''  _ciphertext = {ciphertext.hex()}''')
        return ciphertext
Click to expand

Once again, we got some nice decompilations:

function fallback() public payable { 
    revert();
}

function 0x62300756(uint256 varg0, uint256 varg1, bytes varg2) public payable { 
    require(4 + (msg.data.length - 4) - 4 >= 96);
    require(varg2 <= uint64.max);
    require(4 + varg2 + 31 < 4 + (msg.data.length - 4));
    require(varg2.length <= uint64.max, Panic(65)); // failed memory allocation (too much memory)
    v0 = new bytes[](varg2.length);
    require(!((v0 + ((varg2.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) > uint64.max) | (v0 + ((varg2.length + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) + 32 + 31 & 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0) < v0)), Panic(65)); // failed memory allocation (too much memory)
    require(varg2.data + varg2.length <= 4 + (msg.data.length - 4));
    CALLDATACOPY(v0.data, varg2.data, varg2.length);
    v0[varg2.length] = 0;
    if (v0.length <= 32) {
        v1 = v2 = MEM[v0.data];
    }
    return v1 ^ varg0 ^ varg1;
}

// Note: The function selector is not present in the original solidity code.
// However, we display it for the sake of completeness.

function __function_selector__( function_selector) public payable { 
    MEM[64] = 128;
    if (msg.data.length >= 4) {
        if (0x62300756 == function_selector >> 224) {
            0x62300756();
        }
    }
    fallback();
}
Click to expand

Based on the ABI, we can map the parameters of the 0x62300756() function to prime_from_lcg, conversation_time, and plaintext, respectively. Essentially, this function takes the LCG prime, the conversation timestamp, and a plaintext string, then returns the XOR of the two numbers with the decoded 32-byte value from the string’s byte array. There is some right-zero paddings for strings smaller than 32 bytes as well!

Ooh, this is definitely the LCG-XOR encryption that we’ve seen in the chat log! If you look at the chat log, the first 7 values of the plaintext, timestamp, and ciphertext are exposed to us, we can easily recover the primes (or the LCG outputs for message 0 to 6) by doing prime = ciphertext XOR plaintext XOR conversation_time!

Cryptography Backend

This is probably the most important part of the script. This ChatLogic class is responsible for all of the cryptography logic of the chat app itself.

class ChatLogic:
    def __init__(self):
        self.lcg_oracle = None
        self.xor_oracle = None
        self.rsa_key = None
        self.seed_hash = None
        self.super_safe_mode = False
        self.message_count = 0
        self.conversation_start_time = 0
        self.chat_history = []
        self._initialize_crypto_backend()

    def _get_system_artifact_hash(self):
        artifact = platform.node().encode('utf-8')
        hash_val = hashlib.sha256(artifact).digest()
        seed_hash = int.from_bytes(hash_val, 'little')
        print(f'''[SETUP]  - Generated Seed {seed_hash}...''')
        return seed_hash

    def _generate_primes_from_hash(self, seed_hash):
        primes = []
        current_hash_byte_length = (seed_hash.bit_length() + 7) // 8
        current_hash = seed_hash.to_bytes(current_hash_byte_length, 'little')
        print('[SETUP] Generating LCG parameters from system artifact...')
        iteration_limit = 10000
        iterations = 0
        if len(primes) < 3 and iterations < iteration_limit:
            current_hash = hashlib.sha256(current_hash).digest()
            candidate = int.from_bytes(current_hash, 'little')
            iterations += 1
            if candidate.bit_length() == 256 and isPrime(candidate):
                primes.append(candidate)
                print(f'''[SETUP]  - Found parameter {len(primes)}: {str(candidate)[:20]}...''')
            if len(primes) < 3 and iterations < iteration_limit:
                continue
        if len(primes) < 3:
            error_msg = '[!] Error: Could not find 3 primes within iteration limit.'
            print('Current Primes: ', primes)
            print(error_msg)
            exit()
        return (primes[0], primes[1], primes[2])

    def _initialize_crypto_backend(self):
        self.seed_hash = self._get_system_artifact_hash()
        (m, c, n) = self._generate_primes_from_hash(self.seed_hash)
        self.lcg_oracle = LCGOracle(m, c, n, self.seed_hash)
        self.lcg_oracle.deploy_lcg_contract()
        print('[SETUP] LCG Oracle is on-chain...')
        self.xor_oracle = TripleXOROracle()
        self.xor_oracle.deploy_triple_xor_contract()
        print('[SETUP] Triple XOR Oracle is on-chain...')
        print('[SETUP] Crypto backend initialized...')

    def generate_rsa_key_from_lcg(self):
        print('[RSA] Generating RSA key from on-chain LCG primes...')
        lcg_for_rsa = LCGOracle(self.lcg_oracle.multiplier, self.lcg_oracle.increment, self.lcg_oracle.modulus, self.seed_hash)
        lcg_for_rsa.deploy_lcg_contract()
        primes_arr = []
        rsa_msg_count = 0
        iteration_limit = 10000
        iterations = 0
        if len(primes_arr) < 8 and iterations < iteration_limit:
            candidate = lcg_for_rsa.get_next(rsa_msg_count)
            rsa_msg_count += 1
            iterations += 1
            if candidate.bit_length() == 256 and isPrime(candidate):
                primes_arr.append(candidate)
                print(f'''[RSA]  - Found 256-bit prime #{len(primes_arr)}''')
            if len(primes_arr) < 8 and iterations < iteration_limit:
                continue
        print('Primes Array: ', primes_arr)
        if len(primes_arr) < 8:
            error_msg = '[RSA] Error: Could not find 8 primes within iteration limit.'
            print('Current Primes: ', primes_arr)
            print(error_msg)
            return error_msg
        n = None
        for p_val in primes_arr:
            n *= p_val
        phi = 1
        for p_val in primes_arr:
            phi *= p_val - 1
        e = 65537
        if math.gcd(e, phi) != 1:
            error_msg = '[RSA] Error: Public exponent e is not coprime with phi(n). Cannot generate key.'
            print(error_msg)
            return error_msg
        self.rsa_key = None.construct((n, e))
    # WARNING: Decompyle incomplete

    def process_message(self, plaintext):
        if self.conversation_start_time == 0:
            self.conversation_start_time = time.time()
        conversation_time = int(time.time() - self.conversation_start_time)
        if self.super_safe_mode and self.rsa_key:
            plaintext_bytes = plaintext.encode('utf-8')
            plaintext_enc = bytes_to_long(plaintext_bytes)
            _enc = pow(plaintext_enc, self.rsa_key.e, self.rsa_key.n)
            ciphertext = _enc.to_bytes(self.rsa_key.n.bit_length(), 'little').rstrip(b'\x00')
            encryption_mode = 'RSA'
            plaintext = '[ENCRYPTED]'
        else:
            prime_from_lcg = self.lcg_oracle.get_next(self.message_count)
            ciphertext = self.xor_oracle.encrypt(prime_from_lcg, conversation_time, plaintext)
            encryption_mode = 'LCG-XOR'
        log_entry = {
            'conversation_time': conversation_time,
            'mode': encryption_mode,
            'plaintext': plaintext,
            'ciphertext': ciphertext.hex() }
        self.chat_history.append(log_entry)
        self.save_chat_log()
        return (f'''[{conversation_time}s] {plaintext}''', f'''[{conversation_time}s] {ciphertext.hex()}''')
    
    def save_chat_log(self):
        pass
    # WARNING: Decompyle incomplete
Click to expand

Backend Initialization

Let’s analyze the first two methods.

def _get_system_artifact_hash(self):
    artifact = platform.node().encode('utf-8')
    hash_val = hashlib.sha256(artifact).digest()
    seed_hash = int.from_bytes(hash_val, 'little')
    print(f'''[SETUP]  - Generated Seed {seed_hash}...''')
    return seed_hash
    
def _generate_primes_from_hash(self, seed_hash):
    primes = []
    current_hash_byte_length = (seed_hash.bit_length() + 7) // 8
    current_hash = seed_hash.to_bytes(current_hash_byte_length, 'little')
    print('[SETUP] Generating LCG parameters from system artifact...')
    iteration_limit = 10000
    iterations = 0
    if len(primes) < 3 and iterations < iteration_limit:
        current_hash = hashlib.sha256(current_hash).digest()
        candidate = int.from_bytes(current_hash, 'little')
        iterations += 1
        if candidate.bit_length() == 256 and isPrime(candidate):
            primes.append(candidate)
            print(f'''[SETUP]  - Found parameter {len(primes)}: {str(candidate)[:20]}...''')
        if len(primes) < 3 and iterations < iteration_limit:
            continue
        if len(primes) < 3:
            error_msg = '[!] Error: Could not find 3 primes within iteration limit.'
            print('Current Primes: ', primes)
            print(error_msg)
            exit()
        return (primes[0], primes[1], primes[2])
Click to expand

The cryptographic backend first creates a seed by hashing the nodename (or hostname) of the computer that the script is running on, using SHA-256. As a result, this seed is unique per machine, so we can’t get it. The seed is then used to generate three 256-bit prime numbers, which serve as the multiplier, increment, and modulus for the LCGOracle class.

Apparently the contracts for the LCG and TripleXOR are deployed during the ChatLogic initialization as well.

def _initialize_crypto_backend(self):
    self.seed_hash = self._get_system_artifact_hash()
    (m, c, n) = self._generate_primes_from_hash(self.seed_hash)
    self.lcg_oracle = LCGOracle(m, c, n, self.seed_hash)
    self.lcg_oracle.deploy_lcg_contract()
    print('[SETUP] LCG Oracle is on-chain...')
    self.xor_oracle = TripleXOROracle()
    self.xor_oracle.deploy_triple_xor_contract()
    print('[SETUP] Triple XOR Oracle is on-chain...')
    print('[SETUP] Crypto backend initialized...')
Click to expand

You might notice that the seed is also used as the initial state X0X_0 for the LCGOracle class.

RSA Key Construction

Alright, let’s see all of these RSA stuff.

In short, the generate_rsa_key_from_lcg uses LCG to find 8 256-bit primes, it then computes the RSA modulus n=(primes)n = \prod \text{(primes)}, totient ϕ=(p1)\phi = \prod (p - 1), public exponent e=65,537e = 65{,}537, and finally build a public key (n,e)(n, e).

def generate_rsa_key_from_lcg(self):
    print('[RSA] Generating RSA key from on-chain LCG primes...')
    lcg_for_rsa = LCGOracle(self.lcg_oracle.multiplier,
                            self.lcg_oracle.increment, self.lcg_oracle.modulus, self.seed_hash)
    lcg_for_rsa.deploy_lcg_contract()
    primes_arr = []
    rsa_msg_count = 0
    iteration_limit = 10000
    iterations = 0
    if len(primes_arr) < 8 and iterations < iteration_limit:
        candidate = lcg_for_rsa.get_next(rsa_msg_count)
        rsa_msg_count += 1
        iterations += 1
        if candidate.bit_length() == 256 and isPrime(candidate):
            primes_arr.append(candidate)
            print(f'''[RSA]  - Found 256-bit prime #{len(primes_arr)}''')
        if len(primes_arr) < 8 and iterations < iteration_limit:
            continue
    print('Primes Array: ', primes_arr)
    if len(primes_arr) < 8:
        error_msg = '[RSA] Error: Could not find 8 primes within iteration limit.'
        print('Current Primes: ', primes_arr)
        print(error_msg)
        return error_msg
    n = None
    for p_val in primes_arr:
        n *= p_val
    phi = 1
    for p_val in primes_arr:
        phi *= p_val - 1
    e = 65537
    if math.gcd(e, phi) != 1:
        error_msg = '[RSA] Error: Public exponent e is not coprime with phi(n). Cannot generate key.'
        print(error_msg)
        return error_msg
    self.rsa_key = None.construct((n, e))
Click to expand

And then all of the encryptions happen inside process_message. I can’t find where the generate_rsa_key_from_lcg method is called since my decompilation is kinda incomplete, but I’m too lazy to analyze the original Python bytecode so I’m going to guess that it’ll be called somewhere before the calls to process_message.

def process_message(self, plaintext):
    if self.conversation_start_time == 0:
        self.conversation_start_time = time.time()
    conversation_time = int(time.time() - self.conversation_start_time)
    if self.super_safe_mode and self.rsa_key:
        plaintext_bytes = plaintext.encode('utf-8')
        plaintext_enc = bytes_to_long(plaintext_bytes)
        _enc = pow(plaintext_enc, self.rsa_key.e, self.rsa_key.n)
        ciphertext = _enc.to_bytes(
            self.rsa_key.n.bit_length(), 'little').rstrip(b'\x00')
        encryption_mode = 'RSA'
        plaintext = '[ENCRYPTED]'
    else:
        prime_from_lcg = self.lcg_oracle.get_next(self.message_count)
        ciphertext = self.xor_oracle.encrypt(
            prime_from_lcg, conversation_time, plaintext)
        encryption_mode = 'LCG-XOR'
    log_entry = {
        'conversation_time': conversation_time,
        'mode': encryption_mode,
        'plaintext': plaintext,
        'ciphertext': ciphertext.hex()}
    self.chat_history.append(log_entry)
    self.save_chat_log()
    return (f'''[{conversation_time}s] {plaintext}''', f'''[{conversation_time}s] {ciphertext.hex()}''')
Click to expand

An interesting thing is that, the LCG contract is redeployed using the exact same parameters from the initialization of the backend. Wait, if we can somehow recover the parameters of LCGOracle, can we just use them to generate the private key and break RSA?

Solving Time

At this point, I think the solution to the challenge is pretty clear.

Since we already have the first 7 messages with their plaintext, ciphertext, and conversation_time, we can recover the primes used in LCG-XOR. Using those primes, we can determine the LCG parameters (a, c, m) and the seed, then advance the generator from the seed to obtain the first eight 256-bit prime candidates. From there, reimplement the key construction logic in generate_rsa_key_from_lcg and compute the private key exponent dd based on ee! Then we should be able to decrypt the messages with dd!

This is just a matter of some scripting, so let’s go ahead!

Reversing the LCG-XOR

This is actually super simple. From the analysis above, we can just do prime = ciphertext XOR plaintext XOR conversation_time to get the primes for messages 0 to 6.

In the Python script below, the first seven entries from chat_log.json are added to the log array. The plaintext is padded to 32 bytes, conversation_time is converted to a 32-byte big-endian integer, and prime is also converted to a 32-byte big-endian format.

log = [
    { "conversation_time": 0, "mode": "LCG-XOR", "plaintext": "Hello", "ciphertext": "e934b27119f12318fe16e8cd1c1678fd3b0a752eca163a7261a7e2510184bbe9" },
    { "conversation_time": 4, "mode": "LCG-XOR", "plaintext": "How are you?", "ciphertext": "25bf2fd1198392f4935dcace7d747c1e0715865b21358418e67f94163513eae4" },
    { "conversation_time": 11, "mode": "LCG-XOR", "plaintext": "Terrible...", "ciphertext": "c9f20e5561acf172305cf8f04c13e643c988aa5ab29b5499c93df112687c8c7c" },
    { "conversation_time": 13, "mode": "LCG-XOR", "plaintext": "Is this a secure channel?", "ciphertext": "3ab9c9f38e4f767a13b12569cdbf13db6bbb939e4c8a57287fb0c9def0288e46" },
    { "conversation_time": 16, "mode": "LCG-XOR", "plaintext": "Yes, it's on the blockchain.", "ciphertext": "3f6de0c2063d3e8e875737046fef079d73cc9b1b7a4b7b94da2d2867493f6fc5" },
    { "conversation_time": 24, "mode": "LCG-XOR", "plaintext": "Erm enable super safe mode", "ciphertext": "787cf6c0be39caa21b7908fcd1beca68031b7d11130005ba361c5d361b106b6d" },
    { "conversation_time": 30, "mode": "LCG-XOR", "plaintext": "Ok, activating now", "ciphertext": "632ab61849140655e0ee6f90ab00b879a3a3da241d4b50bab99f74f169d456db" },
]

p_list = []

for entry in log:
    plaintext = entry["plaintext"]
    conversation_time = entry["conversation_time"]
    ciphertext = entry["ciphertext"]

    plaintext_bytes = plaintext.encode('utf-8').ljust(32, b'\x00')
    time_bytes = conversation_time.to_bytes(32, 'big')
    cipher_bytes = bytes.fromhex(ciphertext)

    prime_bytes = bytes(a ^ b ^ c for a, b, c in zip(
        cipher_bytes, plaintext_bytes, time_bytes))

    p = int.from_bytes(prime_bytes, 'big')
    p_list.append(p)

print(p_list)
Click to expand

And we can finally get these large numbers:

[
	72967016216206426977511399018380411256993151454761051136963936354667101207529,
	49670218548812619526153633222605091541916798863041459174610474909967699929824,
	71280768003266775309606950462778030896956536610993788270598595159221463714935, 
	52374492464889938543708223275193226150102478572181009159069287723157087096395, 
	46151066309228226342793435233510111804521449597151473094879900544455543844821, 
	27616895455297410644582736481740143600211650471053558274523739523026009484149, 
	20017674779830364175685710279350076931283727675441675047445679766035806574277
]
Click to expand

Uhh weird, some of them aren’t even prime numbers!

Well I spent some time looking back at the LCG-XOR encryption part in process_message. It turns out that these numbers don’t actually need to be prime as the encryption just uses the generated value as is without checking for primality!

prime_from_lcg = self.lcg_oracle.get_next(self.message_count)
ciphertext = self.xor_oracle.encrypt(prime_from_lcg, conversation_time, plaintext)
encryption_mode = 'LCG-XOR'
Click to expand

So we’ve got the LCG-XOR primes. Let’s recover the parameters from here.

Recovering LCG Parameters

With these 7 numbers, how can we crack the LCG? Grok comes in handy once again and gave me this script:

from math import gcd
import sympy.ntheory as nt

s = [72967016216206426977511399018380411256993151454761051136963936354667101207529, 49670218548812619526153633222605091541916798863041459174610474909967699929824, 71280768003266775309606950462778030896956536610993788270598595159221463714935, 52374492464889938543708223275193226150102478572181009159069287723157087096395, 46151066309228226342793435233510111804521449597151473094879900544455543844821, 27616895455297410644582736481740143600211650471053558274523739523026009484149, 20017674779830364175685710279350076931283727675441675047445679766035806574277]
t = []

for i in range(1, len(s)-2):
    d0 = s[i] - s[i-1]
    d1 = s[i+1] - s[i]
    d2 = s[i+2] - s[i+1]
    ti = abs(d1**2 - d2 * d0)
    t.append(ti)

m = t[0]
for ti in t[1:]:
    m = gcd(m, ti)
print("m:", m)

d0 = s[1] - s[0]
d1 = s[2] - s[1]
a = (d1 * pow(d0, -1, m)) % m
print("a:", a)

c = (s[1] - a * s[0]) % m
print("c:", c)

seed = (s[0] - c) * pow(a, -1, m) % m
print("seed_hash:", seed)
Click to expand

Here’s the parameters of the LCG after running the script:

m: 98931271253110664660254761255117471820360598758511684442313187065390755933409
a: 11352347617227399966276728996677942514782456048827240690093985172111341259890
c: 61077733451871028544335625522563534065222147972493076369037987394712960199707
seed_hash: 80631529052001100272845413254760303954872303349693249834992925889079643767931
Click to expand

While I was doing research on this, I came across this blog post which demonstrates the same cracking approach as Grok’s. It’s worth checking out btw.

Cracking RSA

We’re going to use the exact logic to generate the RSA key as in generate_rsa_key_from_lcg. Another thing to do is to calculate the private key exponent dd as e1(modφ(n))e^{-1} \pmod{\varphi(n)} (modular inverse of e modulo phi) for decrypting the messages.

Generating RSA Primes

This part is just straight copying from the generate_rsa_key_from_lcg function:

import sympy.ntheory as nt

a = 11352347617227399966276728996677942514782456048827240690093985172111341259890
c = 61077733451871028544335625522563534065222147972493076369037987394712960199707
m = 98931271253110664660254761255117471820360598758511684442313187065390755933409
state = 80631529052001100272845413254760303954872303349693249834992925889079643767931

primes = []
counter = 0
iteration_limit = 10000

while len(primes) < 8 and counter < iteration_limit:
    candidate = (a * state + c) % m
    state = candidate
    counter += 1
    if candidate.bit_length() == 256 and nt.isprime(candidate):
        primes.append(candidate)

print(primes)
Click to expand

Running the script yielded eight 256-bit primes:

[
  72967016216206426977511399018380411256993151454761051136963936354667101207529,
  79611551309049018061300429096903741339200167241148430095608259960783012192237,
  75395288067150543091997907493708187002382230701390674177789205231462589994993,
  62826068095404038148338678434404643116583820572865189787368764098892510936793,
  69802783227378026511719332106789335301376047817734407431543841272855455052067,
  68446593057460676025047989394445774862028837156496043637575024036696645401289,
  82836473202091099900869551647600727408082364801577205107017971703263472445197,
  88790251731800173019114073860734130032527125661685690883849562991870715928701
]
Click to expand

Decryptions, Finally!

Once again, the code for calculating phi is copied from generate_rsa_key_from_lcg, with some minor modifications. We’re going to calculate d = pow(e, -1, phi) to obtain the private key exponent.

If you look more closely at the process_message method, ciphertexts are actually little-endian bytes. To decrypt, we’ll compute msg = pow(c, d, n) where c is a ciphertext from chat_log.json, convert it to big-endian bytes, then decode the bytes as UTF-8 to obtain the unencrypted messages.

primes = [72967016216206426977511399018380411256993151454761051136963936354667101207529, 79611551309049018061300429096903741339200167241148430095608259960783012192237, 75395288067150543091997907493708187002382230701390674177789205231462589994993, 62826068095404038148338678434404643116583820572865189787368764098892510936793, 69802783227378026511719332106789335301376047817734407431543841272855455052067, 68446593057460676025047989394445774862028837156496043637575024036696645401289, 82836473202091099900869551647600727408082364801577205107017971703263472445197, 88790251731800173019114073860734130032527125661685690883849562991870715928701]

n = 1
for p in primes:
    n *= p

phi = 1
for p in primes:
    phi *= (p - 1)

e = 65537
d = pow(e, -1, phi)

# From chat_logs.json
rsa_ciphertexts = [
    "680a65364a498aa87cf17c934ab308b2aee0014aee5b0b7d289b5108677c7ad1eb3bcfbcad7582f87cb3f242391bea7e70e8c01f3ad53ac69488713daea76bb3a524bd2a4bbbc2cfb487477e9d91783f103bd6729b15a4ae99cb93f0db22a467ce12f8d56acaef5d1652c54f495db7bc88aa423bc1c2b60a6ecaede2f4273f6dce265f6c664ec583d7bd75d2fb849d77fa11d05de891b5a706eb103b7dbdb4e5a4a2e72445b61b83fd931cae34e5eaab931037db72ba14e41a70de94472e949ca3cf2135c2ccef0e9b6fa7dd3aaf29a946d165f6ca452466168c32c43c91f159928efb3624e56430b14a0728c52f2668ab26f837120d7af36baf48192ceb3002",
    "6f70034472ce115fc82a08560bd22f0e7f373e6ef27bca6e4c8f67fedf4031be23bf50311b4720fe74836b352b34c42db46341cac60298f2fa768f775a9c3da0c6705e0ce11d19b3cbdcf51309c22744e96a19576a8de0e1195f2dab21a3f1b0ef5086afcffa2e086e7738e5032cb5503df39e4bf4bdf620af7aa0f752dac942be50e7fec9a82b63f5c8faf07306e2a2e605bb93df09951c8ad46e5a2572e333484cae16be41929523c83c0d4ca317ef72ea9cde1d5630ebf6c244803d2dc1da0a1eefaafa82339bf0e6cf4bf41b1a2a90f7b2e25313a021eafa6234643acb9d5c9c22674d7bc793f1822743b48227a814a7a6604694296f33c2c59e743f4106",
]

for cipher_hex in rsa_ciphertexts:
    c = int.from_bytes(bytes.fromhex(cipher_hex), 'little')
    msg_int = pow(c, d, n)
    msg_bytes = msg_int.to_bytes((msg_int.bit_length() + 7) // 8, 'big')
    msg_str = msg_bytes.decode('utf-8')
    print(msg_str)
Click to expand

The above script prints the decrypted messages with the flag:

Actually what's your email?
It's W3b3_i5_Gr8@flare-on.com
Click to expand
W3b3_i5_Gr8@flare-on.com

Thoughts

I did try solving the challenge by tossing the decompilation and the chat log file into Grok LOL, since I heard someone said that LLMs are powerful at solving cryptography challenges. And Grok nailed this with just one shot!

In the end, I chose to actually solve this challenge myself, since it was my first Flare-On and I couldn’t get past the 7th challenge. I feels like this challenge is more about cryptography rather than reversing, but it is still super interesting and definitely taught me something about LCG and RSA!