# Service Realism Audit

> Live-tested against `192.168.1.200` (omega-decky, full-audit.ini).
> Every result below is from an actual `nc` or `nmap` probe, not code reading.

---

## nmap -sV Summary

```
21/tcp   ftp           vsftpd (before 2.0.8) or WU-FTPD   ← WRONG: banner says "Twisted 25.5.0"
23/tcp   telnet        (unrecognized — Cowrie)
25/tcp   smtp          Postfix smtpd                       ✓
80/tcp   http          Apache httpd 2.4.54 ((Debian))      ✓ BUT leaks Werkzeug
110/tcp  pop3          (unrecognized)
143/tcp  imap          (unrecognized)
389/tcp  ldap          Cisco LDAP server
445/tcp  microsoft-ds                                      ✓
1433/tcp ms-sql-s?     (partially recognized)
1883/tcp mqtt                                              ✓
2375/tcp docker        Docker 24.0.5                       ✓
3306/tcp mysql         MySQL 5.7.38-log                    ✓
3389/tcp ms-wbt-server xrdp
5060/tcp sip           SIP endpoint; Status: 401 Unauthorized ✓
5432/tcp postgresql?   (partially recognized)
5900/tcp vnc           VNC protocol 3.8                   ✓
6379/tcp redis?        (partially recognized)
6443/tcp (unrecognized) — K8s not responding at all
9200/tcp wap-wsp?      (completely unrecognized — ES)
27017/tcp mongod?      (partially recognized)
502/tcp  CLOSED        — Conpot Modbus not on this port
```

---

## Service-by-Service

---

### SMTP — port 25

**Probe:**
```
220 omega-decky ESMTP Postfix (Debian/GNU)
250-PIPELINING / SIZE / VRFY / AUTH PLAIN LOGIN / ENHANCEDSTATUSCODES / 8BITMIME / DSN
250 2.1.0 Ok          ← MAIL FROM accepted
250 2.1.5 Ok          ← RCPT TO accepted for any domain ✓ (open relay bait)
354 End data with...  ← DATA opened
502 5.5.2 Error: command not recognized  ← BUG: each message line fails
221 2.0.0 Bye
```

**Verdict:** Banner and EHLO are perfect. DATA handler is broken — server reads the socket line-by-line but the asyncio handler dispatches each line as a new command instead of buffering until `.\r\n`. The result is every line of the email body gets a 502 and the message is silently dropped.

**Fixes needed:**
- Buffer DATA state until `\r\n.\r\n` terminator
- Return `250 2.0.0 Ok: queued as <8-hex-id>` after message accepted
- Don't require AUTH for relay (open relay is the point)
- Optionally: store message content so IMAP can serve it later

---

### IMAP — port 143

**Probe:**
```
* OK [omega-decky] IMAP4rev1 Service Ready
A1 OK CAPABILITY completed
A2 NO [AUTHENTICATIONFAILED] Invalid credentials    ← always, for any user/pass
A3 BAD Command not recognized                       ← LIST, SELECT, FETCH all unknown
```

**Verdict:** Login always fails. No mailbox commands implemented. An attacker who tries credential stuffing or default passwords (admin/admin, root/root) gets nothing and moves on. This is the biggest missed opportunity in the whole stack.

**Fixes needed:**
- Accept configurable credentials (default `admin`/`admin` or pulled from persona config)
- Implement: SELECT, LIST, FETCH, UID FETCH, SEARCH, LOGOUT
- Serve seeded fake mailboxes with bait content (see IMAP_BAIT.md)
- CAPABILITY should advertise `LITERAL+`, `SASL-IR`, `LOGIN-REFERRALS`, `ID`, `ENABLE`, `IDLE`
- Banner should hint at Dovecot: `* OK Dovecot ready.`

---

### POP3 — port 110

**Probe:**
```
+OK omega-decky POP3 server ready
+OK               ← USER accepted
-ERR Authentication failed    ← always
-ERR Unknown command          ← STAT, LIST, RETR all unknown
```

**Verdict:** Same problem as IMAP. CAPA only returns `USER`. Should be paired with IMAP fix to serve the same fake mailbox.

**Fixes needed:**
- Accept same credentials as IMAP
- Implement: STAT, LIST, RETR, DELE, TOP, UIDL, CAPA
- CAPA should return: `TOP UIDL RESP-CODES AUTH-RESP-CODE SASL USER`

---

### HTTP — port 80

**Probe:**
```
HTTP/1.1 403 FORBIDDEN
Server: Werkzeug/3.1.8 Python/3.11.2    ← DEAD GIVEAWAY
Server: Apache/2.4.54 (Debian)           ← duplicate Server header
```

**Verdict:** nmap gets the Apache fingerprint right, but any attacker who looks at response headers sees two `Server:` headers — one of which is clearly Werkzeug/Flask. The HTTP body is also a bare `<h1>403 Forbidden</h1>` with no Apache default page styling.

**Fixes needed:**
- Strip Werkzeug from Server header (set `SERVER_NAME` on the Flask app or use middleware to overwrite)
- Apache default 403 page should be the actual Apache HTML, not a bare `<h1>` tag
- Per-path routing for fake apps: `/wp-login.php`, `/wp-admin/`, `/xmlrpc.php`, etc.
- POST credential capture on login endpoints

---

### FTP — port 21

**Probe:**
```
220 Twisted 25.5.0 FTP Server     ← terrible: exposes framework
331 Guest login ok...
550 Requested action not taken    ← after login, nothing works
503 Incorrect sequence of commands: must send PORT or PASV before RETR
```

**Verdict:** Banner immediately identifies this as Twisted's built-in FTP server. No directory listing. PASV mode not implemented so clients hang. Real FTP honeypots should expose anonymous access with a fake directory tree containing interesting-sounding files.

**Fixes needed:**
- Override banner to: `220 (vsFTPd 3.0.3)` or similar
- Implement anonymous login (no password required)
- Implement PASV and at minimum LIST — return a fake directory with files: `backup.tar.gz`, `db_dump.sql`, `config.ini`, `credentials.txt`
- Log any RETR attempts (file name, client IP)

---

### MySQL — port 3306

**Probe:**
```
HANDSHAKE: ...5.7.38-log...
Version: 5.7.38-log
```

**Verdict:** Handshake is excellent. nmap fingerprints it perfectly. Always returns `Access denied` which is correct behavior. The only issue is the hardcoded auth plugin data bytes in the greeting — a sophisticated scanner could detect the static challenge.

**Fixes needed (low priority):**
- Randomize the 20-byte auth plugin data per connection

---

### PostgreSQL — port 5432

**Probe:**
```
R\x00\x00\x00\x0c\x00\x00\x00\x05\xde\xad\xbe\xef
```
That's `AuthenticationMD5Password` (type=5) with salt `0xdeadbeef`.

**Verdict:** Correct protocol response. Salt is hardcoded and static — `deadbeef` is trivially identifiable as fake.

**Fixes needed (low priority):**
- Randomize the 4-byte MD5 salt per connection

---

### MSSQL — port 1433

**Probe:** No response to standard TDS pre-login packets. Server drops connection immediately.

**Verdict:** Broken. TDS pre-login handler is likely mismatching the packet format we sent.

**Fixes needed:**
- Debug TDS pre-login response — currently silent
- Verify the hardcoded TDS response bytes are valid

---

### Redis — port 6379

**Probe:**
```
+OK           ← AUTH accepted (any password!)
$150
redis_version:7.2.7 / os:Linux 5.15.0 / uptime_in_seconds:864000 ...
*0            ← KEYS * returns empty
```

**Verdict:** Accepts any AUTH password (intentional for bait). INFO looks real. But `KEYS *` returns nothing — a real Redis exposed to the internet always has data. An attacker who gets `+OK` on AUTH will immediately run `KEYS *` or `SCAN 0` and leave when they find nothing.

**Fixes needed:**
- Add fake key-value store: session tokens, JWT secrets, cached user objects, API keys
- `KEYS *` → `["sessions:user:1234", "cache:api_key", "jwt:secret", "user:admin"]`
- `GET sessions:user:1234` → JSON user object with credentials
- `GET jwt:secret` → a plausible JWT signing key

---

### MongoDB — port 27017

**Probe:** No response to OP_MSG `isMaster` command.

**Verdict:** Broken or rejecting the wire protocol format we sent.

**Fixes needed:**
- Debug the OP_MSG/OP_QUERY handler

---

### Elasticsearch — port 9200

**Probe:**
```json
{"name":"omega-decky","cluster_uuid":"xC3Pr9abTq2mNkOeLvXwYA","version":{"number":"7.17.9",...}}
/_cat/indices → []   ← empty: dead giveaway
```

**Verdict:** Root response is convincing. But `/_cat/indices` returns an empty array — a real exposed ES instance has indices. nmap doesn't recognize port 9200 as Elasticsearch at all ("wap-wsp?").

**Fixes needed:**
- Add fake indices: `logs-2024.01`, `users`, `products`, `audit_trail`
- `/_cat/indices` → return rows with doc counts, sizes
- `/_search` on those indices → return sample documents (bait data: user records, API tokens)

---

### Docker API — port 2375

**Probe:**
```json
/version → {Version: "24.0.5", ApiVersion: "1.43", GoVersion: "go1.20.6", ...}  ✓
/containers/json → [{"Id":"a1b2c3d4e5f6","Names":["/webapp"],"Image":"nginx:latest",...}]
```

**Verdict:** Version response is perfect. Container list is minimal (one hardcoded container). No `/images/json` data, no exec endpoint. An attacker will immediately try `POST /containers/webapp/exec` to get RCE.

**Fixes needed:**
- Add 3-5 containers with realistic names/images: `db` (postgres:14), `api` (node:18-alpine), `redis` (redis:7)
- Add `/images/json` with corresponding images
- Add exec endpoint that captures the command and returns `{"Id":"<random>"}` then a fake stream

---

### SMB — port 445

**Probe:** SMB1 negotiate response received (standard `\xff\x53\x4d\x42r` header).

**Verdict:** Impacket SimpleSMBServer responds. nmap IDs it as `microsoft-ds`. Functional enough for credential capture.

---

### VNC — port 5900

**Probe:**
```
RFB 003.008   ✓
```

**Verdict:** Correct RFB 3.8 handshake. nmap fingerprints it as VNC protocol 3.8. The 16-byte DES challenge is hardcoded — same bytes every time.

**Fixes needed (trivial):**
- Randomize the 16-byte challenge per connection (`os.urandom(16)`)

---

### RDP — port 3389

**Probe:**
```
0300000b06d00000000000   ← X.224 Connection Confirm
(connection closed)
```

**Verdict:** nmap identifies it as "xrdp" which is correct enough. The X.224 CC is fine. But the server closes immediately after — no NLA/CredSSP negotiation, no credential capture. This is the single biggest missed opportunity for credential harvesting after SSH.

**Fixes needed:**
- Implement NTLM Type-1/Type-2/Type-3 exchange to capture NTLMv2 hashes
- Alternatively: send a fake TLS certificate then disconnect (many scanners fingerprint by the cert)

---

### SIP — port 5060

**Probe:**
```
SIP/2.0 401 Unauthorized
WWW-Authenticate: Digest realm="omega-decky", nonce="decnet0000", algorithm=MD5
```

**Verdict:** Functional. Correctly challenges with 401. But `nonce="decnet0000"` is a hardcoded string — a Shodan signature would immediately pick this up.

**Fixes needed (low effort):**
- Generate a random hex nonce per connection

---

### MQTT — port 1883

**Probe:** `CONNACK` with return code `0x05` (not authorized).

**Verdict:** Rejects all connections. For an ICS/water-plant persona, this should accept connections and expose fake sensor topics. See `ICS_SCADA.md`.

**Fixes needed:**
- Return CONNACK 0x00 (accepted)
- Implement SUBSCRIBE: return retained sensor readings for bait topics
- Implement PUBLISH: log any published commands (attacker trying to control plant)

---

### SNMP — port 161/UDP

Not directly testable without sudo for raw UDP send, but code review shows BER encoding is correct.

**Verdict:** Functional. sysDescr is a generic Linux string — should be tuned per archetype.

---

### LDAP — port 389

**Probe:** BER response received (code 49 = invalidCredentials).

**Verdict:** Correct protocol. nmap IDs it as "Cisco LDAP server" which is fine. No rootDSE response for unauthenticated enumeration.

---

### Telnet — port 23 (Cowrie)

**Probe:**
```
login: <IAC WILL ECHO>
Password: 
Login incorrect   ← for all tried credentials
```

**Verdict:** Cowrie is running but rejecting everything. Default Cowrie credentials (root/1234, admin/admin, etc.) should work. May be a config issue with the decky hostname or user database.

