Skip to content
Merged
6 changes: 3 additions & 3 deletions .github/workflows/python-package-uv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ jobs:
with:
python-version: '3.12'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH && echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Restore cached venv
uses: actions/cache/restore@v4
with:
path: .venv
key: ${{ runner.os }}-python-3.12-uv-${{ hashFiles('poetry.lock') }}
key: ${{ runner.os }}-python-3.12-uv-v2-${{ hashFiles('poetry.lock', 'requirements.txt') }}
- name: Run tests
run: uvx pytest
run: uv run --no-project pytest
7 changes: 4 additions & 3 deletions .github/workflows/reusable-setup.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ jobs:
with:
python-version: ${{ inputs.python-version }}
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.cargo/bin" >> $GITHUB_PATH
run: curl -LsSf https://astral.sh/uv/install.sh | sh && echo "$HOME/.local/bin" >> $GITHUB_PATH && echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- uses: actions/cache@v4
id: cache-uv-venv
with:
path: .venv
key: ${{ runner.os }}-python-${{ inputs.python-version }}-uv-${{ hashFiles('poetry.lock') }}
key: ${{ runner.os }}-python-${{ inputs.python-version }}-uv-v2-${{ hashFiles('poetry.lock', 'requirements.txt') }}
restore-keys: |
${{ runner.os }}-python-${{ inputs.python-version }}-uv-
${{ runner.os }}-python-${{ inputs.python-version }}-uv-v2-
- if: steps.cache-uv-venv.outputs.cache-hit != 'true'
run: |
uv venv --python ${{ inputs.python-version }}
uv pip sync --all-extras pyproject.toml
uv pip install pytest pytest-pretty pytest-cov pytest-mock pytest-asyncio pytest-xdist pytest-freezer tenacity httpretty func-timeout
2 changes: 1 addition & 1 deletion cape_parsers/CAPE/community/MonsterV2.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import hashlib
from Crypto.Cipher import ChaCha20_Poly1305
from Cryptodome.Cipher import ChaCha20_Poly1305
from contextlib import suppress
import zlib
import struct
Expand Down
52 changes: 29 additions & 23 deletions cape_parsers/CAPE/core/BumbleBee.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def extract_config_data(data, pe, config_match):
The LEA ins are using relative addressing. Referenced data is relative to the address of the NEXT ins.
This is inefficient but I'm bad at Python, okay?
"""
if not config_match:
return False, False, False
try:
# Get campaign id ciphertext
campaign_id_rva = pe.get_rva_from_offset(config_match.start() + int(len(config_match.group("campaign_id_ins"))))
Expand Down Expand Up @@ -143,22 +145,24 @@ def extract_2024(pe, filebuf):
key_offset = pe.get_dword_from_offset(rc4key_init_offset + 57)
key_rva = pe.get_rva_from_offset(rc4key_init_offset + 61) + key_offset
key = pe.get_string_at_rva(key_rva)
if not isinstance(key, (bytes, bytearray)) or not (1 <= len(key) <= 256):
return

botid = ""
botid_offset = pe.get_dword_from_offset(botid_init_offset + 51)
botid_rva = pe.get_rva_from_offset(botid_init_offset + 55) + botid_offset
botid_len_offset = pe.get_dword_from_offset(botidlgt_init_offset + 31)
botid_data = pe.get_data(botid_rva)[:botid_len_offset]
with suppress(Exception):
botid = ARC4.new(key).decrypt(botid_data).split(b"\x00")[0].decode()
botid = ARC4.new(key).decrypt(botid_data).split(b"\x00")[0].decode(errors="replace")

port = ""
port_offset = pe.get_dword_from_offset(port_init_offset + 23)
port_rva = pe.get_rva_from_offset(port_init_offset + 27) + port_offset
port_len_offset = pe.get_dword_from_offset(botidlgt_init_offset + 4)
port_data = pe.get_data(port_rva)[:port_len_offset]
with suppress(Exception):
port = ARC4.new(key).decrypt(port_data).split(b"\x00")[0].decode()
port = ARC4.new(key).decrypt(port_data).split(b"\x00")[0].decode(errors="replace")

dgaseed_offset = pe.get_dword_from_offset(dga1_init_offset + 15)
dgaseed_rva = pe.get_rva_from_offset(dga1_init_offset + 19) + dgaseed_offset
Expand All @@ -174,16 +178,17 @@ def extract_2024(pe, filebuf):

tld_offset = pe.get_dword_from_offset(dga2_init_offset + 37)
tld_rva = pe.get_rva_from_offset(dga2_init_offset + 41) + tld_offset
tld_data = pe.get_string_at_rva(tld_rva).decode()
tld_raw = pe.get_string_at_rva(tld_rva)
tld_data = tld_raw.decode(errors="replace") if tld_raw else ""

config = {
"dga_seed": str(int(dgaseed_data)),
"cryptokey": key.decode(),
"cryptokey": key.decode(errors="replace"),
"cryptokey_type": "RC4",
"raw": {
"TLD": tld_data,
"Domain length": domainlen_data.decode(),
"Number DGA domains": numdga_data.decode(),
"Domain length": domainlen_data.decode(errors="replace") if domainlen_data else "",
"Number DGA domains": numdga_data.decode(errors="replace") if numdga_data else "",
},
}
if port:
Expand Down Expand Up @@ -213,35 +218,36 @@ def extract_config(data):
re.DOTALL,
)
# Extract Key
key = None
key_match = list(key_regex.finditer(data))
if len(key_match) > 1:
for index, match in enumerate(key_match):
key = extract_key_data(data, pe, match)
if not key:
extracted = extract_key_data(data, pe, match)
if not extracted:
continue
if index == 0:
cfg["botnet"] = key.decode()
cfg["botnet"] = extracted.decode(errors="replace")
elif index == 1:
cfg["campaign"] = key.decode()
cfg["campaign"] = extracted.decode(errors="replace")
elif index == 2:
cfg.setdefault("raw", {})["Data"] = key.decode("latin-1")
cfg.setdefault("raw", {})["Data"] = extracted.decode("latin-1")
elif index == 3:
cfg["CNCs"] = list(key.decode().split(","))
cfg["CNCs"] = list(extracted.decode(errors="replace").split(","))
elif len(key_match) == 1:
key = extract_key_data(data, pe, key_match[0])
if not key:
if not isinstance(key, (bytes, bytearray)) or not (1 <= len(key) <= 256):
return cfg
cfg["cryptokey"] = key.decode()
cfg["cryptokey"] = key.decode(errors="replace")
cfg["cryptokey_type"] = "RC4"
# Extract config ciphertext
config_match = regex.search(data)
campaign_id, botnet_id, c2s = extract_config_data(data, pe, config_match)
if campaign_id:
cfg["campaign"] = ARC4.new(key).decrypt(campaign_id).split(b"\x00")[0].decode()
if botnet_id:
cfg["botnet"] = ARC4.new(key).decrypt(botnet_id).split(b"\x00")[0].decode()
if c2s:
cfg["CNCs"] = list(ARC4.new(key).decrypt(c2s).split(b"\x00")[0].decode().split(","))
# Extract config ciphertext
config_match = regex.search(data)
campaign_id, botnet_id, c2s = extract_config_data(data, pe, config_match)
if campaign_id:
cfg["campaign"] = ARC4.new(key).decrypt(campaign_id).split(b"\x00")[0].decode(errors="replace")
if botnet_id:
cfg["botnet"] = ARC4.new(key).decrypt(botnet_id).split(b"\x00")[0].decode(errors="replace")
if c2s:
cfg["CNCs"] = list(ARC4.new(key).decrypt(c2s).split(b"\x00")[0].decode(errors="replace").split(","))
except Exception as e:
log.exception("This is broken: %s", str(e))

Expand Down
67 changes: 67 additions & 0 deletions tests/test_bumblebee_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from unittest.mock import MagicMock, patch

from Cryptodome.Cipher import ARC4

from cape_parsers.CAPE.core import BumbleBee


def _make_fake_pe():
pe = MagicMock()
pe.get_rva_from_offset.side_effect = lambda off: off
pe.get_offset_from_rva.side_effect = lambda rva: rva
return pe


def test_extract_config_multi_key_non_utf8_and_empty_key_does_not_raise(caplog):
# Two key_regex matches: first points to non-UTF-8 bytes (0xa0...), second points to empty null byte (b"\x00"),
# followed by a config_match pattern. Should not raise UnicodeDecodeError or ValueError in ARC4.new.
k1_ins = b"\x48\x8d\x05\x00\x00\x00\x00\x80\x3d\x00\x00\x00\x00\x00"
k2_ins = b"\x48\x8d\x05\x00\x00\x00\x00\x80\x3d\x00\x00\x00\x00\x00"
cfg_ins = (
b"\x48\x8d\x05\x00\x00\x00\x00"
b"\x48\x8d\x0d\x00\x00\x00\x00"
b"\x48\x8d\x15\x00\x00\x00\x00"
)
data = k1_ins + k2_ins + cfg_ins + b"\x00" * 128

fake_pe = _make_fake_pe()
with (
patch("cape_parsers.CAPE.core.BumbleBee.pefile.PE", return_value=fake_pe),
patch("cape_parsers.CAPE.core.BumbleBee.extract_key_data", side_effect=[b"\xa0\x89test", b""]),
patch("cape_parsers.CAPE.core.BumbleBee.extract_2024", return_value={}),
):
cfg = BumbleBee.extract_config(data)

assert "This is broken" not in caplog.text
assert cfg == {"botnet": "\ufffd\ufffdtest"}


def test_extract_config_single_key_rc4_decryption():
rc4_key = b"secretkey"
k1_ins = b"\x48\x8d\x05\x00\x00\x00\x00\x80\x3d\x00\x00\x00\x00\x00"
cfg_ins = (
b"\x48\x8d\x05\x00\x00\x00\x00"
b"\x48\x8d\x0d\x00\x00\x00\x00"
b"\x48\x8d\x15\x00\x00\x00\x00"
)
data = k1_ins + cfg_ins

camp_ct = ARC4.new(rc4_key).encrypt(b"camp1\x00")
bot_ct = ARC4.new(rc4_key).encrypt(b"bot1\x00")
c2_ct = ARC4.new(rc4_key).encrypt(b"1.2.3.4:443,5.6.7.8:443\x00")

fake_pe = _make_fake_pe()
with (
patch("cape_parsers.CAPE.core.BumbleBee.pefile.PE", return_value=fake_pe),
patch("cape_parsers.CAPE.core.BumbleBee.extract_key_data", return_value=rc4_key),
patch("cape_parsers.CAPE.core.BumbleBee.extract_config_data", return_value=(camp_ct, bot_ct, c2_ct)),
):
cfg = BumbleBee.extract_config(data)

assert cfg == {
"cryptokey": "secretkey",
"cryptokey_type": "RC4",
"campaign": "camp1",
"botnet": "bot1",
"CNCs": ["1.2.3.4:443", "5.6.7.8:443"],
}
8 changes: 8 additions & 0 deletions tests/test_monsterv2_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from cape_parsers.CAPE.community import MonsterV2


def test_monsterv2_import_and_key_derivation():
key, nonce = MonsterV2.derive_chacha_key_nonce_blake2b(b"A" * 32)
assert len(key) == 32
assert len(nonce) == 24
assert MonsterV2.extract_config(b"not a pe") == {}
3 changes: 0 additions & 3 deletions tests_parsers/test_adaptixbeacon.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,13 @@ def test_adaptixbeacon():
conf = extract_config(data.read())
assert conf == {
"raw": {
"cryptokey": "9030edf2700574ff942f8dadb826fac8",
"cryptokey_type": "RC4",
"agent_type": "BE4C0149",
"use_ssl": 1,
"servers": ["689535ed-3.b-cdn.net"],
"ports": [443],
"http_method": "POST",
"uri": "/amazon/Trust/disputes/press-requests.php",
"parameter": "A-Wabbon-Id",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/131.0.2903.86",
"http_headers": "Accept: application/json, application/javascript, text/javascript; charset=utf-8\r\nContent-Type: application/json; charset=utf-8\r\nX-Amazon-Trace-Id: Base64Encode(intern-session_01234567890)\r\nX-Amz-User-Agent: AmazonEnterpriseApplication/android-1.0.1 (Android 9API HoneyComb)\r\nX-Amz-Target: AmazonEnterpriseClient.BusinessLogic\r\nX-Amz-Date: 2038-03-14T03:14:07Z\r\nAmz-Safe-Signature: TURBECOMPLEXification_0123456789\r\nAmz-Security-Flag: Amazon8Imag genitalsHTLM5\r\nCookie: sessionId=321116abbcdefXXxc8qRVfk; expires=600\r\n",
"ans_pre_size": 26,
"ans_size": 21,
Expand Down
7 changes: 6 additions & 1 deletion tests_parsers/test_axolotlloader.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import hashlib
import os
import pytest

from cape_parsers.CAPE.core.AxolotlLoader import extract_config

SAMPLE_PATH = "tests/data/malware/906035b6092b2ce1290c566f590e88f1b960b25c6d987f46a3d99cca5b05ee9d"


@pytest.mark.skipif(not os.path.exists(SAMPLE_PATH), reason="Missed file")
def test_axolotlloader_906035b6092b2ce1290c566f590e88f1b960b25c6d987f46a3d99cca5b05ee9d():
with open("tests/data/malware/906035b6092b2ce1290c566f590e88f1b960b25c6d987f46a3d99cca5b05ee9d", "rb") as data:
with open(SAMPLE_PATH, "rb") as data:
conf = extract_config(data.read())
conf["dump_files"]["payload"] = hashlib.sha256(conf["dump_files"]["payload"]).hexdigest()
assert conf == {
Expand Down
8 changes: 7 additions & 1 deletion tests_parsers/test_docconnect.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import os
import pytest

from cape_parsers.CAPE.core.DocConnect import extract_config

SAMPLE_PATH = "tests/data/malware/162c0d3e671ddf4f7f3ae5681da5272111eab6588bc53843cc604fc386634594"


@pytest.mark.skipif(not os.path.exists(SAMPLE_PATH), reason="Missed file")
def test_docconnect_162c0d3e671ddf4f7f3ae5681da5272111eab6588bc53843cc604fc386634594():
with open("tests/data/malware/162c0d3e671ddf4f7f3ae5681da5272111eab6588bc53843cc604fc386634594", "rb") as data:
with open(SAMPLE_PATH, "rb") as data:
conf = extract_config(data.read())
assert conf == {
"CNCs": [
Expand Down
2 changes: 1 addition & 1 deletion tests_parsers/test_hijackloader.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from cape_parsers.CAPE.community.HijackLoader import extract_config
from cape_parsers.CAPE.core.HijackLoader import extract_config


def test_hijackloader():
Expand Down
Loading