october 2026 · 5 min

A Refusal Is Not a Clean Verdict

When an LLM reads hostile code, the code gets to talk to the LLM. That is the whole mechanism behind this week's nullpt.rs teardown of a browser fingerprinting script, and it's the same mechanism malicious packages have been using against AI malware scanners since last year. If your pipeline reads a refusal, a timeout or an empty answer as "nothing found", the sample wrote your verdict.

What the Script Does

The script itself is ordinary bot detection: user agent, WebGL, webdriver checks. Around it, the post found two more things, and reads both as anti-LLM obfuscation aimed at whoever pastes the script into a model.

Nothing there asks the model to lie about the code. The effect is a model that refuses. The author's frontier model declined, "citing safety risks around biological research", and a second attempt in another mode was blocked too. GLM-5.3, an open-weight model, given the same prompt, complied.

In the Hacker News thread, several people doing incident response and security research called frontier models a minefield for that work and described moving it to open-weight models. Others said they use frontier models for security work daily without trouble.

The Same Trick, Aimed at Your Scanner

Whatever this script's authors meant by it, the same playbook is already in malicious packages:

Three attacks, one class: the sample controls the analyst's input. Ask for "benign" directly, push the payload past the context window, or trip a safety filter. Two of the three don't need the model to believe anything. They need it to stop, and they need the pipeline around it to treat stopping as passing.

A model that refuses weapons talk, or runs out of context on a 3.5-million-token file, is doing what it was built to do. Mapping that outcome to "no findings" is a bug in your code, and attackers are writing for it.

Fail Closed, and Check Before the Model Reads

Two controls.

First, the gate. The only outcome that passes is an explicit, well-formed "benign" verdict. A refusal, a timeout, a truncated or unparseable answer, an input too large to fit: each of those blocks the package and goes to a human. Socket says it plainly: "A model refusal, timeout, or safety error should not be treated as a clean result." Zscaler's advice on Hades is the same: treat a missing verdict as a signal.

Second, look for the attempt before any model does. Text aimed at a model has no business in a package, and finding it doesn't need a model. The invisible part is the easiest to catch:

import pathlib, sys

TAGS = range(0xE0000, 0xE0080)  # Unicode tag characters: invisible in editors and diffs

found = False
for path in pathlib.Path(sys.argv[1]).rglob("*"):
    if not path.is_file():
        continue
    text = path.read_text(encoding="utf-8", errors="ignore")
    hits = [c for c in text if ord(c) in TAGS]
    if hits:
        found = True
        decoded = "".join(chr(ord(c) - 0xE0000) for c in hits)
        print(f"{path}: {len(hits)} hidden chars -> {decoded[:80]!r}")

sys.exit(1 if found else 0)

Point it at an unpacked package (python3 tags.py node_modules/some-pkg). It prints the decoded hidden text and exits non-zero, so it drops into CI as is. Tag characters also show up legitimately inside a few flag emoji (England, Scotland, Wales), so expect the odd false positive in docs, and remember a clean exit only means this one trick is absent. Do the same for the visible tells -- "ignore", "system override", "this code is legit" in comments, and files far larger than their job. A package that addresses your scanner has already told you what it is. Socket filed shai_hulululud as protestware, and still called it "useful signal": attackers try evasion ideas in noisy packages before serious operators adopt them.

The tradeoff is real: fail-closed means more blocks and more human triage, and the people doing that triage are the ones already short on time. But a model refusing to read a dependency is a rare event, and every one of them deserves a look.

Who Gets the Model That Doesn't Stop

The nullpt.rs case shows the asymmetry I keep coming back to. The open-weight model read the script; the frontier model refused. Attackers already run models without limits. Defenders who standardize on the restricted ones hand the attacker a lever: write something the model won't touch.

My bet is that this makes open models a standard part of defensive tooling, not a workaround people mention in HN threads. Until then, the cheapest fix is in your own code: a refusal is a finding, never a pass.

← all posts