Skip to content

[critical] fix(pdf): stop hex-obfuscated names erasing pdfid's indicators - #105

Open
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/pdfid-obfuscated-counts
Open

[critical] fix(pdf): stop hex-obfuscated names erasing pdfid's indicators#105
elhoim wants to merge 1 commit into
calebevans:mainfrom
elhoim:fix/pdfid-obfuscated-counts

Conversation

@elhoim

@elhoim elhoim commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

BLUF

  • Priority: critical.
  • A PDF that hex-escapes a name — /J#61vaScript instead of /JavaScript — is reported by analyze_pdf with has_javascript: False and no "Contains JavaScript" reason. Name escaping is the textbook way to hide from pdfid, and this parser turned it into silence.
  • Root cause: _extract_pdfid_count does int(line.rsplit(None, 1)[1]). pdfid appends the hex-encoded tally to the count with no separating space, so the field is "1(1)", int() raises ValueError, and the function returns 0.
  • _run_pdfid keeps only indicators with count > 0, so the indicator is discarded outright — and analyze_pdf gates JavaScript extraction on that same indicator, so the JS is never even extracted. One bad int() cascades into a clean bill of health.
  • Fix: parse the count with a regex that accepts the optional (N) suffix and keeps the total.
  • Scope: src/mulder/server/tools/documents.py, one function plus one module constant.

The bug

pdfid 0.2.10 formats each keyword row (pdfid.py, PDFiD2String):

result += ' %-16s %7d' % (node.getAttribute('Name'), int(node.getAttribute('Count')))
if int(node.getAttribute('HexcodeCount')) > 0:
    result += '(%d)' % int(node.getAttribute('HexcodeCount'))

and mulder parsed it:

    parts = line.rsplit(None, 1)
    if len(parts) == 2:
        try:
            return int(parts[1])
        except ValueError:
            return 0          # <- every hex-obfuscated row lands here
    return 0

Verified against the real tool. A minimal PDF whose OpenAction is /S /J#61vaScript /J#53 (...), run through pdfid 0.2.10:

 /JS                    1(1)
 /JavaScript            1(1)
 /OpenAction            1

and through today's documents.py:

' /JS                    1(1)'   -> count=0
' /JavaScript            1(1)'   -> count=0
' /OpenAction            1'      -> count=1
' /EmbeddedFile          2(1)'   -> count=0
risk_level: medium | has_javascript: False
reasons: ['Auto-execution trigger present']

A PDF that auto-executes a script is reported as having no JavaScript. /EmbeddedFile vanishes the same way.

The fix

_PDFID_COUNT_RE = re.compile(r"^(?P<total>\d+)(?:\((?P<hexcode>\d+)\))?$")
    parts = line.rsplit(None, 1)
    if len(parts) != 2:
        return 0
    match = _PDFID_COUNT_RE.match(parts[1])
    if match is None:
        return 0
    return int(match.group("total"))

The total is the right number to keep: in pdfid's UpdateWords, words[name][0] += 1 runs for every occurrence and the hexcode tally [1] += 1 only additionally — so 2(1) means two occurrences, one of which was escaped. Two tests pin that reading and the unchanged behaviour of ordinary rows.

Deliberately out of scope

  • Using the hexcode tally as a signal. A hex-escaped /JavaScript has no legitimate purpose and is arguably an indicator in its own right; the regex captures it as hexcode but nothing consumes it yet. Feeding it into _compute_pdf_risk changes risk scoring and belongs in its own PR.
  • The unimplemented extract_urls / extract_embedded parameters (summary["urls"] = [], summary["embedded_files"] = []) — a separate defect, submitted separately.
  • The tool-failure paths for olevba and msodde — open PRs [high] fix(olevba): stop reporting an unreadable document as macro-free #99 and [high] fix(msodde): report a crashed DDE check instead of "no DDE links found" #101 cover those. This PR touches neither; it only changes pdfid output parsing, so it is complementary to both and conflict-free with them (verified with git merge-tree --write-tree).

Verification

  • uvx pre-commit run over the changed files → ruff, ruff-format, mypy all pass.
  • uv run --locked --extra dev pytest tests/ -q860 passed, nothing deselected.
  • Evidence above came from running the real pdfid 0.2.10 against a crafted PDF, not from reading documentation.
  • Discriminating check — with documents.py restored to origin/main and the new tests kept, 3 of 5 fail and the 2 narrowness tests pass:
FAILED tests/test_pdfid_obfuscated_counts.py::test_a_hex_obfuscated_name_still_reports_its_count - AssertionError: assert 0 == 1
FAILED tests/test_pdfid_obfuscated_counts.py::test_the_count_is_the_total_not_the_hexcode_tally - AssertionError: assert 0 == 2
FAILED tests/test_pdfid_obfuscated_counts.py::test_an_obfuscated_pdf_is_no_longer_reported_without_javascript - assert False is True
3 failed, 2 passed

Context

Recovered from closed PR #79, which bundled several unrelated PDF detection gaps into a 284-line diff. This is one of them, resubmitted on its own. No outcome framework, no shared taxonomy, no new abstraction — one regex and one function, per the review on #32:

focused fixes for specific tools that currently swallow failures or lose partial-result information would be welcome.

Branched fresh from current main (2e5432c); the closed branch was not revised in place.

🤖 Generated with Claude Code

pdfid prints a keyword row as ' %-16s %7d' and appends the hex-encoded tally
as '(%d)', with no separating space, whenever any occurrence of that name was
written with an escaped character. A PDF whose action is spelled
/J#61vaScript therefore arrives as:

     /JavaScript            1(1)

_extract_pdfid_count did int(line.rsplit(None, 1)[1]), which raises
ValueError on "1(1)" and returned 0. _run_pdfid keeps only indicators whose
count is > 0, so the indicator was discarded: the report came back with
has_javascript False and no "Contains JavaScript" reason, and analyze_pdf
skipped JavaScript extraction entirely because it gates that on the same
indicator. Escaping a name is the textbook way to hide it from pdfid, and
this parser turned that evasion into silence.

Parse the count with a regex that accepts the optional hexcode suffix and
keeps the total, which pdfid increments for every occurrence. Rows without
the suffix parse exactly as before, and unparseable rows still yield 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@elhoim
elhoim marked this pull request as ready for review September 9, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant