From 5c7bf49df53155bb1656a56c0b69197051609190 Mon Sep 17 00:00:00 2001 From: nevergiveup-c <217945237+nevergiveup-c@users.noreply.github.com> Date: Wed, 10 Dec 2025 21:24:54 +0700 Subject: [PATCH 1/3] feat(extractors): add CastleLoader malware extractor --- Extractors/CastleLoader/README.md | 28 ++++ Extractors/CastleLoader/requirements.txt | 2 + Extractors/CastleLoader/script.py | 198 +++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 Extractors/CastleLoader/README.md create mode 100644 Extractors/CastleLoader/requirements.txt create mode 100644 Extractors/CastleLoader/script.py diff --git a/Extractors/CastleLoader/README.md b/Extractors/CastleLoader/README.md new file mode 100644 index 0000000..9355cf3 --- /dev/null +++ b/Extractors/CastleLoader/README.md @@ -0,0 +1,28 @@ +# CastleLoader Config Extractor + +Extracts and decrypts configuration strings from CastleLoader malware samples. + +## Installation +```bash +pip install -r requirements.txt +``` + +## Usage +```bash +python script.py +``` + +## Example +```bash +python script.py castleloader_sample.bin +``` + +## Requirements +Full memory dump **including PE header** (not partial dumps or shellcode) + +## Output +Extracts strings including: +- C2 endpoints +- User-Agent strings +- Mutex names +- Configuration parameters \ No newline at end of file diff --git a/Extractors/CastleLoader/requirements.txt b/Extractors/CastleLoader/requirements.txt new file mode 100644 index 0000000..605296d --- /dev/null +++ b/Extractors/CastleLoader/requirements.txt @@ -0,0 +1,2 @@ +capstone==5.0.6 +lief==0.17.1 \ No newline at end of file diff --git a/Extractors/CastleLoader/script.py b/Extractors/CastleLoader/script.py new file mode 100644 index 0000000..61132a0 --- /dev/null +++ b/Extractors/CastleLoader/script.py @@ -0,0 +1,198 @@ +import struct, lief, sys, re + +from capstone import Cs, CS_ARCH_X86, CS_MODE_32 + +lief.logging.disable() + +# Pre-compiled struct for DWORD unpacking +_DWORD = struct.Struct('> 1) % key_len]) + decoded.append(high_byte) + + return decoded.decode('utf-16le', errors='ignore').rstrip('\x00') + +# Finds and decrypts config string by locating MOV stack-write pattern, collecting DWORDs, extracting XOR key from .data access +def ParseNext(data: bytes, pe, cs, start_offset: int): + data_len = len(data) + mv = memoryview(data) + + found_mov = data.find(b'\xC7', start_offset) + if found_mov == -1 or found_mov >= data_len - 1: + return None, data_len + + modrm = data[found_mov + 1] + if modrm not in (0x45, 0x85): + return None, found_mov + 1 + + config_data = [] + array_offset = found_mov + key = None + + while array_offset < data_len - 10: + try: + # Disassemble one instruction at current offset + code = data[array_offset:array_offset + 15] # Max x86 instruction is 15 bytes + insns = list(cs.disasm(code, array_offset, 1)) + if not insns: + array_offset += 1 + continue + + insn = insns[0] + insn_len = insn.size + insn_mnemonic = insn.mnemonic.upper() + + # Clear config on RET/CALL (garbage) + if len(config_data) < 10 and insn_mnemonic in ('RET', 'CALL'): + if config_data: + break + config_data.clear() + array_offset += insn_len + continue + + # Clear config on any jump instruction (garbage) + if insn_mnemonic[0] == 'J': + if config_data: + break + config_data.clear() + array_offset += insn_len + continue + + b1, b2 = data[array_offset], data[array_offset + 1] + + # Key pattern (0F B6 80) - MOVZX reading from .data section + if b1 == 0x0F and b2 == 0xB6 and data[array_offset + 2] == 0x80: + key_va = _DWORD.unpack(mv[array_offset + 3:array_offset + 7])[0] + try: + key = pe.get_content_from_virtual_address(key_va, 4).tobytes() + if len(key) == 4: + break + key = None + except Exception: + pass + + # MOV [EBP-X], imm32 (C7 45) - write dword ptr to stack + elif b1 == 0xC7 and b2 == 0x45: + config_data.append(_DWORD.unpack(mv[array_offset + 3:array_offset + 7])[0]) + + # MOV [EBP-X], imm32 (C7 85) - write dword ptr to stack + elif b1 == 0xC7 and b2 == 0x85: + config_data.append(_DWORD.unpack(mv[array_offset + 6:array_offset + 10])[0]) + + array_offset += insn_len + + except Exception: + array_offset += 1 + + if config_data and key: + try: + string = Xor(config_data, key) + if len(string) > 1: + return string, array_offset + except Exception: + pass + + return None, found_mov + 1 + +# Extract configuration string by pattern (re) +def ExtractConfigByPattern(data: bytes, pe, cs, pattern): + match = pattern.search(data) + if not match: + return None + + offset = match.start() + + result, _ = ParseNext(data, pe, cs, offset) + if result: + return result + + return None + +# Extract all configuration strings +def ExtractAllConfigs(data: bytes, pe, cs): + results = [] + current_offset = 0 + data_len = len(data) + + while current_offset < data_len: + if len(results) >= _MAX_FOUNDS: + break + + result, next_offset = ParseNext(data, pe, cs, current_offset) + + if result: + results.append(result) + + current_offset = next_offset + + print(f"[+] Done. Found {len(results)} strings") + return results + +def main(): + if len(sys.argv) < 2: + print("Usage: python script.py ") + sys.exit(1) + + try: + with open(sys.argv[1], 'rb') as f: + data = f.read() + except Exception as e: + print(f"[-] Error reading file: {e}") + sys.exit(1) + + pe = lief.parse(data) + if not pe: + print("[-] Failed to parse PE file") + sys.exit(1) + + # Initialize capstone disassembler for x86 32-bit + cs = Cs(CS_ARCH_X86, CS_MODE_32) + + result = ExtractAllConfigs(data, pe, cs) + print(result) + + # Find mutex only (works for MD5 1E0F94E8EC83C1879CCD25FEC59098F1, config layout and + # decryption routines differ across binaries, so patterns vary) + + # pattern = re.compile( + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\x33\xC9' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\xC7\x45.{5}' + # b'\x66\x89\x45.' + # b'\x0F\x1F\x44\x00.' + # b'\x8B\xC1' + # b'\x83\xE0.' + # b'\x0F\xB6\x80.{4}' + # b'\x66\x33\x44\x4D.' + # b'\x66\x89\x84\x4D', + # re.DOTALL + # ) + # + # mutex = ExtractConfigByPattern(data, pe, cs, pattern) + # print(mutex) + +if __name__ == '__main__': + main() \ No newline at end of file From ac782c99b3488b73d3e9445df49a652a29472acf Mon Sep 17 00:00:00 2001 From: nevergiveup-c <217945237+nevergiveup-c@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:21:56 +0700 Subject: [PATCH 2/3] refactor(script): add GPL license, migrate to argparse, optimize disasm iteration --- Extractors/CastleLoader/script.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Extractors/CastleLoader/script.py b/Extractors/CastleLoader/script.py index 61132a0..2e2c209 100644 --- a/Extractors/CastleLoader/script.py +++ b/Extractors/CastleLoader/script.py @@ -1,4 +1,19 @@ -import struct, lief, sys, re +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# please, check out README.md before using this + +import argparse, struct, lief, sys from capstone import Cs, CS_ARCH_X86, CS_MODE_32 @@ -47,12 +62,11 @@ def ParseNext(data: bytes, pe, cs, start_offset: int): try: # Disassemble one instruction at current offset code = data[array_offset:array_offset + 15] # Max x86 instruction is 15 bytes - insns = list(cs.disasm(code, array_offset, 1)) - if not insns: + insn = next(cs.disasm(code, array_offset, 1), None) + if not insn: array_offset += 1 continue - insn = insns[0] insn_len = insn.size insn_mnemonic = insn.mnemonic.upper() @@ -143,12 +157,12 @@ def ExtractAllConfigs(data: bytes, pe, cs): return results def main(): - if len(sys.argv) < 2: - print("Usage: python script.py ") - sys.exit(1) + parser = argparse.ArgumentParser() + parser.add_argument('', help='Path to memory dump file') + args = parser.parse_args() try: - with open(sys.argv[1], 'rb') as f: + with open(args.dump, 'rb') as f: data = f.read() except Exception as e: print(f"[-] Error reading file: {e}") From 458cc1df4d18b23476e77b580a38b734ae60436a Mon Sep 17 00:00:00 2001 From: nevergiveup-c <217945237+nevergiveup-c@users.noreply.github.com> Date: Thu, 11 Dec 2025 16:24:38 +0700 Subject: [PATCH 3/3] fix(script): correct argparse positional argument syntax --- Extractors/CastleLoader/script.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Extractors/CastleLoader/script.py b/Extractors/CastleLoader/script.py index 2e2c209..800d80e 100644 --- a/Extractors/CastleLoader/script.py +++ b/Extractors/CastleLoader/script.py @@ -158,7 +158,7 @@ def ExtractAllConfigs(data: bytes, pe, cs): def main(): parser = argparse.ArgumentParser() - parser.add_argument('', help='Path to memory dump file') + parser.add_argument('dump', help='Path to memory dump file') args = parser.parse_args() try: