The refactor that carried a payload¶
The last two attacks in the dataset do something the other six do not. One removes a block from the program, and one hides its trigger behind arithmetic that a search for the constant will not find.
A block that stopped existing¶
| file | what it is | size | sha256 |
|---|---|---|---|
legitimate_lvalue_filtering.xml |
ladder program, PLCopen XML | 20.2 KB | d5ecd314c737 |
malicious_mvalue_filtering.xml |
ladder program, PLCopen XML | 16.4 KB | ab0b821bfe00 |
props.yaml |
property file | 0.4 KB | 5baa03f47937 |
The legitimate program declares four POUs: program0, valves_handler, value_filtering
and stop_cycle. The malicious one declares three. stop_cycle is gone.
That block held the stop latch. Deleting it outright would break the stop button, which a
commissioning test finds on the first afternoon, so the attacker did not delete the
behavior. The stop logic reappears inline, in the body of value_filtering:
Pressing stop still closes both valves. The plant behaves. What changed is the shape of the program: one fewer POU, one more responsibility on a block whose name says it filters values.
Read as a commit, this is a refactor, in which somebody folded a two-line block into its only caller and then deleted the wrapper, which is the kind of change that gets approved without much argument. The payload arrives in the same edit:
A structural change large enough to explain a large diff is useful cover. The reviewer's attention goes to the POU that disappeared and the logic that moved, and the ten lines at the bottom are the least interesting part of a diff that has a story attached.
Recorded 2026-09-04T23:41:02Z. Every build ran the same command.
cp benchmarks/water_treatment/g_tank_value_filtering/legitimate_lvalue_filtering.xml benchmarks/water_treatment/g_tank_value_filtering/legitimate_lvalue_filtering.ld
esbmc benchmarks/water_treatment/g_tank_value_filtering/legitimate_lvalue_filtering.ld --k-induction --ld-scan-watchdog --ld-scan-budget 8 --timeout 20s
Recorded 2026-09-04T23:41:02Z.
cp benchmarks/water_treatment/g_tank_value_filtering/legitimate_lvalue_filtering.xml benchmarks/water_treatment/g_tank_value_filtering/legitimate_lvalue_filtering.ld
esbmc benchmarks/water_treatment/g_tank_value_filtering/legitimate_lvalue_filtering.ld --k-induction --ld-scan-watchdog --ld-scan-budget 8 --timeout 20s
| build | ESBMC | verdict | expected | outcome | ingestion gate | cpu |
|---|---|---|---|---|---|---|
| v8.4 | 8.4.0 | SAFE |
SAFE |
correct | pass | 0.015 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 IN1=NONDET(signed int);
ASSIGN CYCLE_ON=1 && START && !STOP;
ASSIGN CYCLE_ON=1 && CYCLE_ON && !STOP;
ASSIGN value_filtering0__IN1=NONDET(signed int);
ASSIGN value_filtering0__value_real=value_filtering0__IN1 - 5;
ASSIGN value_filtering0__OUT=value_filtering0__value_real;
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_value_filtering/malicious_mvalue_filtering.xml benchmarks/water_treatment/g_tank_value_filtering/malicious_mvalue_filtering.ld
esbmc benchmarks/water_treatment/g_tank_value_filtering/malicious_mvalue_filtering.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_value_filtering/malicious_mvalue_filtering.xml benchmarks/water_treatment/g_tank_value_filtering/malicious_mvalue_filtering.ld
esbmc benchmarks/water_treatment/g_tank_value_filtering/malicious_mvalue_filtering.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.035 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 7
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 STOP=NONDET(_Bool);
ASSIGN IN_TLB1=NONDET(signed int);
ASSIGN IN_TLB2=NONDET(signed int);
ASSIGN IN1=NONDET(signed int);
ASSIGN CYCLE_ON=1 && START && !STOP;
ASSIGN CYCLE_ON=1 && CYCLE_ON && !STOP;
ASSIGN value_filtering0__IN1=NONDET(signed int);
ASSIGN value_filtering0__real_value=value_filtering0__IN1 - 5;
ASSIGN value_filtering0__OUT=value_filtering0__real_value;
ASSIGN value_filtering0__i=0;
IF !(value_filtering0__real_value == 25) THEN GOTO 3
2: IF !(value_filtering0__i < 15) THEN GOTO 3
ASSIGN value_filtering0__OUT=value_filtering0__IN1;
GOTO 2
3: ASSIGN valves_handler0__IN1=NONDET(signed int);
ASSIGN valves_handler0__STOP=NONDET(_Bool);
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 4
ASSIGN valves_handler0__OUT_MV2=(signed int)0;
ASSIGN valves_handler0__OUT_MV1=1;
4: IF !(valves_handler0__real_value >= valves_handler0__IN_TLB1) THEN GOTO 5
ASSIGN valves_handler0__OUT_MV2=(signed int)1;
ASSIGN valves_handler0__OUT_MV1=0;
5: IF !(valves_handler0__STOP == 1) THEN GOTO 6
ASSIGN valves_handler0__OUT_MV2=(signed int)0;
ASSIGN valves_handler0__OUT_MV1=0;
6: ASSIGN MV1=valves_handler0__OUT_MV1;
ASSIGN MV2=(_Bool)valves_handler0__OUT_MV2;
GOTO 1
7:
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
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 2 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 3 thread 0
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 4 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 5 thread 0
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 6 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 7 thread 0
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 8 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 9 thread 0
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 10 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 11 thread 0
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 12 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 13 thread 0
----------------------------------------------------
value_filtering0__IN1 = 5 (00000000 00000000 00000000 00000101)
State 14 thread 0
----------------------------------------------------
value_filtering0__real_value = 0 (00000000 00000000 00000000 00000000)
State 15 thread 0
----------------------------------------------------
value_filtering0__IN1 = 30 (00000000 00000000 00000000 00011110)
State 16 thread 0
----------------------------------------------------
value_filtering0__real_value = 25 (00000000 00000000 00000000 00011001)
State 17 thread 0
----------------------------------------------------
Violated property:
assertion value_filtering0____wd0 <= 8
value_filtering0____wd0 <= 8
The constant that is not the input¶
| file | what it is | size | sha256 |
|---|---|---|---|
legitimate_lsubstitution_coil.xml |
ladder program, PLCopen XML | 20.1 KB | c2d77103f6ab |
malicious_msubstitution_coil.xml |
ladder program, PLCopen XML | 20.3 KB | 8ebebbc63d1f |
Attack number eight triggers on IN1 = 46, and it is the only one of the eight naming
the raw sensor input in its guard rather than the corrected value.
That inverts the problem from 5.2. There, the guard read
real_value = 25 and the level that fired it was 30, so an analyst hunting for level 25
looked at the wrong number. Here the guard reads IN1 = 46 and 46 really is the level, but
46 is not a number anything else in the program mentions. The thresholds are inputs, the
offset is 5, and 46 appears once.
Neither version is harder than the other in any absolute sense. They defeat different searches, which is the point of having both in a dataset: a detector tuned to one shape should be tested against the other.
Recorded 2026-09-04T23:41:01Z. Every build ran the same command.
cp benchmarks/water_treatment/g_tank_substitution_coil/malicious_msubstitution_coil.xml benchmarks/water_treatment/g_tank_substitution_coil/malicious_msubstitution_coil.ld
esbmc benchmarks/water_treatment/g_tank_substitution_coil/malicious_msubstitution_coil.ld --incremental-bmc --unwind 20 --ld-scan-watchdog --ld-scan-budget 8 --timeout 20s
Recorded 2026-09-04T23:41:01Z.
cp benchmarks/water_treatment/g_tank_substitution_coil/malicious_msubstitution_coil.xml benchmarks/water_treatment/g_tank_substitution_coil/malicious_msubstitution_coil.ld
esbmc benchmarks/water_treatment/g_tank_substitution_coil/malicious_msubstitution_coil.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.034 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 STOP=NONDET(_Bool);
ASSIGN START=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 IN1=NONDET(signed int);
ASSIGN cycle0__IN1=NONDET(_Bool);
ASSIGN cycle0__OUT=cycle0__IN1;
ASSIGN CYCLE_ON=cycle0__OUT;
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__IN1 == 46) THEN GOTO 5
4: IF !(valves_handler0__i < 4) THEN GOTO 5
ASSIGN valves_handler0__OUT_MV2=(signed int)0;
ASSIGN valves_handler0__OUT_MV1=0;
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.
Both are found the same way¶
Nothing in this lesson required knowing which of the two tricks was in play. The verifier does not search for constants, so hiding a trigger behind arithmetic costs the attacker nothing and gains nothing. It does not diff against a reference program either, so deleting a POU is neither more nor less visible than adding one, and it asks the same single question of whatever program it is handed: can a scan fail to finish. Both programs answer yes, in about three hundredths of a second, and both counterexamples name the level that does it.