# WireLurker Detector — Admin Guide

Audience: security engineers, incident responders, reverse engineers, and developers who need to integrate, fork, or build on top of the detector. This guide covers architecture, detection heuristics, operational integration, limitations, and the source-level internals of both scripts.

---

## 1. Executive Summary

WireLurker Detector is a two-file Python 2 toolkit published by Palo Alto Networks in November 2014 as part of the disclosure of the **WireLurker** malware family — the first campaign observed targeting non-jailbroken iOS devices via USB pairing from infected Macs.

| Attribute | Value |
|---|---|
| Repository | <https://github.com/PaloAltoNetworks/WireLurkerDetector> |
| Pinned commit | `0957460` (2022-08-15 — cosmetic / metadata change only) |
| Codebase size | 2 Python files, ~6.1 KB total |
| Language | Python 2 (uses `print` statement syntax) |
| Runtime OS | macOS (Darwin) — enforced at runtime |
| External deps | `pyDes` (decryptor only); stdlib otherwise |
| License | ISC-style, permissive |
| Maturity | Historical: published with the 2014 disclosure; later commits are metadata changes. |
| Threat model | Signature-based detection of the exact 2014 WireLurker IOCs on disk. |

**Operational role.** The detector is a read-only scanner. It enumerates known-bad paths, walks `/Applications` and `~/Applications` for `.app` bundles, applies three file-level heuristics, and prints findings. It performs no network I/O, no quarantine, no remediation. `decryptor.py` is a research helper that decrypts captured C&C payloads specific to WireLurker variant C.

**Why it matters today.** It is not a general antivirus and should not be deployed as one. It is still useful as (a) a forensic probe on legacy macOS systems during incident response, (b) a reference implementation of 2014-era macOS malware detection heuristics, and (c) a teaching artifact for reverse-engineering and IR coursework.

---

## 2. Architecture

### 2.1 High-level shape

```
┌──────────────────────────────────────────────────────────┐
│                   WireLurker Detector                    │
│                                                          │
│  ┌────────────────────────────┐  ┌────────────────────┐  │
│  │  WireLurkerDetectorOSX.py  │  │   decryptor.py     │  │
│  │  (scanner — runs on macOS) │  │  (research helper) │  │
│  │                            │  │                    │  │
│  │  main()                    │  │  main()            │  │
│  │   ├─ scan_files(MAL)       │  │   └─ pyDes DES-ECB │  │
│  │   ├─ scan_files(SUS)       │  │      + base64      │  │
│  │   └─ scan_app()            │  │                    │  │
│  │        └─ is_app_infected()│  │                    │  │
│  │             └─ is_file_    │  │                    │  │
│  │                hidden()    │  │                    │  │
│  └────────────────────────────┘  └────────────────────┘  │
│            │                              │              │
│            ▼                              ▼              │
│   Local filesystem (read only)   stdin base64 payload    │
└──────────────────────────────────────────────────────────┘
```

The two scripts are completely independent. They share no code and are invoked separately.

### 2.2 Data-flow — `WireLurkerDetectorOSX.py`

```
 argv (none)
   │
   ▼
 platform.system() == "Darwin" ?  ── no ──► stderr, exit -1
   │ yes
   ▼
 scan_files(MALICIOUS_FILES)   ── found ──► print "[!] Found malicious file: …"
   │
   ▼
 scan_files(SUSPICIOUS_FILES)  ── found ──► print "[!] Found suspicious file: …"
   │
   ▼
 scan_app()
   │
   ├── os.walk("/Applications")
   ├── os.walk(expanduser("~/Applications"))
   │    │
   │    ▼
   │    for each root ending in ".app":
   │        is_app_infected(root)
   │            ├── plistlib.readPlist(Info.plist)
   │            ├── heuristic 1: hidden executable swap
   │            ├── heuristic 2: hidden trojan loader
   │            └── heuristic 3: embedded installer
   │
   ▼
 any findings ?  ── yes ──► print WARNING, exit 1
   │ no
   ▼
 print clean, exit 0
```

All three passes are linear and synchronous. There is no threading, no caching, no persistence. A single invocation reads the filesystem, prints results, and returns.

### 2.3 Data-flow — `decryptor.py`

```
 argv[1] = captured base64 message
   │
   ▼
 session_key = str(sum(int(c) for c in argv[1][:10]))
   │
   ▼
 key = session_key + "dksyel"
   │
   ▼
 ciphertext = base64.b64decode(argv[1][10:])
   │
   ▼
 pyDes.des(key, ECB, PAD_PKCS5).decrypt(ciphertext)
   │
   ▼
 print plaintext
```

The key derivation is deterministic given the message prefix, which means anyone who captures WireLurker-C traffic can recover plaintext without a separate key exchange — a deliberate weakness the Palo Alto Networks researchers exposed.

### 2.4 Component inventory

| Component | File | Role |
|---|---|---|
| `main()` (detector) | `WireLurkerDetectorOSX.py:119` | Orchestrates the three-pass scan and prints results. |
| `scan_files(paths)` | `WireLurkerDetectorOSX.py:52` | Returns paths from the input list that exist on disk. |
| `is_file_hidden(f)` | `WireLurkerDetectorOSX.py:62` | True if the file has the BSD `UF_HIDDEN` flag; falls back to parsing `ls -ldO`. |
| `is_app_infected(root)` | `WireLurkerDetectorOSX.py:82` | Applies the three `.app`-bundle infection heuristics. |
| `scan_app()` | `WireLurkerDetectorOSX.py:107` | Walks `/Applications` and `~/Applications`, calls `is_app_infected` on every `.app` bundle. |
| `MALICIOUS_FILES` | `WireLurkerDetectorOSX.py:23` | 18 hardcoded absolute paths. |
| `SUSPICIOUS_FILES` | `WireLurkerDetectorOSX.py:44` | 4 hardcoded paths (2 absolute, 2 user-home-relative). |
| `main()` (decryptor) | `decryptor.py:187` | Parses one argv, derives the key, decrypts, prints. |

---

## 3. System Requirements

### 3.1 Minimums

| Dimension | Requirement |
|---|---|
| OS | macOS / OS X. The detector hard-exits on anything else. |
| Architecture | Any — Intel, Apple Silicon. No native code. |
| Python | 2.7+. 2.6 may work but is untested. Python 3 does **not** work (parse-time `SyntaxError`). |
| Disk | Negligible (~6 KB for the scripts). Log files grow with output size; typical clean scan is a few KB. |
| RAM | Negligible. The scan holds one list of findings in memory. |
| Filesystem | APFS and HFS+. Both expose `UF_HIDDEN` via `os.stat(f).st_flags`. |
| Network | None for the detector. The decryptor is offline. |

### 3.2 Optional

| Item | When needed |
|---|---|
| `pyDes` Python 2 package | Only to run `decryptor.py`. |
| `sudo` | Not required, but recommended so that the scan can read root-owned LaunchDaemons and SIP-restricted paths. |
| `bash` + coreutils | Only for the `ls -ldO` fallback of `is_file_hidden()` on platforms without `stat.UF_HIDDEN`. macOS always has both. |

### 3.3 Anti-requirements (things that block use)

- macOS 12.3 or later with no manually installed Python 2 — the interpreter was removed.
- Linux or Windows — the detector refuses to run.
- Python 3 — fails at import/parse time on the `print` statements.

---

## 4. Configuration Reference

There is **no configuration**. The detector has:

- No command-line arguments.
- No environment variables.
- No config file.
- No runtime flags.

Every behavior is a constant in source:

| Constant | Location | Meaning |
|---|---|---|
| `MALICIOUS_FILES` | `WireLurkerDetectorOSX.py:23` | Paths whose presence triggers a malicious finding. |
| `SUSPICIOUS_FILES` | `WireLurkerDetectorOSX.py:44` | Paths whose presence triggers a suspicious finding. |
| `__version__` | `WireLurkerDetectorOSX.py:11` | `'1.2.0'` — displayed in the banner. |
| Heuristic filenames | `is_app_infected` in `WireLurkerDetectorOSX.py:82` | `start.sh`, `FontMap1.cfg`, `appinstaller`, `infoplistab`, `CFBundleExecutable`. |

Customization requires a source fork.

> [!TIP]
> To extend the detector for an internal variant with additional IOCs, append to `MALICIOUS_FILES` / `SUSPICIOUS_FILES`. Keep paths absolute; user-relative paths must be wrapped in `os.path.join(expanduser('~'), ...)` for `scan_files()` to resolve them.

---

## 5. Deployment

### 5.1 Deployment model

The detector is a single-shot CLI scanner. There is no daemon, no scheduled service, no agent. Typical deployment options:

1. **Ad-hoc incident response.** Copy the `.py` to the target Mac, run once, read output, delete.
2. **Fleet scan via MDM.** Use Jamf / Kandji / Mosyle to push the script, execute it, and collect stdout.
3. **SSH sweep.** Loop over hosts and run the script remotely; capture output server-side.

### 5.2 Fleet scan (pattern)

```bash
for host in $(cat macs.txt); do
    ssh -n "admin@$host" \
        '/usr/bin/python2 - < /path/to/WireLurkerDetectorOSX.py' \
        > "scans/$host.log" 2>&1
done
```

> [!NOTE]
> Piping the script into `python2 -` avoids leaving the file on disk. Some endpoint-protection suites will still capture the command line; coordinate with your SOC before running.

### 5.3 Container / sandbox note

Running inside a macOS container or VM is fine. Running inside a Linux container is not — `platform.system()` returns `Linux` and the script exits with code `-1`. There is no escape hatch.

### 5.4 Distribution integrity

When packaging for internal distribution, pin a commit hash and store the SHA-256 of the script alongside it:

```bash
git rev-parse --short HEAD
# 0957460

shasum -a 256 WireLurkerDetectorOSX.py > WireLurkerDetectorOSX.py.sha256
```

```text
a0c… WireLurkerDetectorOSX.py
```

Verify on every host before executing.

### 5.5 Deployment checklist

- [ ] Target host runs macOS (Darwin).
- [ ] `python2` is installed and `python2 --version` prints 2.7.x.
- [ ] Script is pinned to a known commit and SHA-256 verified.
- [ ] Scan runs under `sudo` if root-only paths are in-scope.
- [ ] Output is captured to a file for audit trail.
- [ ] AV allowlist entry is in place (the IOC strings trigger false positives in some scanners).

---

## 6. Security Analysis

### 6.1 What the script does with privilege

| Action | Privilege needed | Notes |
|---|---|---|
| `os.path.exists(path)` for each IOC | Depends on path | On root-only paths (`/Library/LaunchDaemons/*`) plain user sees them if directory-read is allowed, but some plists require root. |
| `os.walk('/Applications')` | User-readable | Always works. |
| `os.walk('~/Applications')` | User-readable | Always works, missing dir handled silently. |
| `plistlib.readPlist(Info.plist)` | User-readable | Info.plist is world-readable in standard bundles. |
| `os.stat(f).st_flags` | User-readable | File flags do not require extra privilege. |
| `subprocess.Popen("ls -ldO …")` fallback | User-readable | Only runs when `UF_HIDDEN` is unavailable. |

The script **does not** write to disk, open sockets, modify permissions, or execute anything in the inspected bundles. Its worst-case side effect is CPU/IO load from the `.app` walk.

### 6.2 Robustness of the script itself

| Vector | Exposure | Mitigation |
|---|---|---|
| Shell fallback in `is_file_hidden()` | When `stat.UF_HIDDEN` is unavailable, the function falls back to `ls -ldO` run through a shell. On current macOS that branch is not reached. | A fork for older systems can use a list-argv `subprocess.Popen` with `shell=False` (see section 10). |
| Unpickling / eval | None — `plistlib.readPlist` parses XML plists, not pickle. | Nothing to do. |
| Denial of service | A symlink loop under `/Applications` could cause `os.walk` to spin. | `os.walk(followlinks=False)` is the default, which blocks the attack. |
| AV false positive | IOC strings trigger signature scanners. | Allowlist by hash. |

### 6.3 What the decryptor reveals

`decryptor.py` is a disclosure of WireLurker-C's session-key derivation algorithm. Publishing it showed how weak the malware's C&C encryption was. There is no ongoing operational risk in 2025 because the C&C infrastructure has long been defunct.

### 6.4 Secrets handling

None. The detector handles no secrets. The decryptor's "key" is derived from the ciphertext itself and is not a secret.

### 6.5 Network exposure

Zero. Neither script opens sockets.

---

## 7. Operations

### 7.1 Running the detector under `launchd` as a periodic job

If you want to run the detector on a schedule on a single macOS host, wrap it in a `launchd` plist:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
   "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.example.wirelurker-scan</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/python2</string>
        <string>/opt/wirelurker/WireLurkerDetectorOSX.py</string>
    </array>
    <key>StandardOutPath</key>
    <string>/var/log/wirelurker-scan.log</string>
    <key>StandardErrorPath</key>
    <string>/var/log/wirelurker-scan.err</string>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key><integer>3</integer>
        <key>Minute</key><integer>0</integer>
    </dict>
</dict>
</plist>
```

Save as `/Library/LaunchDaemons/com.example.wirelurker-scan.plist`, then:

```bash
sudo launchctl load -w /Library/LaunchDaemons/com.example.wirelurker-scan.plist
```

### 7.2 Log management

The detector writes only to stdout/stderr. A sensible rotation policy for `/var/log/wirelurker-scan.log` uses `newsyslog`:

```text
# /etc/newsyslog.d/wirelurker.conf
/var/log/wirelurker-scan.log  640  14  1024  *  JN
```

### 7.3 Parsing the output

Output is human-readable text. A minimal parser:

```python
import subprocess, re

proc = subprocess.Popen(['python2', 'WireLurkerDetectorOSX.py'],
                        stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
out, _ = proc.communicate()

malicious = re.findall(r'\[!\] Found malicious file: (.+)', out)
suspicious = re.findall(r'\[!\] Found suspicious file: (.+)', out)
infected_apps = re.findall(r'\[!\] Found infected application: (.+)', out)

if proc.returncode == 0:
    verdict = 'clean'
elif proc.returncode == 1:
    verdict = 'infected'
else:
    verdict = 'error'
```

There is no JSON / structured output mode.

### 7.4 Integration with SIEM

Because the script returns an exit code and writes plain text, any SIEM agent that can collect exit codes and logs can ingest it. Typical patterns:

- **Splunk UF** — add a scripted input pointing at `python2 /opt/wirelurker/WireLurkerDetectorOSX.py`, schedule daily.
- **Elastic Agent / Filebeat** — tail `/var/log/wirelurker-scan.log`, grok-parse the `[!]` lines.
- **osquery** — wrap the scan in a shell script and expose via `--extension`, returning findings as rows.

### 7.5 Alert-worthy lines

Treat any line matching these patterns as a P1 alert:

```regex
^\[\!\] Found malicious file: .+
^\[\!\] Found infected application: .+
```

Lines matching `Found suspicious file:` deserve investigation but are not conclusive — the `maiyadi` cache directory is the most common false positive.

### 7.6 Backup / restore

Nothing to back up — the tool holds no state. A Time Machine restore of a previously infected system can **reintroduce** the WireLurker artifacts (see community issue [#18](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/18)). Run the detector immediately after any restore from legacy backups.

---

## 8. Internals — Source-Level Reference

### 8.1 `scan_files(paths)` (`WireLurkerDetectorOSX.py:52`)

```python
def scan_files(paths):
    results = []
    for f in paths:
        if os.path.exists(f):
            results.append(f)
    return results
```

Linear, synchronous, no caching. `os.path.exists` follows symlinks; if a listed IOC path is a dangling symlink it will return `False` and miss. In practice WireLurker drops real files, not symlinks.

### 8.2 `is_file_hidden(f)` (`WireLurkerDetectorOSX.py:62`)

Tries `os.stat(f).st_flags & stat.UF_HIDDEN` when available. On platforms that lack `UF_HIDDEN`, it falls back to:

```python
proc = subprocess.Popen("ls -ldO '%s' | awk '{print $5}'" % f,
                        shell=True, stdout=subprocess.PIPE,
                        stderr=subprocess.STDOUT)
output = proc.stdout.read()
return output.find('hidden') != -1
```

Notes:

- When `UF_HIDDEN` is unavailable, the fallback builds a shell command from the path; a fork can switch to a list-argv call (see §6.2).
- The fallback column index (`$5`) is correct for macOS BSD `ls -ldO` output; on other Unixes the column differs or the flag isn't printed.
- `proc.communicate()` is called after `proc.stdout.read()`, which on Python 2 is safe but unusual — the canonical form would be `proc.communicate()[0]`.

### 8.3 `is_app_infected(root)` (`WireLurkerDetectorOSX.py:82`)

Three independent tests, `OR`-combined:

| Test | Condition |
|---|---|
| 1 | `UF_HIDDEN` is set on both `Contents/MacOS/<CFBundleExecutable>` AND `Contents/MacOS/<CFBundleExecutable>_` |
| 2 | `UF_HIDDEN` is set on both `Contents/Resources/start.sh` AND `Contents/Resources/FontMap1.cfg` |
| 3 | `Contents/MacOS/appinstaller` AND `Contents/Resources/infoplistab` both exist as files |

Any exception (plist parse, KeyError on `CFBundleExecutable`, OSError on stat) → returns `False`. This is deliberate: unparseable bundles are silently treated as clean to avoid flooding the report.

### 8.4 `scan_app()` (`WireLurkerDetectorOSX.py:107`)

```python
for target in ['/Applications', expanduser('~/Applications')]:
    for root, __, __ in os.walk(target):
        if root.lower().endswith('.app'):
            if is_app_infected(root):
                infected_apps.append(root)
```

Case-insensitive suffix match on `.app`. `os.walk` emits each directory once, so a bundle at any depth is visited exactly once. Nested `.app` bundles (rare, e.g. plugins inside a top-level app) are all checked independently.

### 8.5 `main()` banner and exit logic (`WireLurkerDetectorOSX.py:119`)

```python
print 'WireLurker Detector (version %s)' % __version__
print __copyright__
```

Uses Python-2 `print` statements throughout — the root cause of every Python-3 compatibility bug in the tracker. The function returns `0` when all three finding lists are empty and `1` otherwise; `sys.exit(-1)` is used on non-Darwin.

### 8.6 `decryptor.py:main()` (`decryptor.py:187`)

Key takeaways:

| Line | What | Implication |
|---|---|---|
| `decryptor.py:194` | `sum([int(c) for c in original_data[:10]])` | First 10 chars must all be decimal digits; any non-digit raises `ValueError`. |
| `decryptor.py:195` | `key = session_key + 'dksyel'` | Suffix is a constant. The literal string `dksyel` is the static half of the key. |
| `decryptor.py:199` | `pyDes.des(key, pyDes.ECB, padmode=pyDes.PAD_PKCS5)` | DES-ECB with PKCS#5 padding. Key length is typically 7–9 bytes (digits of the sum plus `dksyel`); pyDes truncates/pads internally. |
| `decryptor.py:201` | `print plaintext` | Python-2 print statement again — fails under Python 3. |

The algorithm is a toy cipher by modern standards: the entire key is recoverable from the prefix of the ciphertext, so "encryption" here is really obfuscation.

### 8.7 Control-flow summary

```
WireLurkerDetectorOSX.py  :  ~165 lines, 0 classes, 4 functions
decryptor.py              :  ~40 lines,  0 classes, 1 function
```

No classes. No decorators. No generators. No third-party imports in the detector. One third-party import (`pyDes`) in the decryptor.

### 8.8 Cyclomatic complexity (qualitative)

All functions are straight-line or shallowly-branched. The highest-complexity function is `is_app_infected`, which nests a `try/except` over three sequential `if` blocks — still trivial.

---

## 9. Code Quality and Patterns

| Aspect | Observation |
|---|---|
| Style | PEP-8-ish. Spaces for indentation (4 spaces). Minimal docstrings (one module-level per file). |
| Error handling | `try/except Exception: return False` in `is_app_infected` and `is_file_hidden`. A best-effort pattern: a bundle that raises an error is reported as not infected (see section 11). |
| Tests | None. No `tests/`, no `conftest.py`, no CI configuration. |
| CI | None — no `.github/workflows/`, no Travis/Circle config. |
| Packaging | None — no `setup.py`, no `pyproject.toml`, not on PyPI. |
| Typing | None — pre-PEP-484 Python 2 code. |
| Logging | `print` only. No `logging` module use. |
| i18n | English-only; WireLurker's original distribution vector was a Chinese app store, so some IOC paths (e.g. `com.maiyadi.appinstaller`) embed that locale. |

> [!NOTE]
> The code reads cleanly because the problem is small. Section 10 lists the upgrades a fork would make (argv flags, structured output, a test harness).

---

## 10. Security Hardening for Forks

If you are forking for internal use, these are the high-value upgrades:

1. **Port to Python 3.** Replace every `print x` with `print(x)`; replace `plistlib.readPlist` with `plistlib.load(open(p, 'rb'))`; decode `subprocess` output with `.decode('utf-8')`. This also fixes every open SyntaxError bug in the upstream tracker.
2. **Remove the shell fallback.** On any Python 2.6+ build, `stat.UF_HIDDEN` exists. Delete the `ls -ldO` branch entirely; if you want a fallback, use a list-argv `subprocess.Popen` with `shell=False`.
3. **Add a `--json` output flag.** Structured output makes SIEM ingestion trivial.
4. **Add a `--paths-file` option.** Externalize `MALICIOUS_FILES` / `SUSPICIOUS_FILES` so your SOC can update IOCs without editing source.
5. **Emit exit code `2` for "ran but could not read some paths".** The current code conflates "clean" with "clean but privilege-denied on half the LaunchDaemons".
6. **Drop the decryptor.** It's historical; ship it in a separate `legacy/` package if at all.
7. **Re-audit the IOC list.** Before running on a fleet, confirm none of the paths (`/etc/manpath.d/`, `/usr/local/ipcc/`) are legitimate on the macOS versions you manage.

---

## 11. Troubleshooting

### Error: `SyntaxError: Missing parentheses in call to 'print'`

```text
File "WireLurkerDetectorOSX.py", line 120
    print 'WireLurker Detector (version %s)' % __version__
```

**Cause.** Running with Python 3 (issues [#17](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/17), [#23](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/23), [#25](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/25)).
**Solution.** Use `python2`. If unavailable, run the community Python-3 port in a fork, or translate the five `print` statements and the `plistlib.readPlist` call by hand.
**Prevention.** Put a `#!/usr/bin/env python2` shebang check in your deployment script.

### Error: `ERROR: The script should only be run in a Mac OS X system.`

**Cause.** `platform.system()` did not return `Darwin`.
**Solution.** Run on real macOS. No override flag exists.
**Prevention.** Gate invocations on `uname -s`.

### Error: `ImportError: No module named pyDes`

**Cause.** Missing `pyDes` in the Python 2 interpreter being used for `decryptor.py`.
**Solution.** `pip2 install pyDes`.
**Prevention.** Pre-seed the interpreter environment with a frozen `requirements.txt` containing `pyDes==2.0.1`.

### Error: Prisma Cloud IaC scan fails on this repo

**Cause.** Community issue [#26](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/26) — a Prisma Cloud IaC scanner crashes when scanning the repo. The repo contains no IaC; this is a scanner-side bug, not a repo issue.
**Solution.** Exclude the repo from IaC scanning. Open a support ticket with the scanner vendor.
**Prevention.** Configure IaC scan include-paths explicitly.

### Error: Avast flags `WireLurkerDetectorOSX.py`

**Cause.** Issue [#19](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/19) — signature scanners fire on the embedded IOC strings.
**Solution.** Allowlist by SHA-256.
**Prevention.** Store the hash in your configuration management system.

### Error: `curl: (60) SSL certificate problem`

**Cause.** Issue [#24](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/24) — outdated CA bundle on vintage macOS.
**Solution.** Update `curl` and system roots, or side-load the script from a modern machine.
**Prevention.** Prepare signed, hash-verified media in advance.

### Error: script hangs during the `.app` scan phase

**Cause.** Many `.app` bundles on the target host; `os.walk` is I/O-bound.
**Solution.** Wait. Use `sudo fs_usage -f filesys python2` to confirm progress.
**Prevention.** Run scans during maintenance windows.

### Error: `plistlib.InvalidFileException` in one bundle

**Cause.** A malformed `Info.plist`. The exception is swallowed — the bundle is marked "not infected".
**Solution.** Manually inspect with `plutil -convert xml1` if you need certainty.
**Prevention.** None at the tool level.

### Error: Time Machine restore reintroduces WireLurker artifacts

**Cause.** Issue [#18](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/18) — backups contain pre-cleanup state.
**Solution.** Exclude known infection paths from the restore, then re-scan.
**Prevention.** After confirmed cleanup, rotate or prune backups that contain infected snapshots.

### Error: `decryptor.py` raises `ValueError: invalid literal for int()`

**Cause.** The first 10 characters of the supplied message are not all decimal digits.
**Solution.** Verify the captured payload format — WireLurker-C prefixes with 10 decimal digits that seed the key. A non-conforming input is not a WireLurker-C message.
**Prevention.** Capture complete, unmodified C&C payloads.

### Error: exit code `-1` with no output

**Cause.** The interpreter failed to load the script (syntax/imports) before the guard prints an error.
**Solution.** Run `python2 -m py_compile WireLurkerDetectorOSX.py` to surface the underlying error.
**Prevention.** CI-verify the script parses under the Python version you deploy.

### Error: `os.walk` follows a symlink loop

**Cause.** An attacker with local write access planted a symlink loop under `/Applications`.
**Solution.** `os.walk(followlinks=False)` is the default — verify you are not patching the argument.
**Prevention.** Keep the default. Audit patches that change it.

### Error: scan returns clean, but user-reported symptoms persist

**Cause.** The detector only sees 2014-era WireLurker IOCs. Modern macOS malware is not covered.
**Solution.** Escalate to a full IR workflow using modern tools (KnockKnock, BlockBlock, EDR).
**Prevention.** Do not use this tool as a sole endpoint defense.

### Error: `python2: command not found` on macOS 12.3+

**Cause.** Apple removed system Python 2.
**Solution.** Install from source (see `python.org/ftp/python/2.7.18`) or run in a VM with an older macOS.
**Prevention.** Maintain a dedicated forensic VM image.

---

## 12. Integrations

### 12.1 Jamf / MDM script

Wrap the detector as a Jamf extension attribute returning `<result>clean</result>` or `<result>infected</result>`:

```bash
#!/bin/sh
OUTPUT=$(/usr/local/bin/python2 /opt/wirelurker/WireLurkerDetectorOSX.py 2>&1)
if echo "$OUTPUT" | grep -q 'Your OS X system isn'\''t infected'; then
    echo "<result>clean</result>"
else
    echo "<result>infected</result>"
fi
```

### 12.2 osquery external table

Expose findings as rows in an osquery extension (Python 2 + `osquery-python`). Each IOC path becomes a row; the `infected` column is a boolean.

### 12.3 Incident-response playbook snippet

Add a single step to your IR runbook for any legacy macOS host:

```text
Step 4: Run WireLurker Detector (historical 2014 IOCs)
    host$ sudo python2 /opt/forensics/WireLurkerDetectorOSX.py \
              > /var/log/ir/$(hostname)-wirelurker.log
    Verify exit code. Capture log. Attach to ticket.
```

### 12.4 CI check for a fork

If you maintain a fork, a minimum CI step:

```yaml
name: smoke
on: [push]
jobs:
  run:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Python 2
        run: brew install python@2 || true
      - name: Compile check
        run: python2 -m py_compile WireLurkerDetectorOSX.py decryptor.py
```

macOS runners no longer ship Python 2; the fork should port to Python 3 before relying on CI.

---

## 13. Known Issues and Potential Improvements

### 13.1 Known issues (from the upstream tracker)

| Issue | Status | Summary |
|---|---|---|
| [#12](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/12) | Open | "This is NOT CROSS PLATFORM" — the OSX script only covers macOS. By design; see `HOWTO-Windows.md` for the Windows logic. |
| [#17](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/17) | Closed | `SyntaxError` under Python 3 on line 75 (`except Exception as e`). |
| [#18](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/18) | Open | Time Machine backups reintroduce WireLurker samples on restore. |
| [#19](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/19) | Closed | Avast false positive on the script itself. |
| [#20](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/20) | Closed | Typo in script output. |
| [#23](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/23) | Closed | SyntaxError under Python > 2.7. |
| [#24](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/24) | Open | `curl` SSL certificate error on old systems. |
| [#25](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/25) | Open | SyntaxError invalid syntax (Python 3 root cause). |
| [#26](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/26) | Open | Prisma Cloud IaC scan error (external scanner bug). |
| [#27](https://github.com/PaloAltoNetworks/WireLurkerDetector/issues/27) | Open | Community health assessment — repo lacks `CONTRIBUTING.md`, issue templates, etc. |

### 13.2 Potential improvements

1. **Python 3 migration.** Single highest-impact change. Closes five bug reports.
2. **JSON / SARIF output.** Makes automated ingestion trivial.
3. **Pluggable IOC files.** External `iocs.yaml` read at runtime.
4. **Hash-based file matching.** Current checks are path-based; adding SHA-256 or MD5 comparison against known WireLurker binary hashes would reduce false negatives from renamed artifacts.
5. **Parallel `.app` traversal.** `multiprocessing.Pool` over the bundle list would cut scan time on fleets with many installed apps.
6. **Dry-run `--cleanup` flag.** Print a proposed `rm` list for user review. Still requires human confirmation per the project's own `HOWTO-Windows.md` guidance.
7. **Unit tests.** Start with a fixture directory emulating `/Applications/Infected.app/Contents/...` and assert on `is_app_infected`.

### 13.3 Known non-issues

- The script does not check `/System/Volumes/Data/...` on sealed system volumes (macOS 10.15+). Those paths are canonicalized under the virtualized `/` root, so the existing absolute paths still resolve correctly via the firmlink layer.

---

## 14. Contributing

Since 2014 the repository has received metadata changes only. If you plan to contribute:

1. Open an issue first describing what you plan to change.
2. Match the existing code style (PEP-8, 4-space indent, minimal docstrings).
3. Do not introduce new dependencies beyond `pyDes`.
4. For any behavioral change to detection logic, attach a test fixture and a before/after output comparison.

The license is permissive; forks are common and legitimate.

---

## 15. Quick Reference

### 15.1 Files

| File | Role |
|---|---|
| `WireLurkerDetectorOSX.py` | Main detector. |
| `decryptor.py` | WireLurker-C C&C decryptor. |
| `HOWTO-Windows.md` | Text spec for Windows detection. |
| `README.md` | Upstream user-facing instructions. |
| `LICENSE` | ISC-style license. |
| `.gitignore` | Ignore rules (minimal). |

### 15.2 Invocation

| Script | Command |
|---|---|
| Detector | `python2 WireLurkerDetectorOSX.py` |
| Decryptor | `python2 decryptor.py '<base64 payload>'` |

### 15.3 Exit codes

| Code | Meaning |
|---|---|
| `0` | Clean |
| `1` | Indicators found |
| `-1` | Wrong OS, or interpreter/import failure |

### 15.4 Key constants

| Constant | Location | Count |
|---|---|---|
| `MALICIOUS_FILES` | `WireLurkerDetectorOSX.py:23` | 18 paths |
| `SUSPICIOUS_FILES` | `WireLurkerDetectorOSX.py:44` | 4 paths |
| Heuristic files | `is_app_infected` | `start.sh`, `FontMap1.cfg`, `appinstaller`, `infoplistab` |

### 15.5 External references

- Palo Alto Networks disclosure: <http://researchcenter.paloaltonetworks.com/2014/11/wirelurker-new-era-os-x-ios-malware/>
- Windows variant: <http://researchcenter.paloaltonetworks.com/2014/11/wirelurker-windows/>
- Community Windows cleaner: <https://github.com/ltfish/WireLurkerCleaner>
- Community Windows detector: <https://github.com/kaustubhsant/WireLurkerDetector>

---

_Generated May 2026 from PaloAltoNetworks/WireLurkerDetector at 0957460. AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it. Unofficial, AI-generated guide for a Palo Alto Networks open-source repository; not reviewed by Palo Alto Networks._