---

### Conpot — port 502

**Verdict:** Not responding on port 502 (Modbus TCP). Conpot may use a different internal port that gets NAT'd, or it's not configured for Modbus. Needs investigation.

---

## Bug Ledger

| # | Service    | Bug                                       | Severity |
|---|------------|-------------------------------------------|----------|
| 1 | SMTP       | DATA handler returns 502 for every line   | Critical |
| 2 | HTTP       | Werkzeug in Server header + bare 403 body | High     |
| 3 | FTP        | "Twisted 25.5.0" in banner                | High     |
| 4 | MSSQL      | No response to TDS pre-login              | High     |
| 5 | MongoDB    | No response to OP_MSG isMaster            | High     |
| 6 | K8s        | Not responding (TLS setup?)               | Medium   |
| 7 | IMAP/POP3  | Always rejects, no mailbox ops            | Critical (feature gap) |
| 8 | Redis      | Empty keyspace after AUTH success         | Medium   |
| 9 | SIP/VNC    | Hardcoded nonce/challenge                 | Low      |
| 10| MQTT       | Rejects all connections                   | High (ICS feature gap) |
| 11| Conpot     | No Modbus response                        | Medium   |
| 12| PostgreSQL | Hardcoded salt `deadbeef`                 | Low      |

---

## Related Plans

- [`SMTP_RELAY.md`](SMTP_RELAY.md) — Fix DATA handler, implement open relay persona
- [`IMAP_BAIT.md`](IMAP_BAIT.md) — Auth + seeded mailboxes + POP3 parity
- [`ICS_SCADA.md`](ICS_SCADA.md) — MQTT water plant, SNMP tuning, Conpot
- [`BUG_FIXES.md`](BUG_FIXES.md) — HTTP header leak, FTP banner, MSSQL, MongoDB, Redis keys

---

## Progress Updates

### [2026-04-10] ICS/SCADA & IMAP Bait Completion
The following infrastructure gaps from the Bug Ledger have been successfully resolved:
* **#7 (IMAP/POP3):** Both services now implement full protocol state machines (authentication, selection/transactions, fetching) and serve realistic hardcoded bait payloads (AWS keys, DB passwords).
* **#10 (MQTT):** The service now issues successful `CONNACK` responses, presents interactive persona-driven topic trees, and logs attacker `PUBLISH` events.
* **#11 (Conpot):** Wrapped in a custom build context that correctly binds Modbus to port `502` using a temporary template overwrite, resolving the missing Modbus response issue.

---

## Implementation Plan

### Phase 3: Critical SMTP Data Handling (P0)
- **SMTP (`SMTP_RELAY.md`)**: Rewrite `templates/smtp/server.py` to buffer `DATA` blocks properly and respond to `DATA` termination with a legitimate `250 OK` queue ID. Accept all open relay behavior inherently without mandating `AUTH`.

### Phase 4: High-Severity Protocol Fingerprint Fixes (P1)
- **HTTP**: Hijack Flask `after_request` to enforce the Apache `Server` header in `templates/http/server.py`. Rewrite the 403 response body with authentic Apache HTML.
- **FTP**: Update `templates/ftp/server.py` to overwrite Twisted FTP greeting banner to `vsFTPd`. Implement `FTPAnonymousShell` to serve fake files (tarball, db dump, credentials).
- **MSSQL**: Update `templates/mssql/server.py` to emit a valid length-fixed TDS 7.x pre-login payload to successfully pass the nmap probe.
- **MongoDB**: Update `templates/mongodb/server.py` to respond to the `OP_MSG isMaster` requests generated by modern `nmap` and MongoDB clients.

### Phase 5: State & Realism Improvements (P2)
- **Redis**: Instantiate `_FAKE_STORE` dict with bait authentication tokens and JWT salts in `templates/redis/server.py` to return plausible data for `KEYS *`, `GET`, `SCAN`, etc.
- **Dynamic Nonces (SIP/VNC/Postgres)**: Use `os.urandom()` and `secrets` to dynamically generate salts/nonces per connection instead of hardcoded strings in `templates/postgres/server.py`, `templates/sip/server.py`, and `templates/vnc/server.py`.
- **K8s (Kubernetes API)**: Investigate TLS setup block for K8s API port `6443` dropping traffic, pending an actual solution (requires deeper analysis and likely a separate plan).

