What real plant code costs¶
Six benchmarks in this catalog come from SWaT, a working water treatment testbed, by way of the PLC_Defuser dataset. Twelve program variants, legitimate and malicious in pairs, and the plant they control is real.
Not one of them has a verdict on this site.
This lesson is why, measured rather than asserted, because the reasons are the honest answer to what it costs to verify code that arrived from somewhere else.
What the programs are¶
Each file holds four POUs and a configuration that runs three of them as separate tasks:
FUNCTION_BLOCK check_pumps
PROGRAM PLC1 level, request -> pumps_state, pumps, valve
PROGRAM PLC3 level -> pump
PROGRAM PLC2 level -> request
CONFIGURATION Config0
RESOURCE Res0 ON PLC
TASK task0(INTERVAL := T#20ms, PRIORITY := 0);
...
PROGRAM instance0 WITH task0 : PLC1;
PROGRAM instance1 WITH task1 : PLC2;
PROGRAM instance2 WITH task2 : PLC3;
Three controllers, one plant, wired to each other: PLC2 computes a request that
PLC1 consumes. Nothing in Parts 1 to 3 looks remotely like this, where every benchmark
was a single program with a handful of Booleans.
| file | what it is | size | sha256 |
|---|---|---|---|
sleg_timer1_0.st |
structured text | 3.0 KB | a3b5b2c2471b |
smal_timer1_0.st |
structured text | 3.1 KB | d8ecf890b1f8 |
props.yaml |
property file | 0.3 KB | af57fb8174c7 |
The bomb¶
Inside the function block, in the malicious variant:
The same shape as lesson 3.4: a loop whose counter is never advanced, so the scan never completes. What makes this one different is where the trigger sits.
The function block is enabled by an on-delay timer with a preset of ten thousand seconds. The controller runs correctly for close to three hours and then stops responding.
Obstacle 1: ten of twelve are not IEC¶
VAR
exc : __SYSTEM.ExceptionCode;
END_VAR
__TRY
real_value := IN1 - 5;
__CATCH(exc)
real_value := 0;
__ENDTRY;
__TRY, __CATCH and __SYSTEM.ExceptionCode are CODESYS extensions. They are not in
IEC 61131-3, and MatIEC rejects them at the declaration:
Ten of the twelve files use them. Only the timer1 pair is portable Structured Text, and
those two do compile.
This is not a defect in anything. It is what production code looks like: written against a vendor's toolchain, using that vendor's error handling, because the plant had to work.
Obstacle 2: four POUs and three tasks¶
The via-C harness this suite generates instantiates one POU and drives one scan loop.
These files need three program instances stepped together, with PLC2's output reaching
PLC1's input in whatever order the configuration implies.
That is a real extension rather than a hard problem, and it is the one obstacle here that straightforward work would remove.
Obstacle 3: the trigger is behind a clock¶
MatIEC compiles TON against a real time source:
Nothing in a generated harness advances that clock. Leave it alone and TON0.Q never
rises, check_pumps0 is never enabled, and the bomb is unreachable no matter how deep the
search goes. Verifying this program means modeling time, not just scans.
Obstacle 4: and then the depth¶
Suppose obstacles one to three were solved. The preset is T#10000s on a T#20ms task:
Lesson 3.3 measured a 32,767-scan fuse at 393 seconds of solving. This one is fifteen times deeper, and the cost was growing faster than linearly at that point.
The attacker wrote T#10000s. It took no longer to type than T#1s.
What would actually work¶
Not a deeper bound. Two things, both of which the suite already demonstrates elsewhere.
The ladder route's scan watchdog. Lesson 3.4 catches
this exact payload in the eight g_tank_* benchmarks, in milliseconds, because it does
not chase the trigger at all. It instruments the loop and asserts the iteration count
stays in budget, which is true or false regardless of how the program got there. Those
eight have verdicts on this site and these six did not, and the difference is the
instrument rather than the difficulty.
An inductive argument. A proof that never mentions the timer's value holds for every
scan at once, which is what --k-induction does for the expected-SAFE tasks throughout
this site, closing at k = 2 whatever the program's horizon.
One of the six now has a verdict¶
Writing that advice down was enough to act on it. Termination is not an assertion over variables, which is why the C route refused these files, but ESBMC already answers the question a different way: an unwinding assertion fails when a loop cannot be closed within the bound. That is the same shape as the ladder route's watchdog, expressed with the tool the C route already runs.
The runner now accepts kind: termination on that basis, and the timer1 pair, the one
portable pair of the six, comes back as a pair:
Recorded 2026-08-28T18:39:19Z. Every build ran the same command.
runner/record_via_c.py benchmarks/water_treatment/st_swat_timer1/smal_timer1_0.st benchmarks/water_treatment/st_swat_timer1/props.yaml false --tool master=<path-to-esbmc>
which generates the C below and hands it to ESBMC as:
Translated by Beremiz df4370c and MatIEC 7949c0b.
| build | ESBMC | verdict | expected | outcome | cpu |
|---|---|---|---|---|---|
| master | 8.4.0 0064c4ac |
VIOLATION |
VIOLATION |
correct | 17.798 s |
What ESBMC was given. The harness the adapter generated, with the property written over the variables MatIEC emitted.
#include "iec_std_lib.h"
#include "POUS.h"
#include "POUS.c"
int main(void) {
CHECK_PUMPS_data__ d = {0};
CHECK_PUMPS_init__(&d, 0);
for (int scan = 0; scan < 8; scan++) {
__SET_VAR(d., IN1, , nondet_int());
__SET_VAR(d., IN2, , nondet_int());
CHECK_PUMPS_body__(&d);
}
return 0;
}
State 1 file <generated>/harness.c line 9 column 15 function main thread 0
----------------------------------------------------
d.IN1.value = { .EN={ .value=1, .flags=0 }, .ENO={ .value=1, .flags=0 }, .IN1={ .value=nondet_symbol(symex::nondet1), .flags=0, .anon_pad#2=0 },
.IN2={ .value = 0 },
.OUT={ .value = 0 }, .I={ .value = 0 },
.anon_pad#6=0 }
State 2 file <generated>/harness.c line 10 column 15 function main thread 0
----------------------------------------------------
d.IN2.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=8538369, .OUT=d.OUT,
.I=d.I, .anon_pad#6=0 }
State 3 file <generated>/POUS.c line 27 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->ENO.value = { .EN=d.EN, .ENO=1, .IN1=d.IN1, .IN2=d.IN2, .OUT=d.OUT,
.I=d.I, .anon_pad#6=0 }
State 4 file <generated>/POUS.c line 32 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=1,
.I=d.I, .anon_pad#6=0 }
State 5 file <generated>/POUS.c line 37 column 13 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->I.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=d.OUT,
.I=0, .anon_pad#6=0 }
State 6 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 7 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 8 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 9 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 10 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 11 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 12 file <generated>/POUS.c line 39 column 15 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
... 8 lines omitted from the middle of the trace (129 lines in total) ...
----------------------------------------------------
data__->OUT.value = { .EN=d.EN, .ENO=d.ENO, .IN1=d.IN1, .IN2=d.IN2, .OUT=0,
.I=d.I, .anon_pad#6=0 }
State 15 file <generated>/POUS.c line 38 column 3 function CHECK_PUMPS_body__ thread 0
----------------------------------------------------
Violated property:
file <generated>/POUS.c line 38 column 3 function CHECK_PUMPS_body__
unwinding assertion loop 8
** Results:
<generated>/POUS.c, function CHECK_PUMPS_body__
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.1] line 22 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.1] line 22 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.2] line 23 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.2] line 23 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.3] line 23 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.3] line 23 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.4] line 27 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.4] line 27 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.5] line 27 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.5] line 27 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.6] line 31 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.6] line 31 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.7] line 32 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.7] line 32 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.8] line 32 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.8] line 32 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.9] line 34 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.9] line 34 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.10] line 35 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.10] line 35 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.11] line 35 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.11] line 35 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.12] line 37 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.12] line 37 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.13] line 37 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.13] line 37 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.14] line 38 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.14] line 38 dereference failure: Incorrect alignment when accessing data object
FAILED [CHECK_PUMPS_body__.assertion.15] line 38 unwinding assertion loop 8
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.15] line 39 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.16] line 39 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_body__.object-out-of-bounds.16] line 39 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_body__.assertion.17] line 39 dereference failure: Incorrect alignment when accessing data object
<generated>/POUS.c, function CHECK_PUMPS_init__
PASSED [CHECK_PUMPS_init__.object-out-of-bounds.1] line 11 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_init__.assertion.1] line 11 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_init__.object-out-of-bounds.2] line 12 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_init__.assertion.2] line 12 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_init__.object-out-of-bounds.3] line 13 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_init__.assertion.3] line 13 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_init__.object-out-of-bounds.4] line 14 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_init__.assertion.4] line 14 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_init__.object-out-of-bounds.5] line 15 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_init__.assertion.5] line 15 dereference failure: Incorrect alignment when accessing data object
PASSED [CHECK_PUMPS_init__.object-out-of-bounds.6] line 16 dereference failure: Access to object out of bounds
PASSED [CHECK_PUMPS_init__.assertion.6] line 16 dereference failure: Incorrect alignment when accessing data object
The witness is unwinding assertion loop 8, and its legitimate twin verifies. Read the
claim carefully, because it is weaker than it looks: an unwinding assertion also fails on a
loop that merely runs deeper than the bound. It is evidence of non-termination, not a proof,
which is why the record carries the bound it used. For while i< 4 do OUT:=FALSE; with i
never assigned, the evidence is about as strong as evidence gets.
The same change reached the eight g_tank_* pairs, whose properties are also termination.
Their legitimate halves now carry an independent SAFE from the C route alongside the
ladder verdict they already had, while the bombs time out, so refuting those still rests on
the watchdog alone.
The other five cannot be ported, and that is the finding¶
Obstacle 1 looked like a syntax problem. It is not. Look at where the bomb lives:
__TRY
OUT:=FALSE;
IF IN1=IN2 then OUT:=TRUE; END_IF;
IN1 : = IN2/IN1; (* divides by zero when IN1 = 0 *)
__CATCH(exc)
i:=IN1;
WHILE i<4 do i := i * i; End_WHILE; (* 0*0 = 0, so this never exits *)
__ENDTRY
The trigger is a division by zero and the payload is in the exception handler. All five
non-timer pairs share that shape. So the CODESYS extension is not packaging around the
attack, it is the attack: strip __TRY and __CATCH to make the file portable and you
have deleted the bomb along with them.
That is worth more than a verdict. An entire class of attack here is written in a vendor's exception mechanism, which IEC 61131-3 does not define and no conforming tool implements, so no standards-conforming verifier can read it, let alone refute it. A tool that only accepts portable Structured Text cannot see this bug, and cannot tell you it could not see it.
Both amount to the same advice: stop reasoning about when the trigger fires, and reason about what the program does when it does.
Why these stay in the catalog¶
A benchmark suite that only contains programs its tools can verify is a suite that measures nothing. These six are the ones that say what the gap actually is: not "Structured Text is unsupported", but four specific obstacles, three of which are ordinary engineering and one of which is a genuine research question.
They also keep the rest of the catalog honest. Everything in Parts 1 to 3 is small enough to hold in your head, and a reader who saw only those would leave with a very comfortable impression of how much of a real plant a model checker can currently take on.