Spaces:
Running on Zero
A newer version of the Gradio SDK is available: 6.24.0
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. Every screenshot below is of that Space as it actually runs β nothing is mocked up.
1. Open the Space
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.
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.
| 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.
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,
markerorsweep, 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
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.
Line 1 β the extraction.
12,729 byteson disk β the length of the uploaded file, unmodified.5,255-character skeletonβ the result ofbuild_skeleton(). Everystream β¦ endstreambody in the PDF is inflated withzlibwhere 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<<BINARY n bytes>>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 bydetect_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 | The 12 injection families and their structural signatures β the definition of what an attack looks like |
| 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⦠| 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
(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.





