An ICS/SCADA honeypot appliance. It presents a Schneider Modicon PLC to the public internet, answers Modbus/TCP and EtherNet/IP at wire speed, simulates the pumping station that PLC is supposedly controlling, and turns every attempted write into evidence.
Nothing an attacker sends is ever executed, stored as code, or forwarded
anywhere. See AUTHORIZATION.md for the legal and ethical
basis and the design safeguards that make it non-weaponizable.
Sister product to Drosera, which presents a small-business web host. The two share a schema, a scoring model and a containment posture, and share no code, no volume and no host.
Running, and taking real traffic. The schneider profile — Modbus/TCP and
EtherNet/IP presenting a Modicon M340 — is deployed and answering. The test
suite passes, the containment assertions hold in both directions, and the
smoke test verifies both protocols end to end against a live stack.
./deploy/preflight.sh # syntax, pyflakes, tests, containment, git safety
./deploy/smoke-test.sh # against a running deploymentSeveral defects only surfaced under real traffic and are recorded in
FIXES.md — most usefully a tarpit threshold that survived four
rounds of tests and a clean preflight, then tarpitted the very first genuine
visitor for running an ordinary nmap modbus-discover. No test had asked what
a real scanner actually scores. One does now.
Not finished, and will not work if you enable it:
role: hmi / role: gateway |
Passes the coherence check with no web service behind it. The persona would claim an interface the host does not serve — worse than a bare PLC, because the port is closed rather than absent |
| DNP3 (20000), BACnet (47808) | Mapped in archetypes.py. The protocols are not written |
| Campaign clustering | shared/clustering.py works and has no producer — nothing yet assembles profiles from the event log |
| S7comm | Implemented, never run against real equipment. The SZL layouts are reverse-engineered with no published spec to check them, and a wrong SZL is a positive identification of a fake, where a closed port 102 is just a closed port |
One thing to verify against hardware if you can: word_order for 32-bit
registers. Backwards means a run-hour counter read an hour apart moves by
65536× too much — nothing logs it, and a careful reader sees it immediately.
Deploy role: plc, site: water, schneider profile. That is the path that
is complete.
Utricularia is the genus of the bladderworts, from the Latin utriculus — a little bottle, a wineskin. They are rootless plants that drift in still water and look like nothing at all: a tangle of green thread and a scatter of specks too small to bother with. The specks are the traps, and they are the only carnivorous mechanism in the plant kingdom that is genuinely a machine.
Every other carnivorous plant is passive. A sundew is glue. A pitcher is a hole. A flytrap is a hinge that has to be tripped twice. A bladderwort is a sealed chamber held under negative pressure by cells that pump water out of it continuously — and the pumping is the whole design. Stop, and the pressure equalises, and the trap is just a bag. Keep going, and the walls bow inward under an atmosphere of stored force, waiting on a door that a hair can unlatch.
When something brushes that hair the door buckles and the vacuum takes it: a half a millisecond, the fastest movement any plant makes, faster than the prey can register that the water moved. Then the door reseals, the cells start pumping again, and about fifteen minutes later the trap is armed once more.
A bladderwort is therefore not a thing that catches prey. It is a thing that maintains a state in which prey gets caught, and it has to keep doing the work or it stops being that thing at all.
| Bladderwort | Here |
|---|---|
| Pumps continuously to hold its vacuum; armed only while it keeps working | Armed only while it runs. A stopped process is a disarmed trap |
| Looks like debris on a pond floor | A PLC indistinguishable from every other exposed PLC. Nothing distinctive, because anything distinctive is a tell |
| A trigger hair buckles the door | A write to a coil — the one action that would move plant if this were real |
| Closes faster than the prey can react | Answers at wire speed until the score says otherwise |
| Reseals and re-pumps | Score and tarpit state persist per address. The next connection meets what the last one earned |
| Digestion takes days | Decoded PDUs, attempted writes and tags touched, turned into an evidence bundle |
The metaphor earns its keep by rejecting a fantasy. A honeypot is not a clever trick you set down and walk away from — it is a machine that has to keep running convincingly, and the moment it stops being maintained it is not a quiet trap, it is an obvious one. A simulation that freezes, a counter that resets on restart, a scoring rule that stopped matching real traffic: none of those announce themselves. They just quietly become a bag.
Bladderworts are also strictly passive. They grow in water too poor in nitrogen
to live on otherwise, they never move toward anything, and they consume only
what drifted in. That is exactly the posture in
AUTHORIZATION.md: nothing here scans back, connects out,
or reaches toward anyone. The one container with internet access listens on
nothing. It waits, and the only resource it spends is the visitor's own.
schneider profile (default) siemens profile (--profile siemens)
Internet Internet
│ │
├─ :502 ─► modbus-honey └─ :102 ─► s7-honey
└─ :44818 ─► enip-honey COTP, Setup, Read SZL
│ │
└────────────► storage/ ◄────────────────────────┘
│ events, transcripts, quarantined payloads
│
┌─────────────────┼──────────────────┬────────────────────┐
▼ ▼ ▼ ▼
admin-dashboard intel elastic-shipper (analysis)
127.0.0.1:8444 THE ONLY --profile elastic
read-only mount EGRESS internal network
SSH tunnel only alerts + VT ES + Kibana
Work crosses between containers as FILES on the storage volume, never as
sockets. Only `intel` reaches the internet; the honeypots, the dashboard and
the search stack all have none.
The two profiles are mutually exclusive and belong on two hosts. S7comm is Siemens-proprietary: Schneider controllers do not speak it and Siemens controllers do not answer EtherNet/IP, so a host with all three ports open is not a device that exists. That is the same contradiction as a PLC serving HTTP, one protocol layer down. Preflight fails when the persona and the running services disagree, the smoke test fails if both are up, and each service refuses to start under the wrong persona.
Drosera presents nginx, WordPress, sshd, SMTP and MySQL. A PLC that also runs
those does not exist. Anyone scanning the full port range sees the
contradiction instantly, so this is its own image, its own IP and its own
identity. deploy/smoke-test.sh fails if anything but 502 and 44818 is
published here.
The same reasoning drove the choice of device, one level deeper. EtherNet/IP is what makes Shodan classify a host as an Allen-Bradley PLC — but Rockwell controllers do not speak Modbus/TCP natively. A ControlLogix needs a separate gateway module, so a host answering both ports while claiming CIP vendor ID 1 is the same contradiction in miniature. Schneider authored Modbus, ships EtherNet/IP on the same platform, and carries vendor ID 243, so both ports can tell one story. WAGO 750-8xx is the alternative if you want both protocols equally native.
Two independent choices, in shared/archetypes.py, which together decide every
observable thing about the host.
Role decides the port surface — the first thing a full-range scan sees:
| role | ports | devices |
|---|---|---|
plc |
502, 44818 | Modicon M340/M580/M241 |
hmi |
80/443, 502 | Magelis GTO, Red Lion G3, Weintek cMT |
gateway |
80/443, 502, +DNP3/BACnet | eWON Flexy, Moxa MGate, Red Lion DA30D |
rtu |
502, 20000 | SCADAPack 474, TBox LT2 |
Site decides tags, telemetry and what an operator page would show:
water, power, hvac, manufacturing.
The one-personality rule is often shortened to "a PLC must not serve HTTP", which is true and is not the whole rule. A bare controller with a web server is a contradiction; an HMI with a web server is a Magelis. Those devices genuinely expose an operator interface and speak fieldbus, and they are what is actually on the internet — a naked PLC with only 502 open is plausible and unusual, because something is normally in front of it. So the rule is stated properly: every open port must be one this role would open, and every protocol must be one this sector uses.
The pairing is checked, not assumed:
- BACnet names a building. A water works speaking BACnet is a contradiction even though BACnet is an ordinary thing for a gateway to do.
- DNP3 names a utility. A chiller plant does not speak it.
- S7comm never shares a device — it is Siemens-proprietary.
- An HMI without a web interface is refused too. A panel with its web server switched off is one somebody hardened, and a hardened device is not one left on the public internet. The absence is as much a tell as an extra port.
persona.coherence_problems() is the machine-checkable form; preflight fails
on a non-empty result.
This is the hard part and the usual tell. A PLC whose registers never change is a PLC nobody is running.
There is no simulation loop. Every value is a closed-form function of wall-clock
time, seeded from the persona — process.snapshot(t) is pure. A loop would be
state that has to be maintained and shared, which means two protocols
disagreeing, Redis loss freezing the plant, and, fatally, a container restart
resetting every counter to zero. A device advertising 2019 firmware with three
hours on its pump run-time counter is identified in one read, and that happens
every time you deploy.
Four things make it read as real, none of which is a differential equation:
Hysteresis, not integration. A wet well of constant cross-section fills and empties linearly. The sawtooth between a start setpoint and a stop setpoint is the behaviour, not an approximation of it.
A phase integral, so there are no seams. Inflow varies with time of day, so
cycle duration does too. Stepping cycles from the start of each day would jump
the level at midnight UTC — a gift to anyone logging for 48 hours. The cycle
count is instead the integral of the inflow rate, which for a constant plus a
sinusoid is exactly integrable, so cycle index and position are available at any
t with no accumulation and no reset point.
Scan quantisation. Time is floored to the scan period before anything is computed. Two reads 5 ms apart return byte-identical values, because a PLC updates its input image once per cycle. Smooth values at microsecond resolution are not hardware that exists.
Correlation, and things that do not move. Flow, current, discharge pressure and winding temperature all follow from the pump state; suction pressure follows from the level; run hours follow from the fraction of time spent pumping since commissioning. Independent random walks give a flat correlation matrix, and that is the tell. Meanwhile the setpoints and mode word sit exactly still for weeks, because on a real station they do.
The configured area is much larger than the named tags and the gaps read zero — a device that answers only where we named something has published our map.
The engine in shared/engine.py is shared. The site models are not, because
each one's tell is different:
| Site | Behaviour | What would give it away |
|---|---|---|
water |
ramps between setpoints | values that never move |
power |
barely moves at all | values that move too much — a bus voltage bouncing scan to scan is an instrument nobody would leave in service |
hvac |
holds a control loop | a process variable tracking setpoint exactly: a plant with no load and no lag |
manufacturing |
runs, stops, changes over | 100% utilisation around the clock |
A single generic "drift some numbers" model fails all four differently, which
is the argument for there being four. tests/test_sites.py asserts each
distinct property: breakers that never change state, tap position a whole
number moving under twelve times in six hours, mixed air genuinely between
return and outside, fan power following speed cubed, part counts that are
integers, an oven still 20 °C above ambient at 03:00.
Load follows the working day wherever it should — engine.occupancy() knows
about weekends and gives soft edges, because a site that goes idle-to-full in
one scan has nobody walking into it.
A flat surface teaches you one thing about each visitor — they arrived. A scanner and a competent control engineer produce nearly identical telemetry, and the interesting question, how far can this person actually get, has no answer.
So the device carries four tiers, each only reachable through the one before it. Tier 2 names where tier 3 lives; tier 3 names where tier 4 lives. An attacker cannot skip a step by guessing an address, because the address is in the previous step.
| Tier | Content | The gate |
|---|---|---|
| 1 | Undocumented maintenance block — firmware build, commissioning note, engineer initials | Read past the documented map. Any address sweep finds it |
| 2 | Comms configuration — SCADA master, poll interval, station number | Notice a register block is packed ASCII and decode it. Schneider stores strings in %MW as byte pairs; a scanner sees noise |
| 3 | Engineering project record — project, revision, workstation, historian, VPN endpoint | Know FC 20 Read File Record exists, and which file number. The number is in tier 2 |
| 4 | Planted credentials | Ask for the second file, whose number is in tier 3 |
Every gate is something a control engineer does routinely and a scanner does not. None of it is a riddle.
That chain is the measurement. "This address reached tier 3" is a capability statement backed by evidence, and it is the most useful single thing this appliance can say about a visitor.
Tier 4 is honeytokens. Credentials and hostnames unique to this deployment
that authenticate nowhere and exist nowhere. If one appears in a paste dump, a
credential-stuffing attempt or another sensor's logs, it names which box it
came from and roughly when — an inference that only holds while the values are
unique to you, which is why generate-persona.sh replaces every one of them.
Plant addresses are RFC 1918 and the VPN endpoint is under .example, which
RFC 2606 reserves, so a captured token cannot point anyone at a real host.
Progression is not a reward that pauses enforcement — every tier scores, and the deep ones score heavily because reaching them says more than a write does. A write needs one request; tier 3 needs somebody who knows what FC 20 is.
But a fixed threshold makes the chain unreachable: at 35 points an address that writes twice is gone long before it finds tier 3. So each tier buys bounded headroom.
| Tier | Cost so far | Threshold | One write | Two writes |
|---|---|---|---|---|
| 0–2 | 5–34 | 35 | banned | banned |
| 3 | 50 | 85 | 78 — safe | 106 — banned |
| 4 | 74 | 135 | 102 — safe | 130 — safe, third bans |
Only the deep tiers buy anything, and that correction matters. Tiers 1 and
2 are both plain holding-register reads in the same address space, so a single
sweep of %MW crosses both — a scanner with no understanding at all arrives at
tier 2. If that bought headroom, a sweeper could then write twice and survive,
which is exactly what the ban exists to stop.
Tier 3 is the first that cannot be swept into: it needs FC 20 and the file number decoded out of packed ASCII. That is the line between running a tool and having commissioned this equipment, so that is where the concession starts.
Tiers 1 and 2 still score — "swept far enough to find the undocumented area" is a real observation. They just do not buy leniency, because they demonstrate persistence rather than capability.
Working the chain is not free. Reaching tier 4 costs about 74 points on its own, so the headroom has to cover that and leave room to act. The first version bought 12 per tier from tier 1: the ceiling was 83, a pure explorer arrived at 89% of a ban having written nothing, and one write anywhere removed them before the end — making exploring and writing mutually exclusive. That is backwards, because the most valuable address is the one that does both.
The base is deliberately unchanged at 35. Raising it would break the other
end: a block write scores 35, so connect + FC 16 is 36 and bans on contact
only while the threshold stays there. Nothing about the non-explorer path
moves.
An explorer needs dozens of requests — a register sweep, a decode, then two file reads. At 1.5–4 seconds a reply they give up long before the end, so the tarpit would drive away precisely the visitor it is least worth driving away. That is the same argument the write exemption makes.
So tier 3 and above are exempt from the tarpit, keyed off the same boundary as the headroom. Not tier 1 — exempting a sweep-reachable tier would hand every scanner a permanent bypass: walk the address space once, never be slowed again. A test asserts the two boundaries stay identical, because an address exempt from one and not the other is a state nobody reasoned about.
A read of a tier that has not been earned returns zeroes, not an exception. Refusing would confirm the area exists and is protected, which turns a chain they have to follow into a list they can enumerate. The out-of-sequence attempt is scored separately, because somebody guessing file numbers behaves differently from somebody following the chain.
Nothing here solicits or invites intrusion. It is passive content on a system
the visitor is already accessing without authorisation, several deliberate
steps in. See AUTHORIZATION.md §3.
shared/fingerprint.py derives a toolmark from the choices Modbus leaves
to the client and nothing checks: transaction-id policy, unit-id probe order,
quantity selection, address base, connection discipline, function-code order,
and whether a write was preceded by a read of the same address.
None of it is specified, so every implementation answers differently, and those answers are stable across every run of the same binary. It supports one claim:
Two sessions with the same toolmark were produced by the same tool.
It supports no claim about who ran it. There is no table mapping toolmark to a tool name — that would launder a guess into a field that reads like a fact.
Two decisions worth knowing. Cadence is measured but excluded from the digest: it is genuinely discriminating and it is also the feature our own tarpit distorts, so including it would mean fingerprinting our own behaviour. And sessions under four requests get no toolmark at all, because a digest over a two-request session manufactures clusters out of noise, and a cluster built on noise looks exactly like a finding.
shared/clustering.py groups activity with explicit confidence tiers, each
named for what it licenses a reader to say:
| Tier | Means |
|---|---|
TOOLING |
Same software. Widely available tools have many users |
OPERATION |
Same tooling plus infrastructure or behaviour that co-varies |
RELATED |
Features overlap without being distinctive |
Nothing above OPERATION, and it is named for the operation rather than the
operator because protocol telemetry does not support the second.
The rule doing the real work: infrastructure features cannot reach
OPERATION alone. Two addresses in one cloud range at similar hours would
otherwise accumulate enough weight to look coordinated — that describes
thousands of unrelated pairs. Something behavioural must be present. Crowded
ASNs are weighted to a quarter.
Every evidence item carries ESTABLISHED, EXPERIMENTAL or HYPOTHESIS.
Operational-hours correlation is HYPOTHESIS, weighted so it cannot decide
anything, and ignored for profiles under five sessions because two
single-session profiles always "match" on hours.
Every cluster carries its caveat inline rather than in a footnote, because clusters get copied out of tools and into documents.
Reads score zero, and are logged.
A poller reads the same twenty registers every second forever, and that is what port 502 is for. Per-PDU scoring bans an ordinary SCADA poll in under a minute. Recorded is not alerted: the read is in the transcript, the tag-level event fields and the evidence bundle. It simply does not move a number whose only job is to decide when to stop being polite.
What scores is pattern and intent:
| Event | Points | |
|---|---|---|
MODBUS_READ |
0 | Background noise |
ICS_DEVICE_ID_REQUEST |
4 | Fingerprinting |
ICS_FUNCTION_SWEEP |
6 | Once, at ≥5 distinct function codes |
ICS_UNIT_ID_SWEEP |
8 | Once, at ≥5 distinct unit IDs — "what else is on this network" |
ICS_ADDRESS_SWEEP |
4 | Once, at ≥12 distinct address blocks |
ICS_WRITE_ATTEMPT |
28 | FC 5/6 |
ICS_WRITE_MULTIPLE |
35 | FC 15/16 — bans on contact |
A write is the alarming event. Write Single Coil against a pump station is
an attempt to start a pump, and there is no benign reading of it from an address
that arrived unsolicited from the internet.
Against a ban threshold of 35:
connect 1 + device id 4 + first write 28 = 33 -> not banned
+ second write 28 = 61 -> banned
That gap is deliberate. Banning on the first write costs the entire sequence, and the sequence is the product: which registers, in what order, to what values, and whether they read back to confirm. One write tells you somebody tried; the fifth tells you what they were trying to do.
The tarpit is inverted relative to Drosera. Sweeps get drained; writers get wire speed until the ban. A client that times out mid-sequence never sends the rest, and the rest is what we are here for.
There is no crash tier, and that is a decision rather than an omission.
Drosera answers high-scoring addresses with procedurally generated malformed
data, which is right for SSH. Garbage on port 502 is not unidentifiable, it is
anomalous — real PLCs are fragile but they fail as timeouts, resets or
exception 0x0B. Random bytes in an MBAP frame are a honeypot signature, not a
controller under stress.
Three layers decide, deliberately:
| Code | Decided by | Because |
|---|---|---|
0x03 Illegal Data Value |
the codec | "Read 300 registers" is illegal on every device ever built |
0x02 Illegal Data Address |
the register map | Whether address 40100 exists is a fact about this device |
0x01 Illegal Function |
the service | A function we decline is a fact about the persona |
A honeypot answering 0x01 to everything is distinguishable from a PLC in three
requests. The smoke test asserts all three.
Reachable only over an SSH tunnel. It is never proxied, never published to the internet, and requires a password and TOTP — the session is created only after the TOTP step, so the password stage grants nothing on its own.
ssh -N -L 8444:127.0.0.1:8444 you@your-vps # then http://127.0.0.1:8444Pages: a live feed with sessions being recorded right now, per-address profiles with the stitched engagement transcript, a live register map, statistics recomputed per day, evidence export, an audit trail and settings.
The live session view is read-only by construction, not by promise. Four independent properties, any one of which would be enough:
- The dashboard mounts
storage/read-only. A live session is a.castfile another container is appending to; this one opens it for reading and seeks. The smoke test asserts the mount isRW=false. - There is no socket from it to any protocol service. It is on
honeypot-internalsolely to reach Redis. Preflight grepsapp.pyfor socket, urllib and requests calls and fails the run if any appear. - It has no egress, exactly like the honeypots —
admin-internalisinternal: trueandhoneypot-internalcarries the same DOCKER-USER drop. The smoke test asserts it cannot reach1.1.1.1. - There is no route toward an attacker anywhere in the appliance. Nothing connects out, so there is no mechanism an "interact" button could be wired to even if someone wanted one.
Bytes an attacker caused flow outward to a file and then to a browser. Nothing flows back. The transcript viewer polls for frames appended since a byte offset — a GET carrying a number, with no corresponding write route.
The live register map deserves its own mention: it is the one page that
tells you the trap is armed. A honeypot answering every request from a frozen
process is still up, still logging and still scoring, and is a PLC nobody is
running. That page computes process.snapshot() directly — the same pure
function the protocol services call — and highlights every value that has moved
since it loaded.
Charts are inline SVG built from the same JSON the tables use. No chart library: the dashboard is reached from a box with no egress, and a CDN that fails to load leaves empty boxes with no explanation of why.
Two protocol paths carry attacker-chosen bytes, and both are worth more than a commodity dropper:
- Modbus FC 21, Write File Record. A real write of record content into a file number the attacker picks. Rare in scan traffic, which is what makes it interesting.
- S7comm block download. How PLC logic actually gets pushed to a controller. Someone sending a logic block to a device they found on the internet is not enumerating — they are trying to change what the plant does.
FC 21 / S7 block download
│
▼
shared/loot.py: capture()
hash → dedupe → size check → write
│
├─ storage/loot/<sha256>.bin the bytes, mode 0400
└─ storage/loot/<sha256>.json metadata + sightings
│
▼ (filesystem, never a socket)
intel container, on intel-egress
reads the .json and the hash in the filename only
│
▼
VirusTotal hash lookup → verdict written back, alert on a hit
Why it is safe to have on the box:
- Content-addressed. The filename is the SHA-256 of the content. The file
number and any label are metadata and never build a path. This is the one
that matters most — otherwise a label of
../../etc/cron.d/xis a real write primitive.tests/test_loot.pyfires exactly that input at it. - Never executable. Files land
0400, the directory0700, on no PATH, under no document root, on a mount carryingnoexec,nosuid,nodev. - Nothing parses it. Bytes and a hash. The moment a honeypot starts understanding attacker input it inherits the attack surface of whatever library does the understanding.
- Write-only from the attacker's side. There is no code path by which a stored payload can be read back out through any emulated service. FC 20 reads answer with generated zeroes, never from the quarantine.
- Bounded and deduplicated. Per-file and total caps checked before writing; the same payload from fifty hosts is one file and fifty sightings.
intel is the only container with internet access, and it is on no
honeypot network and listens on nothing. Work reaches it over the storage
volume as files — the event log for alerts, the loot sidecars for scanning.
A compromised honeypot gains no route out through it; a compromised intel
gains no route in.
The alert credentials go to intel and nowhere else. Configuring a webhook on
a honeypot container would look set up, time out against a dropped packet, and
swallow the failure — preflight fails the run if anyone does it.
# .env — reaches intel only
ALERT_TELEGRAM_BOT_TOKEN=...
ALERT_TELEGRAM_CHAT_ID=...Message the bot once first; bots cannot open a conversation, and an unmessaged
chat id gives HTTP 400. intel.py logs that specific case rather than failing
silently, along with a 401 for a bad token.
Rate limited, because an attacker decides how many events they cause: repeats
of the same (address, event) pair collapse over ALERT_DEDUPE_SECONDS, and
ALERT_MAX_PER_HOUR is a ceiling regardless. A scanner writing a coil every
second produces 3,600 events an hour and, at the defaults, 12 messages.
On first run it checkpoints every log file at its current end and delivers nothing — otherwise enabling alerting on a box that has been running a month sends a month of backlog in one burst and gets the bot throttled into silence.
The payload is never sent anywhere. intel reads the sidecar JSON and the
hash in a filename; it does not open .bin files, and there is no upload path
in it — not a flag, not an option. Both are asserted by preflight. A hash
lookup asks "has anyone seen this?"; uploading the sample distributes it to
VirusTotal's customers, and that market includes the people who wrote it.
- No
exec,system,popen,evalorsubprocessanywhere, in any language.deploy/preflight.shgreps for them and fails the run - No attacker-supplied string ever reaches a path, a shell argument or a DB connection. Anything an attacker names is metadata
- Every container: non-root (UID 1000), read-only rootfs,
cap_drop: [ALL],no-new-privileges, setuid stripped at build, memory and PIDs capped - Every writable path — tmpfs and the capture mount —
noexec,nosuid,nodev - No egress from any honeypot container, or from the dashboard. Host
firewall rules drop anything the honeypot subnet originates outbound — in
IPv4 and IPv6, because
iptablesdoes not touch the latter and a single daemon flag would otherwise open an unfiltered path with every IPv4 control still reporting green. Notinternal: true, which makes Docker refuse to publish ports and leaves every honeypot silently unreachable.deploy/smoke-test.shasserts both directions - Exactly one container reaches the internet —
intel, on its own network, on no honeypot network, listening on nothing. The smoke test asserts all four properties: that it has egress, that it is on no honeypot network, that it publishes no port, and that a honeypot cannot reach it
Nothing scans, probes or connects back. The connecting address is very often a compromised third party, so a scan-back is a live port scan aimed at another victim. The architecture makes it impossible rather than the policy forbidding it, because policy slips under deadline pressure and architecture does not.
Requests are still answered and still written to storage/logs/, because that
is a file and needs nothing else. Scoring, tarpitting and banning all stop.
Enforcement is gone while collection carries on, and on a dashboard that looks
exactly like a quiet day.
So it says so: IDENTITY_STORE_DEGRADED goes to the event log the moment it is
detected, and IDENTITY_STORE_RECOVERED when it returns. A circuit breaker
opens after three failures so calls short-circuit to a local fallback instead of
paying a socket timeout on every request — a three-second stall on a protocol
where clients expect single-digit milliseconds is a deception failure on top of
an availability one.
Failing open and loud is deliberate. Refusing connections during an outage turns a degraded honeypot into a dead one and discards data that is still perfectly collectable.
Ten steps, in this order. Several of them are order-dependent in ways that are not obvious, and each one says why.
Nothing real may be co-hosted. The premise the whole design rests on is that no legitimate traffic ever reaches this machine, so every connection is by construction an alert. Sharing the box with anything real destroys that and puts real data behind a deliberately weak front door.
One profile per host, too. schneider and siemens are mutually exclusive:
S7comm is Siemens-proprietary, so a host answering 502, 44818 and 102 is not
a device that exists.
sudo apt-get update
sudo apt-get install -y ufw fail2ban git curl python3-pyflakes python3-yaml
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER && exit # log back in for the grouppython3-pyflakes and python3-yaml are for preflight. Install them with apt,
not pip — preflight runs python3 -m pyflakes against the system interpreter,
and on any PEP 668 distribution pip3 install --user refuses outright.
Unlike the sister appliance, this one never binds port 22. It opens 502 and 44818, or 102 on a Siemens host, and nothing else. There is no cutover to perform and no reason to move your own SSH.
bootstrap.sh restricts 22 to ADMIN_IP instead, which is simpler and has one
fewer way to lock yourself out. Set SSH_PORT if yours is already elsewhere.
sudo git clone <your-repo> /opt/utricularia
sudo chown -R $USER:$USER /opt/utricularia && cd /opt/utricularia# ADMIN_IP is YOUR address, not the VPS's.
sudo ADMIN_IP=203.0.113.10 ./deploy/bootstrap.shGetting ADMIN_IP wrong is the most common way to lock yourself out, and the
symptom is a connection timeout rather than a refusal, which reads like a
network fault. ADMIN_IP=$(curl -s ifconfig.me) run on the VPS resolves to
the VPS's own address — get it from your own machine, or from
echo "${SSH_CLIENT%% *}" in an existing session.
For a Siemens host, add HONEYPOT_PROFILE=siemens so ufw opens 102 rather than
502 and 44818.
This creates the storage tree owned by UID 1000, installs the DOCKER-USER egress rules in IPv4 and IPv6, sets up the fail2ban jail, and installs a retention watchdog. It deliberately does not open 8444 — the dashboard is reached over an SSH tunnel, and a firewall rule for it would put an authentication surface on the public internet.
cp .env.example .env
$EDITOR .envTwo settings matter more than the rest:
ENIP_ADVERTISED_ADDRESS— set it to this host's public IP. A List Identity reply naming0.0.0.0is what an unconfigured emulator sends, in the one request that matters most on 44818.HONEYPOT_IGNORE_IPS— put your own address in it. Without that, testing your own honeypot scores you like anyone else: a couple of writes bans you, and a firewall rule against your admin address is how you lose access.
./deploy/generate-persona.sh # role: plc, site: water
./deploy/generate-persona.sh --profile siemens # Siemens hostThe engine is public, so every constant shipped as source is a fingerprint — and on an ICS honeypot the widest part of that surface is the process itself. Two deployments whose wet well fills on the same 22-minute cycle with the same 75% start setpoint are visibly the same software.
This randomises the device, firmware, serial, station name, commissioning date,
cycle time, setpoints and the seed behind every per-cycle variation. Back up
the result with your .env. It holds the commissioning date every cumulative
counter derives from, so regenerating changes the device's entire apparent
history — and an attacker who saw one machine last week and a different one on
the same address this week has learnt something.
docker compose run --rm admin-dashboard python3 setup.pyPassword and TOTP; the session is created only after the TOTP step. Scan the QR before dismissing it — the secret is shown once, and the only recovery is re-running this and invalidating the old enrolment.
If that starts gunicorn and scrolls a web log at you instead of prompting, the
image predates the ENTRYPOINT → CMD change and your command is being
appended to gunicorn's arguments rather than replacing it. Either rebuild, or:
docker compose run --rm --entrypoint python3 admin-dashboard setup.py./deploy/preflight.shDo not skip this and do not deploy past a failure. It runs the test suite, checks syntax and undefined names, greps for process execution and outbound client code, verifies persona coherence, checks that no service publishes a port on an internal-only network, and audits what git is tracking. It is the last thing between you and a silent failure.
docker compose up -d --build
./deploy/smoke-test.shFor a Siemens host, add --profile siemens to both. A profile flag is part of
a service's identity — repeat it on every up, down, logs and ps
touching those containers, or Compose behaves as though they do not exist.
ssh -N -L 8444:127.0.0.1:8444 you@your-vps # then http://127.0.0.1:8444Check the Process page first. It is the one that tells you the trap is armed: a honeypot answering every request from a frozen register map is still up, still logging and still scoring, and is a PLC nobody is running.
Then test the protocols from a VPN or a second VPS, not from your admin
address — that one is in HONEYPOT_IGNORE_IPS and will not be scored, so it
proves nothing about whether scoring works.
docker compose --profile elastic up -d
ssh -N -L 5601:127.0.0.1:5601 you@your-vpsWants ~2.5 GB on top of everything else. On a small VPS, run it elsewhere instead — see Sizing.
cd /opt/utricularia && git pull
# Only if bootstrap.sh, the fail2ban jail or the watchdog changed. Idempotent.
sudo ADMIN_IP=203.0.113.10 ./deploy/bootstrap.sh
./deploy/preflight.sh
docker compose up -d --build # --build matters; see below
./deploy/smoke-test.shIf an update needs the Docker daemon restarted, stop the stack first — see the two rules below.
Compose limits are caps, not reservations. Real idle usage is far below them everywhere except Elasticsearch, which genuinely uses its heap.
| Component | Cap | Idle |
|---|---|---|
| redis + two protocol services | 896 MB | ~160 MB |
| intel | 192 MB | ~25 MB |
| redis-admin + dashboard | 448 MB | ~105 MB |
| Docker daemon + Ubuntu | — | ~300 MB |
| Honeypot total | 1.5 GB | ~590 MB |
| Elasticsearch | 1536 MB | ~1.2 GB |
| Kibana | 1024 MB | ~600 MB |
| elastic-shipper | 256 MB | ~60 MB |
With --profile elastic |
4.3 GB | ~2.5 GB |
Recommended: 1 vCPU / 2 GB / 20 GB. 1 GB runs it and leaves no headroom if the caps are approached together under a scan flood. CPU is not the constraint — these are asyncio services doing very little per request.
With Elasticsearch: 2 vCPU / 8 GB / 40 GB. Analytics roughly quadruples the
box for a low-volume log, which is why it is profile-gated. On a small VPS,
run it elsewhere instead: the shipper reads events off the filesystem and
never talks to a honeypot, so copying storage/logs/ to a machine with spare
memory and starting the profile there works identically.
Disk: ICS ports see far less traffic than Drosera's SSH and web. Expect
200–600 MB of event log at 90 days, transcripts capped at 2 MB each, ~700 MB of
images, and quarantine bounded by LOOT_MAX_TOTAL_MB (1 GB default).
Generate the persona before going live. The engine is public, so every
observable constant shipped as source is a fingerprint — and on an ICS honeypot
the widest part of that surface is the process. Two deployments whose wet well
fills on the same 22-minute cycle with the same 75% start setpoint are visibly
the same software. The generator randomises the device, the firmware, the
serial, the station name, the cycle time, the setpoints, the commissioning date
and the seed behind every per-cycle variation. Back up the result with your
.env; an attacker who saw one machine last week and a different one on the
same address this week has learnt something.
error: Your local changes to the following files would be overwritten by merge:
deploy/preflight.sh
A chmod +x is a tracked change. Git records the exec bit, so running it
to fix a "Permission denied" leaves a local modification that blocks the next
pull of that file. The two failures chain: scripts arrive non-executable, you
chmod them, and the next pull aborts.
git status --short # confirm it is only mode changes
git reset --hard origin/main
chmod +x deploy/*.shFix it once so it stops happening, from a Linux host — Windows does not carry exec bits on the filesystem, so committing the mode from there is unreliable:
git update-index --chmod=+x deploy/*.sh
git commit -m "Set exec bit on deploy scripts"
git pushrestart is almost never what you want, and neither is pull.
Only shared/ and persona/ are bind-mounted. Everything else is baked into
the image at build time — including the dashboard's templates/, static/
and setup.py. So:
| Changed | Needs |
|---|---|
shared/, persona/ |
docker compose restart <service> |
.env |
docker compose up -d |
| Anything else | docker compose build <service> && docker compose up -d |
A git pull alone changes the source on disk and nothing else. The symptom is
always the same and always confusing: a template edit that does not appear, a
script still printing an old value, a fix that appears not to work. There is no
error, because nothing failed — the container is faithfully serving what it was
built with.
docker compose build admin-dashboard
docker compose up -d --force-recreate admin-dashboardIf a change still does not appear, the COPY layer was cached — add
--no-cache to the build.
Stop the stack before restarting the Docker daemon, never after.
docker compose down # first
sudo systemctl restart docker # then
docker compose up -d --buildDestroying networks while the daemon restarts leaves stale nftables rules naming a bridge that no longer exists. Docker writes those with a negated interface match:
ip daddr 172.30.0.2 iifname != "br-4964bdc7466b" drop
While the bridge exists that reads "arrived from somewhere else" and is correct. Once it is gone, nothing can have arrived on it — so the negation matches everything and a protective rule inverts into a blackhole.
It defeats every normal diagnostic, so recognise it by the pattern:
| Looks like | Actually because |
|---|---|
| Every container times out reaching Redis, but the event feed keeps updating | The log is a file and needs nothing else |
iptables -L shows nothing dropped |
raw PREROUTING is not in that view |
| ARP resolves, the bridge looks healthy | ARP is not IP; the rule cannot match it |
| The host reaches containers fine | Host traffic uses OUTPUT, not PREROUTING |
ACCEPT at the top of DOCKER-USER changes nothing |
The packet is already gone |
tcpdump sees the SYN on the veth, nothing on the bridge |
Dropped in between |
On any nft-backed host, sudo nft list ruleset should be the first command
when packets vanish without explanation.
sudo ./deploy/clean-stale-nft.sh # --dry-run to look firstdeploy/smoke-test.sh checks for these on every run, because none of the
diagnostics above will find them.
Configured and inert. The honeypot containers have no egress, so webhook,
Telegram and syslog settings deliver nothing. They exist in .env.example so
that a future egress container inherits one spelling of each key, and so the
configuration is visible rather than absent.
python3 -m unittest discover -s tests -t . -vThe wire tests use the Modbus specification's own worked examples rather than
this implementation's output — a test built from our own encoder passes just as
happily when the encoder is consistently wrong, and consistently wrong is the
failure mode that matters. The process tests assert relationships between
registers rather than values, for the reason in FIXES.md: the map
has two of nearly everything, and the interesting bugs live in which of the two.
The siemens profile implements COTP/RFC 1006, S7 Setup Communication and Read
SZL for module and component identification: the three exchanges that make up
essentially all real traffic to an exposed S7 CPU. Read Var and Write Var are
decoded, scored and recorded, then declined with a documented S7 error rather
than answered — fabricating a datablock read is a large surface where a wrong
answer positively identifies the device as fake.
The SZL field layouts come from protocol documentation, not from a device on a bench. Modbus has a published specification with worked examples and the tests here assert against them; S7comm is reverse-engineered and the tests can only assert internal consistency. If you cannot check this against a capture from real equipment, running it is worse than not running it — a closed port 102 is just a closed port. The service says so at startup.
docker compose --profile elastic up -d
ssh -N -L 5601:127.0.0.1:5601 you@vps # then http://127.0.0.1:5601elastic-shipper bulk-indexes storage/logs/*.jsonl*. Three details come
straight from Drosera's scars:
- Document ids are
sha1(file:offset), so a replay overwrites rather than duplicates — which is what makes self-correction cheap. - The checkpoint is reconciled against the index, not trusted. Deleting the Elasticsearch volume (the standard fix for a cluster that will not start) leaves a checkpoint claiming days of progress, and the shipper then forwards only what arrived since. Nothing fails; the only symptom is a number being too low somewhere else.
- The glob is
*.jsonl*. If a rotation tool has ever run on this tree,copytruncateleaves the events in a.1sibling and a zero-byte file under the bare name — and an empty file reads identically to a finished one.
technique_id here carries ATT&CK for ICS identifiers, which is why every
event also carries technique_matrix. Merging this index with Drosera's
without it silently mixes two taxonomies in one column and produces a breakdown
that answers neither question.
In priority order by intelligence value against added risk:
An analysis pass for clustering. shared/clustering.py has no producer —
nothing yet assembles ActivityProfile records from the event log. Write-target
order is the highest-value behavioural feature this platform can collect and
is currently the least exercised: an operator who understands the process
touches points in a sequence that reflects intent, and that is the difference
between a scanner with a payload and someone who has seen the process before.
A packet layer. The single biggest structural gap. Everything today is decoded at the application layer by our own codecs, which blocks JA3/JA4, TLS certificate reuse, HTTP and SSH client fingerprints, Zeek and Suricata entirely. No amount of work on the ICS side reaches those. It also carries real costs — storage, and a much heavier data-protection posture, since full capture records third-party credentials in transit.
The HMI web service. Believability and the JA3/HTTP fingerprint surface.
Each site already exports HMI_PAGE, so the content exists.
STIX 2.1 / TAXII export, to make the output usable by everyone else.
DNP3 (20000), the protocol that actually matters for water and power. IEC 60870-5-104 (2404) and BACnet (47808) after it.
Deliberately not planned: OPC UA, historians and SCADA servers. Large CVE surface, and a plausible-looking implementation invites exploitation attempts this appliance cannot safely absorb — while a bad one is a positive identification of a fake. High effort, high risk, moderate return.
YARA matching stays off the honeypot path. Running it over captured samples means parsing attacker input, and the loot argument is "bytes and a hash, nothing understands it". It belongs offline on a host you are willing to lose.
Read AUTHORIZATION.md first, particularly §9, which
covers what is specific to an ICS honeypot rather than a honeypot in
general. The short version:
- Dedicated host, nothing real co-hosted. The premise every other control rests on is that no legitimate traffic can reach the box.
- Generate the persona before going live. The engine is public, so every constant shipped as source is a fingerprint — and here the widest part of that surface is the process. Two deployments whose wet well fills on the same 22-minute cycle with the same setpoints are visibly the same software.
- Nothing here controls anything. There is no plant, no actuator and no PLC. The registers describe a station that does not exist, computed from the clock. A write to a coil moves nothing because there is nothing to move.
- Do not attach a real organisation's name to the station. The generator produces fiction; adding a real utility or site converts a fictional device into an apparent misrepresentation of a named third party, and it is the one change that would make this hard to defend.
- Somebody will believe it is real. Exposed-controller censuses count every responding host, so this inflates the figures for Schneider equipment in your country. Someone may report you to your provider in good faith as an endangered utility. §2.1 covers when to tell them first.
PolyForm Noncommercial 1.0.0. Any noncommercial purpose is permitted, including use by charities, educational institutions, public research organisations, public safety and health bodies, and government institutions — regardless of how they are funded.
Commercial use is not permitted. That is a materially different claim from
MIT, and the sister appliance shipped a README saying MIT over a PolyForm
licence file for a while, which is in its FIXES.md for good reason: a licence
is a statement people rely on, and the two disagreeing means one of them was
misleading somebody.
Built by Digital Systems LLC / Afterpacket.