Skip to content

Map lookup result used as truthiness in arithmetic — (prev or 0) + 1 never dereferences the pointer, counter never accumulates #89

Description

@douglasmun

Summary

When the result of a map .lookup() is used in a boolean/arithmetic expression like (prev or 0) + 1, Python-BPF compiles prev or 0 as a truthiness test on the lookup pointer rather than dereferencing it to read the stored value. The result is a counter that silently never accumulates: it stores 1 on first insert and 2 forever after (or 0, depending on how the boolean is coerced), instead of value + 1.

This is easy to hit because (prev or 0) + 1 is the natural Python way to write "increment, defaulting to 0" — and it produces a program that loads and runs cleanly but is silently wrong.

Versions

  • pythonbpf==0.1.9
  • pylibbpf==0.0.7
  • llvmlite==0.47.0
  • Python 3.12.3, Ubuntu 24.04
  • kernel 6.12 (aarch64); reproduced architecture-independently (see note)

Minimal reproduction

from ctypes import c_int32, c_uint64, c_int64, c_void_p
from pythonbpf import bpf, bpfglobal, map, section, BPF
from pythonbpf.maps import HashMap
from pythonbpf.helper import pid
import time, subprocess

@bpf
@bpfglobal
def LICENSE() -> str:
    return "GPL"

@bpf
@map
def counts() -> HashMap:
    return HashMap(key=c_int32, value=c_uint64, max_entries=4096)

@bpf
@section("tracepoint/syscalls/sys_enter_execve")
def count_exec(ctx: c_void_p) -> c_int64:
    key = 0                                   # fixed key so every exec hits one bucket
    prev = counts.lookup(key)
    counts.update(key, (prev or 0) + 1)       # <-- the trap
    return 0

b = BPF(); b.load(); b.attach_all()
m = b["counts"]
for _ in range(20):
    subprocess.run(["/bin/true"])
time.sleep(1)
print("bucket[0] =", m.items().get(0))        # expected >= 20

Observed: bucket[0] = 0 after 20 execs.
Expected: bucket[0] >= 20.

(A per-PID key hides the bug, because fresh execs get fresh PIDs and always take the first-insert path — the broken accumulation never shows. Use a fixed key to see it.)

Generated IR (the smoking gun)

For (prev or 0) + 1, the emitted IR treats the lookup pointer as a bool and adds 1 to that bool — it never loads *prev:

%".8"  = call i64* @lookup(... @counts, i64* %process_id)   ; pointer result
store i64* %".8", i64** %"prev"
%".10" = load i64*, i64** %"prev"
%".11" = icmp ne i64* %".10", null            ; <-- pointer truthiness, not a load
...
%"or.result" = phi i1 [ ... ]
%".16" = sext i1 %"or.result" to i64          ; bool -> 0/1
%".17" = add i64 %".16", 1                    ; (0 or 1) + 1  -- value never read
store i64 %".17", i64* %"__helper_temp_i64_0"
call i64 @update(... @counts, %process_id, %"__helper_temp_i64_0", i64 0)

So the stored value is (prev != null) + 1, not *prev + 1.

Workaround (what I'm using)

Explicitly dereference with deref() and branch on existence:

from pythonbpf.helper import pid, deref

prev = counts.lookup(key)
if prev:
    counts.update(key, deref(prev) + 1)
else:
    counts.update(key, 1)

With this, the same fixed-key test reads 20. Verified working.

Suggestion

Either (a) make prev or 0 / arithmetic on a lookup result auto-dereference (matching the Python intuition), or (b) reject/​warn at compile time when a map-lookup pointer is used directly in arithmetic/boolean context, pointing users to deref(). A note in the docs/README about lookup() returning a pointer (and (prev or 0) being a footgun) would also help — the C-equivalent semantics aren't obvious from the Python.

Happy to provide the full container setup or test more cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions