--- title: PDF Injection Detector (MiMo-7B) emoji: πŸ” colorFrom: yellow colorTo: gray sdk: gradio sdk_version: 5.50.0 app_file: app.py pinned: false license: mit short_description: MiMo-7B finds payloads hidden inside PDF files --- # PDF Injection Detector β€” MiMo-7B Upload a PDF. It is rendered to text with the extractor that built the project corpus, the regions carrying structural markers are ranked, and **MiMo-7B-RL** reads the most promising ones and says whether a payload is hidden there β€” naming the family and quoting the substring that convinced it. It is a coursework artefact built on a synthetic corpus of 1,100 PDFs carrying harmless EICAR/AMTSO/WICAR/RANSIM test markers. **It is not a general malware scanner.** ## How to use it Live Space: [BentoUniAcc/Mimo_Injection_detector](https://huggingface.co/spaces/BentoUniAcc/Mimo_Injection_detector). Every screenshot below is of that Space as it actually runs β€” nothing is mocked up. ### 1. Open the Space ![The interface on load](docs/screenshots/01_landing.png) The page comes up before anything is downloaded. The header states which runtime is live and roughly what a region costs on it β€” **GPU, ~4.2 s per region** above. Read the disclaimer paragraph: the verdict, the family and the treatment are all fallible, and the family is wrong more often than it is right. ### 2. Give it a PDF Either drag a file onto the **PDF** box, or press one of the **Examples** buttons down the left edge β€” one per attack family, plus a clean control. An example just drops its file into the same uploader, so it is triaged by exactly the same code path as your own upload. ![An example loaded, with the plan underneath](docs/screenshots/02_example_loaded.png) Note the uploader shows `Example_5.pdf`, not the family name. The button is labelled; the file is not. Nothing about the document announces its own answer, and a filename never reaches the model. ### 3. Read the plan, then set the controls Under the button, the app says what it found and what it intends to read *before* any model runs β€” that panel is broken down in [the next section](#where-the-plan-panels-numbers-come-from). ![The controls](docs/screenshots/03_controls.png) | Control | What it does | When to change it | |---|---|---| | **Runtime** | `gpu` (4-bit NF4, Part B's configuration, ~4 s/region, capped by your daily ZeroGPU quota) or `cpu` (Q4_K_M GGUF, ~2 min/region, no quota). Hidden when only one backend is installed β€” as on this Space. | Switch to `cpu` when the GPU quota runs out. | | **Regions per batch** | How many regions one run of the model reads. It only re-cuts the same list, so a lower number means more batches, not less work. | Leave it at the maximum. A run reserves the same GPU time whatever this is, so lowering it inspects less of the file for the same quota. | | **Batch to check** | Which batch this run spends. Each label states how many regions, whether they are marker or sweep, where in the skeleton they sit, and the estimated time. | Batch 1 is the most marker-dense, so start there and spend further runs on later batches if you want the rest read. | | **Sweep the rest of the document too** | On, the batches tile the whole skeleton. Off, only marker regions are read β€” which is the exact shape Part B measured. | Turn it off to stay strictly inside the measured configuration; leave it on for coverage of text no marker points at. | | **Also show the nearest files in the corpus** | Runs Part A's embedding lookup after the scan. Costs a one-off 550 MB model download. | Turn it off to save the download. | ### 4. Press **Check this batch** The button locks itself for the duration and any previous result is cleared, so nothing on screen can belong to a different file. The first scan after a restart also fetches the weights, which is the slow part. ![The first scan, downloading the model](docs/screenshots/04_scanning.png) One press is one run of the model over one batch. Press it again β€” on a different batch β€” to spend another run; the app never scans the whole file behind your back. ### 5. Read the four tabs - **Report** β€” the verdict for this batch. If MiMo flagged anything it says so, names the family it guessed, and prints the containment advice for that family. It always states how many regions in other batches are still unread, and how many answers could not be parsed (those count as *not injected*, exactly as Part B scored them). - **Regions read** β€” one row per region: where in the skeleton, `marker` or `sweep`, which signatures the regex saw, MiMo's verdict, the family, and the substring quoted as evidence. - **Nearest corpus files** β€” the five most similar corpus documents to the first flagged region. Precision@5 is 35.6%, so read it as *resemblance*, never identification. - **What MiMo actually said** β€” the untouched generation per region, and which prompt route produced it. This is where to look when a verdict seems wrong. #### The nearest-files tab is a recommender, and the score is cosine similarity That tab is the project's recommendation system, and it is content-based rather than collaborative β€” there are no users to learn from, only documents. The flagged region is embedded with Part A's winning model (`nomic-ai/nomic-embed-text-v1.5`, the `search_document: ` prefix, 768 dimensions) into the same space as the 1,100 corpus files, and the five most similar are returned. Both the index and the query are **unit-normalised**, so the dot product *is* the cosine similarity, and the whole lookup is a single 1,100 Γ— 768 matrix–vector product β€” no approximate index or clustering is needed at this size, and the ranking is exact. The number in the *cosine similarity* column is that value: 1.0 is identical direction, 0 is unrelated. It measures how alike two payload windows *read*, which is not the same as being the same attack β€” hence precision@5 of 35.6% against a 6.8% random baseline, and the word *resemblance* rather than *identification*. `check_provenance()` asserts the model, prefix, dimension, normalisation and source column against Part A's own results file before any lookup runs, because a query embedded differently lands in a different space and returns confident nonsense with no error anywhere. ### Quotas, and what "MiMo could not run" means ![The ZeroGPU quota message](docs/screenshots/05_quota.png) A free visitor gets roughly **five minutes of ZeroGPU per day**, and the scheduler reserves a run's full requested duration up front rather than what it turns out to use β€” so two or three batches can exhaust the day. The quota is counted per visitor, not per Space: this message means your allowance, not a broken app. The report says exactly how long until it resets. Where a CPU backend is installed, switching the runtime to `cpu` keeps working with no quota at all; on this Space it is not installed (see *Two runtimes* below), so the options are to wait, or to sign in to Hugging Face for a larger allowance. ## Where the plan panel's numbers come from This block appears under the button as soon as a file is loaded, and it is written **before any model runs** β€” it is pure text extraction and regex, computed in `corpus_text.py` on every upload, every slider move and every checkbox change. ![The plan panel](docs/screenshots/06_plan_panel.png) **Line 1 β€” the extraction.** - **`12,729 bytes` on disk** β€” the length of the uploaded file, unmodified. - **`5,255-character skeleton`** β€” the result of `build_skeleton()`. Every `stream … endstream` body in the PDF is inflated with `zlib` where it is FlateDecode, capped at 4,096 characters, and kept if more than 60% of its bytes are printable ASCII. Control and binary residue is replaced with spaces and runs of four or more spaces collapse to three. So the number is smaller than the file for a compressed text document and much smaller for one full of images. - **`4 binary stream(s) dropped`** β€” streams that failed the 60%-printable test, i.e. genuine binary such as an image. Each is replaced in the skeleton by the literal `<>` rather than deleted, so its position is preserved. This count is exactly how many times that happened. - If the file is very large the line also says **truncated**: past a 120,000-character budget the skeleton keeps 45% from the head and the rest from the tail, because the corpus generator inserts payloads at both ends and a plain head truncation would lose most of them. **Line 2 β€” the triage.** `ANY_MARKER_RE` β€” a single alternation of all twelve families' structural patterns plus the framework and test-payload strings β€” is run over the whole skeleton. - **`1 region(s) carry a marker`** β€” every match becomes a Β±1,500-character window centred on the hit, and overlapping windows are merged so two markers 200 characters apart are read once rather than twice. This is the count after merging, so it is regions, not raw matches. - **`the remaining 1 cover the rest of the document`** β€” the sweep. The skeleton is tiled into 3,000-character windows in document order, and a tile is dropped if a marker region already covers half of it or more. This is what stops "clean" meaning "the model never looked there". With the sweep checkbox off, this part is absent and only the marker regions exist. - **`2 in total, cut into 1 batch(es) of at most 8`** β€” the two lists are concatenated, marker regions first in density order, and the result is sliced into batches of the size the *Regions per batch* slider is set to. Move the slider and this line re-computes immediately; the regions themselves do not change, only how they are grouped. - **`Structural signatures in the raw file: javascript_injection, ransomware_simulation`** β€” which families' regexes matched, run by `detect_markers()` against the **raw bytes**, not the skeleton. Two names appear here because the generator gives several families a JavaScript launcher, so a ransomware sample legitimately carries a JavaScript signature too. Two things this panel deliberately does not do. It does not say `javascript_injection` on its own as though that were a finding β€” plenty of harmless PDFs contain a `/JS (` token, and the word *signature* is doing real work in that sentence. And the ordering it produces decides **reading order only**: batch 1 is the most marker-dense, not the guilty one. The verdict below it is MiMo's alone, which is what the italic line under the counts is there to say. ## The three repos this is built on | Repo | What this Space takes from it | |---|---| | [Generated_Injected_PDFs_HARMLESS](https://huggingface.co/datasets/Cyber-security-final-project/Generated_Injected_PDFs_HARMLESS) | The 12 injection families and their structural signatures β€” the definition of what an attack looks like | | [HARMLESS_Synthetic_Injected_PDFs_EDA](https://huggingface.co/datasets/Cyber-security-final-project/HARMLESS_Synthetic_Injected_PDFs_EDA) | `build_skeleton`, `mask_leaks` and `payload_window` β€” how a PDF becomes the text a model reads | | [Evaluation_of_OpenSource_Models…](https://huggingface.co/datasets/Cyber-security-final-project/Evaluation_of_OpenSource_Models_for_PDF_Injection_Recognition) | Part A's embedding index and winning configuration; Part B's prompt, prefill and parser | The prompt, the MiMo prefill, the brace-counting parser and the whole text-extraction path are quoted **verbatim** from those notebooks. That is the correctness argument for quoting Part B's scores here at all: change how the text is extracted or how the question is asked, and the published numbers stop describing this program. ## The examples Thirteen example documents ship with the Space β€” **one per injection family, plus one clean control** β€” taken from the generation repo. They appear as a rail of buttons down the left edge, labelled by attack type: press **ransomware simulation** and that document loads into the uploader and is triaged exactly as an uploaded file would be. There is no separate code path for examples and nothing about them is pre-computed. **The files themselves stay anonymous.** On disk they are `Example_0` … `Example_12`, so the uploader shows a neutral filename and the document never announces its own answer. The buttons are labelled because a demo you can drive deliberately is more useful than a guessing game β€” and it costs nothing on the model side, since a filename never reaches the prompt. Only extracted text does. Two further precautions on the files: the numbering is shuffled with a fixed seed so `Example_0` is not simply the alphabetically-first family, and every file sits in a **12–15 KB band** so file size does not reveal which one is the clean control. An earlier draft had the clean file at 3 KB against 200 KB+ injected ones, which gave it away completely. Button labels are read from `examples/manifest.json`, written by the same script that copies the PDFs β€” so a button cannot end up pointing at the wrong family. Being small also matters practically: each example produces 2–8 regions, so **every one fits in a single batch** β€” one example is one GPU run covering the whole document. `examples/Example_Key.txt` records which is which, for whoever is marking this. It also notes that several files legitimately carry two structural signatures (the generator gives some families a JavaScript launcher), so `ransomware_simulation` and `dde_template_injection` also match `javascript_injection`, and `object_action_injection` also matches `shellcode_embedded_exe`. The clean control is there for the harder half of the claim: watching the app *not* flag something. ## Why MiMo, when Gemma scored higher Part B's actual winner is **Gemma-2-9B at F1 0.969**, and this app runs **MiMo-7B at F1 0.945** instead. That is a hosting decision, not a disagreement with the evaluation, and it comes down to three things a free Space cannot absorb: - **Gemma is gated.** It needs a Hugging Face account with Google's licence accepted, plus a read token. On a public Space that means the first thing a new visitor meets is a 403, or the app has to ask strangers to paste a token. MiMo downloads for anyone, with no account at all. - **Gemma is 2.6Γ— slower** β€” 10.95 s per window against MiMo's 4.18 s, measured in Part B on the same T4. On ZeroGPU a single grant is capped at 300 s and the whole scan plus the model load must fit inside it, so the slower model means roughly a third as many regions per run. - **Free ZeroGPU is about five minutes per day.** At Gemma's rate that is a couple of batches for a whole day; at MiMo's it is meaningfully more. The 9B model is also ~6 GB in 4-bit against MiMo's ~5 GB, which is not decisive on its own but points the same way. What it costs: **0.024 F1**, and family-naming accuracy drops from 63% to 43%. Both figures are stated in the interface rather than quietly rounded away. If the Space were ever moved to dedicated paid hardware, switching to Gemma would be a change to `mimo.py`'s repo constants and a token secret β€” the prompt, prefill and parser are shared and would not need touching. ## Two runtimes, chosen at startup A Space's hardware is not this code's decision, so `mimo.py` carries both paths and picks one at import time. Which is live is printed at the top of the interface and again at the foot of every report. **`gpu` β€” ZeroGPU, and Part B's own configuration.** The original BF16 checkpoint of `XiaomiMiMo/MiMo-7B-RL` quantised to 4-bit NF4 by `bitsandbytes`, greedy, 200 new tokens, batched at 8. Nothing about the arithmetic differs from the run that produced F1 0.945, so that figure describes this configuration. About 4 seconds per region, which is why the slider goes to 24. The whole scan happens inside a single `@spaces.GPU` call, because ZeroGPU grants and reclaims the device around each one and paying the model-load cost per region would dominate everything else. **`cpu` β€” a free CPU Space, where `bitsandbytes` cannot run at all** (it requires CUDA). The same base model runs as [`quantflex/MiMo-7B-RL-nomtp-Q4_K_M.gguf`](https://huggingface.co/quantflex/MiMo-7B-RL-nomtp-GGUF) (4.7 GB) through `llama.cpp`. That build has MiMo's multi-token-prediction layers removed, because `llama.cpp` cannot load them β€” MTP is a speculative-decoding accelerator that the ordinary forward pass does not use, so greedy output should be unaffected, but it is a real difference. **On this path, read F1 0.945 as the figure for the configuration Part B measured, not for the one running.** About two minutes per region on 2 vCPUs, so the slider stops at 6. The prompt, the prefill, the decoding parameters and the parser are byte-identical on both paths. **On this Space the CPU path is dormant, and that is not a choice.** `llama-cpp-python` cannot be installed here by either available route: the prebuilt wheels are tagged `linux_x86_64` but linked against **musl** while a Space runs on glibc (`libc.musl-x86_64.so.1: cannot open shared object file`), and PyPI ships no binary wheel at all β€” compiling the sdist exceeded the Space build limit with `Job timeout`. So `requirements.txt` installs neither, the runtime picker hides itself when only one backend is live, and the code path stays in `mimo.py` for any machine that does have a working llama.cpp. The runtime that remains is the one that reproduces Part B exactly, so what was lost is a fallback rather than a capability. **Where both are live, the picker matters.** A free Hugging Face account gets roughly **five minutes of ZeroGPU per day**, and the scheduler reserves a run's full requested duration up front rather than what it turns out to use β€” so a couple of batches can exhaust the day. When that happens the Space is not broken: switch the runtime to `cpu` and it keeps working, slowly and without any quota. That is also why one GPU grant is kept short (110s requested, 165s reserved) and a batch tops out at 8 regions. ## Triage, and why it exists **It triages instead of scanning everything.** MiMo reads a 3,000-character window in roughly two minutes on 2 vCPUs, and a real PDF has dozens of windows. So the same marker alternation that located the payload in the corpus is run over the whole skeleton, every hit becomes a candidate window with the identical Β±1,500-character shape, overlapping ones are merged, and the most marker-dense go to the model first. **The ranking decides reading order, never the verdict.** The report always says how many marked regions were left unread, so "clean" never overstates itself. A file with no marker anywhere yields exactly one marker candidate β€” the head of the document β€” which is byte-identical to what the corpus builder produced for a *clean* file. ### Batches, and the sweep Marker regions alone leave most of a file unread: the triage only knows the twelve families this project generated, so a payload shaped like none of them produces no marker and would sit in text the model never saw while the report said "clean". So after the marker regions, the rest of the skeleton is tiled into windows of the same size, and the whole list is cut into **batches sized to fit one run of the model** β€” one ZeroGPU grant, or a tolerable wait on CPU. You pick which batch to spend a run on, and the report always states how much is still unread. **The sweep regions do not inherit Part B's accuracy, and the app says so.** Part B only ever showed MiMo marker-centred windows or the head of a document. Handed an arbitrary mid-file content stream β€” a page of font-positioning operators β€” MiMo frequently does not answer at all: it carries on copying the input after the prefill, and the answer parses as unrecoverable, which scores as *not injected*. Those regions buy coverage of text that would otherwise never be looked at; a *clean* verdict on one is close to no evidence. The regions table labels every row `marker` or `sweep`, the report counts the sweep parse failures separately and explains them, and the sweep can be switched off to keep the app strictly inside the shape Part B measured. **Nothing is downloaded until it is needed.** The page comes up first; the 4.7 GB GGUF, the 550 MB embedding model and the 3 MB index arrive on the first scan and are cached after that. ## The numbers On the 1,100-document corpus Part B measured, MiMo-7B-RL scored: | | | |---|---| | F1 | **0.945** | | precision | 0.988 | | recall | 0.906 | | names the family correctly | 43.3% of files it caught | | false alarms | 10 of 200 clean files (5%) | | unparsable answers | 155 of 1,100 | **A detector that calls every file malicious scores F1 0.900 on this corpus**, because 82% of it is injected. Read 0.945 against 0.900, not against zero β€” it is a 5% relative improvement on doing no work at all. Gemma-2-9B scored 0.969 and is the actual Part B winner; MiMo is used here because it is ungated, needs no token, and is 2.6Γ— faster, which on a CPU is the difference between usable and not. Two limits worth stating plainly: - **The family is a suggestion, not a verdict** β€” right 43% of the time. The nearest known corpus files are shown beside it so the two can disagree in public. - **The nearest-file lookup is weak on purpose to report.** Part A's winning embedder reaches precision@5 of 35.6% against a 6.8% random baseline: fewer than 2 of the 5 files returned are the same kind of attack. Far better than chance, and not good. It is labelled *resemblance*, never *identification*. ## The files | File | What it does | |---|---| | `app.py` | The Gradio interface and the document-level report. No detection logic. | | `corpus_text.py` | PDF bytes β†’ skeleton β†’ candidate windows. Everything above `Triage` is verbatim from the EDA notebook. | | `mimo.py` | The prompt, the prefill, the parser (verbatim from Part B) and the llama.cpp runtime. | | `neighbours.py` | Part A's embedding index and the nearest-neighbour lookup, with a provenance assertion. | `neighbours.check_provenance()` asserts the embedder repo, prefix, dimension, normalisation and input column against Part A's own `part_a_results.json` before any lookup runs β€” a query embedded with the wrong model lands in a different space and returns meaningless neighbours silently, with no error anywhere. ## Secrets None. Every model used here is ungated.