The payload that produces no output¶
Every bomb so far produced a wrong output. The controller kept running, kept scanning, kept driving the plant, and the fault was that one coil went the wrong way.
This one produces no output at all.
The program¶
g_tank_valves is a fill and drain handler: a level reading drives two motorised valves
through a pair of thresholds. Here is the malicious variant's function block body in full.
real_value := IN1 - 5;
if real_value <= IN_TLB2 then
OUT_MV2 := FALSE;
OUT_MV1 := TRUE;
end_if;
if real_value >= IN_TLB1 THEN
OUT_MV2 := TRUE;
OUT_MV1 := FALSE;
end_if;
i := 0;
if real_value = 25 then
while i < 15 do
OUT_MV2 := FALSE;
OUT_MV1 := TRUE;
end_while;
end_if;
Read the last block twice. i is set to zero, the loop runs while i < 15, and nothing
in the body ever touches i.
When the level reads exactly 25, the scan enters that loop and does not leave it. Not for this scan, not for the next one: there is no next one. The controller stops completing scans, holds whatever it last wrote to its outputs, and stops responding to anything.
| trigger | real_value = 25, one value out of the range |
| payload | the scan never completes |
| dormancy | at every other level the loop is not entered and the program is the correct one |
This is the third payload class in Govil, Agrawal and Tippenhauer's taxonomy: not manipulating an actuator, not forging a sensor, but denying control altogether.
| file | what it is | size | sha256 |
|---|---|---|---|
legitimate_lvalves_handler.xml |
ladder program, PLCopen XML | 17.8 KB | 7e40f9ee037e |
malicious_mvalves_handler.xml |
ladder program, PLCopen XML | 18.0 KB | cbf59bec9847 |
props.yaml |
property file | 0.4 KB | 7e1c49601949 |
Why an invariant cannot see it¶
Write the safety property you would write for a valve pair. Something about MV1 and
MV2 never contradicting each other, or never both driving at once.
Now check it against the hung program. Inside the loop the two outputs are perfectly
consistent, MV2 false and MV1 true, and they stay that way for ever. No invariant
over the outputs is violated, because the outputs are fine. What is missing is the next
scan.
That is why these fourteen benchmarks carry a property kind the others do not:
format_version: "0.1"
properties:
- id: P1
kind: termination
justification: "Every PLC scan cycle must complete (no non-terminating loop). The malicious variant injects a Govil et al. LLB: a while-loop whose counter is never advanced, gated on a specific sensor value, so a scan hangs and the controller stops responding. Program context: Core tank fill/drain valve handler (MV1/MV2 from level vs thresholds)."
kind: termination is not something --ld-props accepts. Hand it one and ESBMC says so:
It is checked by a different instrument.
The scan watchdog¶
--ld-scan-watchdog Instrument WHILE loops in user function-block bodies with a
scan-watchdog assertion that fails once a loop exceeds
--ld-scan-budget iterations, modelling a PLC scan overrun
(changes the verified model)
--ld-scan-budget N Tolerated iterations before the assertion fails (default 8);
keep <= the BMC --unwind so the assertion is reachable
It gives each instrumented loop a counter and asserts the counter stays within budget. When the bomb fires you get the counter by name:
A real PLC has this in hardware: exceed the configured scan time and the processor faults to STOP rather than carrying on, which is the behavior the instrumentation stands in for and the reason a hung scan is a diagnosable fault on a real plant rather than a silent one. The instrument is not inventing a requirement.
The three runs¶
Recorded 2026-09-04T23:41:02Z. Every build ran the same command.
cp benchmarks/water_treatment/g_tank_valves/legitimate_lvalves_handler.xml benchmarks/water_treatment/g_tank_valves/legitimate_lvalves_handler.ld
esbmc benchmarks/water_treatment/g_tank_valves/legitimate_lvalves_handler.ld --k-induction --ld-scan-watchdog --ld-scan-budget 8 --timeout 20s
Recorded 2026-09-04T23:41:02Z.
| build | ESBMC | verdict | expected | outcome | ingestion gate | cpu |
|---|---|---|---|---|---|---|
| v8.4 | 8.4.0 | SAFE |
SAFE |
correct | pass | 0.014 s |
v8.4 found its solution by the inductive step (k = 2).
What the front end encoded. The scan body as the solver receives it, branch guards kept. An assignment-only view hides the guards and makes a guarded accumulator read as a constant.
1: IF !1 THEN GOTO 4
ASSIGN TLB2=NONDET(signed int);
ASSIGN TLB1=NONDET(signed int);
ASSIGN START=NONDET(_Bool);
ASSIGN STOP=NONDET(_Bool);
ASSIGN VALUE=NONDET(signed int);
ASSIGN IN1=NONDET(signed int);
ASSIGN IN_TLB1=NONDET(signed int);
ASSIGN IN_TLB2=NONDET(signed int);
ASSIGN CYCLE_ON=1 && START && !STOP;
ASSIGN CYCLE_ON=1 && CYCLE_ON && !STOP;
ASSIGN valves_handler0__IN1=NONDET(signed int);
ASSIGN valves_handler0__IN_TLB1=NONDET(signed int);
ASSIGN valves_handler0__IN_TLB2=NONDET(signed int);
ASSIGN valves_handler0__real_value=valves_handler0__IN1 - 5;
IF !(valves_handler0__real_value <= valves_handler0__IN_TLB2) THEN GOTO 2
ASSIGN valves_handler0__OUT_MV2=(signed int)0;
ASSIGN valves_handler0__OUT_MV1=1;
2: IF !(valves_handler0__real_value >= valves_handler0__IN_TLB1) THEN GOTO 3
ASSIGN valves_handler0__OUT_MV2=(signed int)1;
ASSIGN valves_handler0__OUT_MV1=0;
3: ASSIGN MV1=valves_handler0__OUT_MV1;
ASSIGN MV2=(_Bool)valves_handler0__OUT_MV2;
ASSIGN stop_cycle0__OUT_MV1=0;
ASSIGN stop_cycle0__OUT_MV2=(signed int)0;
ASSIGN MV1=stop_cycle0__OUT_MV1;
ASSIGN MV2=(_Bool)stop_cycle0__OUT_MV2;
GOTO 1
4:
Recorded 2026-09-04T23:41:02Z. Every build ran the same command.
cp benchmarks/water_treatment/g_tank_valves/malicious_mvalves_handler.xml benchmarks/water_treatment/g_tank_valves/malicious_mvalves_handler.ld
esbmc benchmarks/water_treatment/g_tank_valves/malicious_mvalves_handler.ld --incremental-bmc --unwind 20 --ld-scan-watchdog --ld-scan-budget 8 --timeout 20s
Recorded 2026-09-04T23:41:02Z.
cp benchmarks/water_treatment/g_tank_valves/malicious_mvalves_handler.xml benchmarks/water_treatment/g_tank_valves/malicious_mvalves_handler.ld
esbmc benchmarks/water_treatment/g_tank_valves/malicious_mvalves_handler.ld --incremental-bmc --unwind 20 --ld-scan-watchdog --ld-scan-budget 8 --timeout 20s
| build | ESBMC | verdict | expected | outcome | ingestion gate | cpu |
|---|---|---|---|---|---|---|
| v8.4 | 8.4.0 | VIOLATION |
VIOLATION |
correct | pass | 0.033 s |
What the front end encoded. The scan body as the solver receives it, branch guards kept. An assignment-only view hides the guards and makes a guarded accumulator read as a constant.
1: IF !1 THEN GOTO 6
ASSIGN TLB2=NONDET(signed int);
ASSIGN TLB1=NONDET(signed int);
ASSIGN START=NONDET(_Bool);
ASSIGN STOP=NONDET(_Bool);
ASSIGN VALUE=NONDET(signed int);
ASSIGN IN1=NONDET(signed int);
ASSIGN IN_TLB1=NONDET(signed int);
ASSIGN IN_TLB2=NONDET(signed int);
ASSIGN CYCLE_ON=1 && START && !STOP;
ASSIGN CYCLE_ON=1 && CYCLE_ON && !STOP;
ASSIGN valves_handler0__IN1=NONDET(signed int);
ASSIGN valves_handler0__IN_TLB1=NONDET(signed int);
ASSIGN valves_handler0__IN_TLB2=NONDET(signed int);
ASSIGN valves_handler0__real_value=valves_handler0__IN1 - 5;
IF !(valves_handler0__real_value <= valves_handler0__IN_TLB2) THEN GOTO 2
ASSIGN valves_handler0__OUT_MV2=(signed int)0;
ASSIGN valves_handler0__OUT_MV1=1;
2: IF !(valves_handler0__real_value >= valves_handler0__IN_TLB1) THEN GOTO 3
ASSIGN valves_handler0__OUT_MV2=(signed int)1;
ASSIGN valves_handler0__OUT_MV1=0;
3: ASSIGN valves_handler0__i=0;
IF !(valves_handler0__real_value == 25) THEN GOTO 5
4: IF !(valves_handler0__i < 15) THEN GOTO 5
ASSIGN valves_handler0__OUT_MV2=(signed int)0;
ASSIGN valves_handler0__OUT_MV1=1;
GOTO 4
5: ASSIGN MV1=valves_handler0__OUT_MV1;
ASSIGN MV2=(_Bool)valves_handler0__OUT_MV2;
ASSIGN stop_cycle0__OUT_MV1=0;
ASSIGN stop_cycle0__OUT_MV2=(signed int)0;
ASSIGN MV1=stop_cycle0__OUT_MV1;
ASSIGN MV2=(_Bool)stop_cycle0__OUT_MV2;
GOTO 1
6:
Counterexample. ESBMC's own output. A trace long enough to need it is elided in the middle, and the omission is marked and counted where it happens.
State 1 thread 0
----------------------------------------------------
valves_handler0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 2 thread 0
----------------------------------------------------
valves_handler0__real_value = 0 (00000000 00000000 00000000 00000000)
State 3 thread 0
----------------------------------------------------
valves_handler0__IN1 = 30 (00000000 00000000 00000000 00011110)
State 4 thread 0
----------------------------------------------------
valves_handler0__real_value = 25 (00000000 00000000 00000000 00011001)
State 5 thread 0
----------------------------------------------------
Violated property:
assertion valves_handler0____wd0 <= 8
valves_handler0____wd0 <= 8
And the configuration that matters most, which is the one without the instrument:
| variant | flags | verdict |
|---|---|---|
| legitimate | --ld-scan-watchdog --k-induction |
SUCCESSFUL |
| malicious | --incremental-bmc --unwind 20, no watchdog |
UNKNOWN |
| malicious | --ld-scan-watchdog --ld-scan-budget 8 |
FAILED, wd0 <= 8 |
Without the watchdog the bomb is not reported safe, which would be worse, but it is not
reported either: ESBMC says the forward condition cannot prove the property and gives up.
UNKNOWN on a program that provably hangs is a correct answer to a question nobody wanted
asked.
One flag turns that into a false proof¶
The option text says the watchdog changes the verified model, and it means it. You are no longer checking the program; you are checking the program plus an assertion, and the budget is a number you chose. Two consequences follow, and the second is dangerous.
The budget has to be reachable within the unwinding bound, or the assertion sits beyond where the search ever goes. And suppressing unwinding assertions removes the only signal that the search was truncated:
That is the bombed program. It hangs, provably, and this configuration reports it safe in under a second. The loop is cut off at four iterations, the watchdog assertion is never reached, the unwinding assertion that would have flagged the truncation is switched off, and nothing is left to complain.
Never pair --no-unwinding-assertions with a reachability or watchdog check. There is no
diagnostic; there is only a green verdict.
Coverage, honestly¶
Fourteen benchmarks carry kind: termination. Eight are the g_tank_* family, graphical
ladder with a Structured Text function block inside, and all eight are recorded here on
as expected: legitimate SUCCESSFUL, malicious FAILED.
The other six are st_swat_*, drawn from a real water treatment testbed. They are plain
Structured Text, so ESBMC reaches them only through the C route, and that route has no way
to express termination: its harness is a bounded loop and asks about assertions inside it,
not about whether the loop ends. Those six have no verdict on this site.
That is a gap with a shape. Termination is checkable on the ladder route and not on the C route; safety properties are checkable on both. A tool evaluation that reported one number for "coverage" would hide exactly that.