Skip to content

Repository files navigation

Utricularia

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.


Status

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 deployment

Several 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.


The name

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.

Why it is its own appliance

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.

What it pretends to be

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.

The simulation

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.

Four plants, because "realistic" means four different things

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.

Forward progression: things worth finding, that take work to find

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.

It scores, and the ban threshold had to move

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.

The tarpit uses the same line

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.

Threat intelligence: toolmarks and campaign clustering

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.

Scoring — what differs most

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.

Exception codes are a fingerprint

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.

The dashboard, and why watching cannot become interfering

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:8444

Pages: 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 .cast file another container is appending to; this one opens it for reading and seeks. The smoke test asserts the mount is RW=false.
  • There is no socket from it to any protocol service. It is on honeypot-internal solely to reach Redis. Preflight greps app.py for socket, urllib and requests calls and fails the run if any appear.
  • It has no egress, exactly like the honeypots — admin-internal is internal: true and honeypot-internal carries the same DOCKER-USER drop. The smoke test asserts it cannot reach 1.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.

Loot — what an attacker hands you

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/x is a real write primitive. tests/test_loot.py fires exactly that input at it.
  • Never executable. Files land 0400, the directory 0700, on no PATH, under no document root, on a mount carrying noexec,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.

Alerting

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.

Containment

  • No exec, system, popen, eval or subprocess anywhere, in any language. deploy/preflight.sh greps 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 iptables does not touch the latter and a single daemon flag would otherwise open an unfiltered path with every IPv4 control still reporting green. Not internal: true, which makes Docker refuse to publish ports and leaves every honeypot silently unreachable. deploy/smoke-test.sh asserts both directions
  • Exactly one container reaches the internetintel, 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.

When Redis goes away

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.

Deploying

Ten steps, in this order. Several of them are order-dependent in ways that are not obvious, and each one says why.

1. A dedicated host

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.

2. Base packages

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 group

python3-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.

3. Leave SSH where it is

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.

4. Clone

sudo git clone <your-repo> /opt/utricularia
sudo chown -R $USER:$USER /opt/utricularia && cd /opt/utricularia

5. Host preparation

# ADMIN_IP is YOUR address, not the VPS's.
sudo ADMIN_IP=203.0.113.10 ./deploy/bootstrap.sh

Getting 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.

6. Configure

cp .env.example .env
$EDITOR .env

Two settings matter more than the rest:

  • ENIP_ADVERTISED_ADDRESS — set it to this host's public IP. A List Identity reply naming 0.0.0.0 is 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.

7. Generate the persona — before going live

./deploy/generate-persona.sh                       # role: plc, site: water
./deploy/generate-persona.sh --profile siemens     # Siemens host

The 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.

8. Operator account

docker compose run --rm admin-dashboard python3 setup.py

Password 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 ENTRYPOINTCMD 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

9. Preflight, then launch

./deploy/preflight.sh

Do 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.sh

For 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.

10. Verify from outside

ssh -N -L 8444:127.0.0.1:8444 you@your-vps     # then http://127.0.0.1:8444

Check 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.

Optional: analytics

docker compose --profile elastic up -d
ssh -N -L 5601:127.0.0.1:5601 you@your-vps

Wants ~2.5 GB on top of everything else. On a small VPS, run it elsewhere instead — see Sizing.

Updating

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.sh

If an update needs the Docker daemon restarted, stop the stack first — see the two rules below.

Sizing

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.

git pull refuses because of a chmod you ran

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/*.sh

Fix 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 push

Two rules that will save you an hour

restart 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-dashboard

If 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 --build

Destroying 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 first

deploy/smoke-test.sh checks for these on every run, because none of the diagnostics above will find them.

Alerting

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.

Testing

python3 -m unittest discover -s tests -t . -v

The 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.

S7comm — verify it before you rely on it

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.

Analytics

docker compose --profile elastic up -d
ssh -N -L 5601:127.0.0.1:5601 you@vps      # then http://127.0.0.1:5601

elastic-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, copytruncate leaves the events in a .1 sibling 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.

Future work

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.

If you run this

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.

Licence

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.

About

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.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages