1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【マルウェア解析】macOSを標的とした Atomic Stealer ビーコンの解析

1
Last updated at Posted at 2026-02-18

はじめに

こちらの記事はmacOSを標的とした Atomic Stealer ビーコンの分析とConfig Parseを行う記事です。

以降記載する情報は実際のマルウェアサンプルが通信を行う宛先など含まれているため、取り扱いには十分注意してください。

Atomic Stealerとは

トレンドマイクロさんの以下の記事を引用します。

情報窃取型マルウェア「Atomic macOS Stealer(AMOS)」であり、Appleユーザからの機密情報窃取に特化した作りとなっています。

keychainやブラウザのパスワード、クッキー、ブラウザデータ、暗号通貨ウォレットなど様々な情報を搾取するマルウェアです。

Darktraceさんの記事によるとClickFixや偽のアプリケーションインストーラーを介して様々な方法で初期感染が行われているようです。

この記事によると、ビーコンによるバックドアも展開し、継続的なアクセスを確保しているようです。

今回はこのビーコンの分析を行います。

追記:
以下のブログのサンプルとほぼ同じような検体だと思われます。

Beaconの解析

表層解析

まずバイナリの先頭を確認するとMach-OのFATバイナリであることが確認できます。
スクリーンショット 2026-02-18 18.46.34.png

CPUアーキテクチャはx86-64であることが以下からわかります。
スクリーンショット 2026-02-18 18.51.17.png

静的解析

Beaconのメインを見ていきます。このマルウェアの機能はほぼこの関数に集約されています。
スクリーンショット 2026-02-18 20.32.41.png

以下に注目します。
スクリーンショット 2026-02-18 20.21.48.png
ここは以下のような演算を行っています。

$$
((A[i] - B[i]) / C[i]) ⊕ D[i] - E[i]
$$

データ構造体から何やら上記多項式を利用してデコードしているように見えます。この5つの構造体が12セットブロックほどあるのでここから意味のある文字列がデコードされているように見えます。

実際にデータ構造の一部を確認します。
スクリーンショット 2026-02-18 20.00.16.png
4バイトごとに意味のある文字列がありそうですね。

この演算後に格納されているポインタをhex_to_str_binary_decodeの関数に渡します。この関数の中身を確認すると以下のようにASCIIでよく見るバイト0x300x41などがあるのがわかります。hex2strの動作です。
スクリーンショット 2026-02-18 20.24.03.png

続いてdecrypt_custom_base64_tableの挙動を確認します。
スクリーンショット 2026-02-18 20.27.17.png
先ほどの多項式と同じ動作のように見えます。シンボルの命名からわかると思いますが、これがBase64の元となるテーブルです。同じようにhex2strも行われます。

その後の動作は以下のようになっています。
スクリーンショット 2026-02-18 20.29.58.png
base64_decode_custom内部を確認します。
スクリーンショット 2026-02-18 21.50.49.png
下位6ビットand、6ビットシフト、2の比較。この流れでBase64デコードかなと判断できます。
ただ、このMalwareは基本的なBase64のマッピングABCD......789+/ではなくカスタムしたマッピングを行っていました。これはenc_base64_tableb_のhex2strしたブロックを元に作成しています。

2つ目のブロックを見てみます。
スクリーンショット 2026-02-18 21.03.48.png
大方同じ流れです。これをまとめると以下のようになります。

  1. ((A[i] - B[i]) / C[i]) ⊕ D[i] - E[i]の多項式を実施
  2. hex2str実施
  3. カスタムBase64マッピングテーブルを作成
  4. カスタムBase64でデコード
  5. 以降12ブロック同様(各ブロックで1番目の多項式のループ回数は違う)

Config Parse

この挙動を再現し、マルウェアのConfigをパースするスクリプトを記載します。

#!/usr/bin/env python3
import struct, sys, os, base64

# ============================================================
# Universal Binary (Fat Binary) / Mach-O 解析
# ============================================================
FAT_MAGIC    = 0xCAFEBABE
MH_MAGIC_64  = 0xFEEDFACF
CPU_X86_64   = 0x01000007
CPU_ARM64    = 0x0100000C
LC_SEGMENT_64 = 0x19
MACH_HEADER_64_SIZE = 0x20  

def find_text_segment(data: bytes, macho_offset: int) -> tuple:
    """__TEXT セグメントの vmaddr と fileoff を返す"""
    magic = struct.unpack_from('<I', data, macho_offset)[0]
    if magic != MH_MAGIC_64:
        raise ValueError(f"Not Mach-O 64 at offset {macho_offset:#x}")
    ncmds = struct.unpack_from('<I', data, macho_offset + 16)[0]
    cursor = macho_offset + MACH_HEADER_64_SIZE
    for _ in range(ncmds):
        cmd, cmdsize = struct.unpack_from('<II', data, cursor)
        if cmd == LC_SEGMENT_64:
            segname = data[cursor+8:cursor+24].split(b'\x00')[0].decode('ascii')
            vmaddr, vmsize, fileoff, filesize = struct.unpack_from('<QQQQ', data, cursor + 24)
            if segname == '__TEXT':
                return vmaddr, fileoff
        cursor += cmdsize
    raise ValueError("__TEXT segment not found")


def parse_macho(filepath: str) -> tuple:
    """Mach-O / Universal Binary を読み込み、(data, va_to_file_offset_delta) を返す"""
    with open(filepath, 'rb') as f:
        data = f.read()
    magic_be = struct.unpack_from('>I', data, 0)[0]
    magic_le = struct.unpack_from('<I', data, 0)[0]

    if magic_be == FAT_MAGIC:
        nfat_arch = struct.unpack_from('>I', data, 4)[0]
        print(f"[*] Universal Binary ({nfat_arch} slices)")
        for i in range(nfat_arch):
            off = 8 + i * 20  # sizeof(fat_arch) = 20
            cputype, cpusubtype, slice_offset, slice_size, align = struct.unpack_from('>IIIII', data, off)
            print(f"    Slice {i}: cputype=0x{cputype:08X} offset=0x{slice_offset:X} size=0x{slice_size:X}")
            if cputype == CPU_X86_64:
                print(f"    → x86_64 使用 (offset=0x{slice_offset:X})")
                vmaddr, fileoff = find_text_segment(data, slice_offset)
                # VA→ファイルオフセット変換定数: file_pos = VA - delta
                delta = vmaddr - fileoff - slice_offset
                print(f"[*] __TEXT: vmaddr=0x{vmaddr:X} fileoff=0x{fileoff:X}")
                print(f"[*] VA→ファイルオフセット変換定数 = 0x{delta:X}")
                print(f"    (file_pos = VA - 0x{delta:X})")
                return data, delta
        raise ValueError("x86_64 slice not found")

    elif magic_le == MH_MAGIC_64:
        vmaddr, fileoff = find_text_segment(data, 0)
        delta = vmaddr - fileoff
        print(f"[*] Mach-O 64: vmaddr=0x{vmaddr:X}")
        return data, delta

    else:
        raise ValueError("Unsupported file format")

# ============================================================
# データ読み取り
# ============================================================
def read_int32_array(data: bytes, base: int, va: int, count: int) -> list:
    off = va - base
    return [struct.unpack_from('<i', data, off + i * 4)[0] for i in range(count)]


# ============================================================
# Stage 1: 多項式復号 
# ============================================================
def decrypt_polynomial(data: bytes, base: int, count: int,
                       va_A: int, va_B: int, va_C: int,
                       va_D: int, va_E: int) -> bytes:
    A = read_int32_array(data, base, va_A, count)
    B = read_int32_array(data, base, va_B, count)
    C = read_int32_array(data, base, va_C, count)
    D = read_int32_array(data, base, va_D, count)
    E = read_int32_array(data, base, va_E, count)

    result = bytearray(count)
    for i in range(count):
        diff = (A[i] - B[i]) & 0xFFFFFFFF
        if diff >= 0x80000000:
            diff -= 0x100000000
        if C[i] == 0:
            q = 0
        else:
            q = int(diff / C[i])  # truncation toward zero
        result[i] = ((q ^ D[i]) - E[i]) & 0xFF
    return bytes(result)


# ============================================================
# Stage 2: hex2str
# ============================================================
def hex_to_binary(hex_bytes: bytes) -> bytes:
    return bytes.fromhex(hex_bytes.decode('ascii'))


# ============================================================
# Stage 3: カスタム Base64 デコード
# ============================================================
def build_base64_map(alphabet: bytes) -> bytes:
    seen = set()
    result = bytearray()
    for ch in alphabet:
        if ch not in seen:
            seen.add(ch)
            result.append(ch)
    return bytes(result)

def base64_decode_custom(encoded: bytes, custom_alphabet: bytes) -> bytes:
    std_alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
    table = bytes.maketrans(custom_alphabet, std_alphabet)
    return base64.b64decode(encoded.translate(table) + b"==")


# ============================================================
# テーブル定義
# ============================================================
DECRYPT_BLOCKS = [
    {"id": 1,  "label": "Initial Command (system実行)",
     "count": 0x80,
     "A": 0x1000064C0, "B": 0x100006CC0, "C": 0x100006AC0,
     "D": 0x1000068C0, "E": 0x1000066C0},
    {"id": 2,  "label": "Path/Command #2",
     "count": 0x24,
     "A": 0x100008A40, "B": 0x100008C80, "C": 0x100008BF0,
     "D": 0x100008B60, "E": 0x100008AD0},
    {"id": 3,  "label": "Home-relative Path #1",
     "count": 0x1C,
     "A": 0x100008720, "B": 0x1000088E0, "C": 0x100008870,
     "D": 0x100008800, "E": 0x100008790},
    {"id": 4,  "label": "Home-relative Path #2",
     "count": 0x0C,
     "A": 0x100008950, "B": 0x100008A10, "C": 0x1000089E0,
     "D": 0x1000089B0, "E": 0x100008980},
    {"id": 5,  "label": "Home-relative Path #3",
     "count": 0x20,
     "A": 0x10000FE30, "B": 0x100010030, "C": 0x10000FFB0,
     "D": 0x10000FF30, "E": 0x10000FEB0},
    {"id": 6,  "label": "C2 Polling Command/Path",
     "count": 0x86,
     "A": 0x100006EC0, "B": 0x100007740, "C": 0x100007520,
     "D": 0x100007300, "E": 0x1000070E0},
    {"id": 7,  "label": "C2 Response Prefix",
     "count": 0x68,
     "A": 0x100007960, "B": 0x100007FE0, "C": 0x100007E40,
     "D": 0x100007CA0, "E": 0x100007B00},
    {"id": 8,  "label": "Compare Token B (memcmp)",
     "count": 0x10,
     "A": 0x100008310, "B": 0x100008410, "C": 0x1000083D0,
     "D": 0x100008390, "E": 0x100008350},
    {"id": 9,  "label": "StartsWith Pattern A",
     "count": 0x14,
     "A": 0x100008180, "B": 0x1000082C0, "C": 0x100008270,
     "D": 0x100008220, "E": 0x1000081D0},
    {"id": 10, "label": "StartsWith Pattern B",
     "count": 0x16,
     "A": 0x100008450, "B": 0x1000085D0, "C": 0x100008570,
     "D": 0x100008510, "E": 0x1000084B0},
    {"id": 11, "label": "Compare Token A (memcmp)",
     "count": 0x0C,
     "A": 0x100008630, "B": 0x1000086F0, "C": 0x1000086C0,
     "D": 0x100008690, "E": 0x100008660},
    {"id": 12, "label": "Command Prefix (3x system)",
     "count": 0x10,
     "A": 0x100008D10, "B": 0x100008E10, "C": 0x100008DD0,
     "D": 0x100008D90, "E": 0x100008D50},
]

BASE64_TABLE = {
    "count": 0x596,  # asm: mov esi, 0x596 (be 96 05 00 00)
    "A": 0x100008E50, "B": 0x10000E7D0, "C": 0x10000D170,
    "D": 0x10000BB10, "E": 0x10000A4B0,
}


def safe_str(b: bytes) -> str:
    try:
        return b.decode('utf-8')
    except UnicodeDecodeError:
        return b.decode('latin-1')


def main():
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <path_to_mainhelper_binary>")
        sys.exit(1)

    filepath = sys.argv[1]
    if not os.path.exists(filepath):
        print(f"Error: {filepath} not found"); sys.exit(1)

    data, base = parse_macho(filepath)
    print(f"File: {filepath} ({len(data)} bytes)")
    print("=" * 72)

    # --- Base64 アルファベット ---
    bt = BASE64_TABLE
    b64_hex = decrypt_polynomial(data, base, bt["count"],
                                  bt["A"], bt["B"], bt["C"], bt["D"], bt["E"])
    b64_raw = hex_to_binary(b64_hex)
    b64_map = build_base64_map(b64_raw)
    print(f"\n[*] Base64アルファベット ({len(b64_raw)} bytes, {len(b64_map)} unique)")
    print(f"    {safe_str(b64_raw)[:100]}...")

    # --- Block 1 (system実行) ---
    b1 = DECRYPT_BLOCKS[0]
    b1_hex = decrypt_polynomial(data, base, b1["count"],
                                b1["A"], b1["B"], b1["C"], b1["D"], b1["E"])
    b1_raw = hex_to_binary(b1_hex)
    b1_map = build_base64_map(b1_raw)
    b1_final = base64_decode_custom(b64_raw, b1_map)
    print(f"\n{''*72}")
    print(f"[Block  1] {b1['label']}")
    print(f"    Hex:     {safe_str(b1_hex)}")
    print(f"    Decoded: {safe_str(b1_raw)}")
    print(f"    B64 Map: {len(b1_map)} entries")
    print(f"    ★ system(): {safe_str(b1_final)}")

    # --- Block 2〜12 ---
    for blk in DECRYPT_BLOCKS[1:]:
        hex_str = decrypt_polynomial(data, base, blk["count"],
                                     blk["A"], blk["B"], blk["C"], blk["D"], blk["E"])
        raw = hex_to_binary(hex_str)
        decoded = base64_decode_custom(raw, b1_map)
        print(f"\n{''*72}")
        print(f"[Block {blk['id']:2d}] {blk['label']}")
        print(f"    Hex:     {safe_str(hex_str)}")
        print(f"    Raw:     {safe_str(raw)}")
        print(f"    ★ Result: {safe_str(decoded)}")

    print(f"\n{'='*72}")
    print("[*] Done")


if __name__ == "__main__":
    main()

実行結果を記載します。
スクリーンショット 2026-02-18 21.14.24.png

スクリーンショット 2026-02-18 21.16.39.png

osascriptやビーコンのコマンド機能が確認できます。
C2の情報などもわかります。Darktraceさんの記事のC2のレンジと一致していますね。
ここまで来ればある程度マルウェアの挙動を推測できるようになりますね。

実は...

このスクリプトはAIを利用して作成しました。BinaryNinjaのMCPとClaude Codeを利用しています。
最初の復号の流れを理解すれば、あとはdataのアドレスをいい感じにしてParseスクリプト書いてくださいで一発でした。AIの力すごいですね。

環境構築は以下を参考にしてください。

VM環境とAIくんがいる環境は分けましょう。

IOC

取り扱いには十分注意してください。

C2アドレス:
45[.]94[.]47[.]204

sha256 hash:
b8821699fb0a1d51914bf55b1d7afc0582b13d4d3128e7a36a3331d1c066de23
477934fcb24f7dc9bcf2b786ba42f47c81f70de62be89c4cd83184e071e5d2ae
8fe2303dca31bf0a29fc2b4873a287eafbc08f90f59acbace00ac72890c6f704

まとめ

今回はAtomic Stealerのビーコンの解析を行いました。難読化されている機能やC2情報を復号して、マルウェアの挙動がある程度推測できるようになりました。
Blue Teamの方向けにこのParse Scriptを利用して業務の手助けになれば嬉しいと思い、記事を公開いたしました。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?