Add files using upload-large-folder tool
Browse files- LICENSE +21 -0
- MODEL_CARD.md +110 -0
- README.md +110 -0
- binprov/__init__.py +37 -0
- binprov/__pycache__/__init__.cpython-312.pyc +0 -0
- binprov/__pycache__/corpus.cpython-312.pyc +0 -0
- binprov/__pycache__/data.cpython-312.pyc +0 -0
- binprov/__pycache__/discover.cpython-312.pyc +0 -0
- binprov/__pycache__/elf.cpython-312.pyc +0 -0
- binprov/__pycache__/metrics.cpython-312.pyc +0 -0
- binprov/__pycache__/model.cpython-312.pyc +0 -0
- binprov/__pycache__/provenance.cpython-312.pyc +0 -0
- binprov/__pycache__/vocab.cpython-312.pyc +0 -0
- binprov/__pycache__/vote.cpython-312.pyc +0 -0
- binprov/corpus.py +445 -0
- binprov/data.py +367 -0
- binprov/discover.py +217 -0
- binprov/elf.py +313 -0
- binprov/metrics.py +71 -0
- binprov/model.py +453 -0
- binprov/provenance.py +277 -0
- binprov/vocab.py +66 -0
- binprov/vote.py +125 -0
- binprov_config.json +10 -0
- environment.json +8 -0
- evidence/args.json +57 -0
- evidence/evaluation.json +50 -0
- evidence/probs.npz +3 -0
- evidence/result.json +141 -0
- head.json +8 -0
- inference.py +173 -0
- inference_config.json +5 -0
- labels.json +16 -0
- metadata.json +296 -0
- model/binprov_config.json +10 -0
- model/head.json +8 -0
- model/inference_config.json +5 -0
- model/labels.json +16 -0
- model/model.pt +3 -0
- requirements.txt +36 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2022 Viewer-HX
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
MODEL_CARD.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: binprov
|
| 4 |
+
tags:
|
| 5 |
+
- binary-analysis
|
| 6 |
+
- compilation-provenance
|
| 7 |
+
- optimization-level-classification
|
| 8 |
+
- pytorch
|
| 9 |
+
- roberta
|
| 10 |
+
metrics:
|
| 11 |
+
- accuracy
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# BinProv Opt4
|
| 15 |
+
|
| 16 |
+
`binprov` classifies the compiler optimization level of x86-64 ELF
|
| 17 |
+
binaries from raw `.text` bytes: O0, O1, O2, O3.
|
| 18 |
+
It follows the BinProv method and does not disassemble the input.
|
| 19 |
+
|
| 20 |
+
This is a BinProv-native PyTorch checkpoint. Its byte encoder is RoBERTa-shaped,
|
| 21 |
+
but its pooling and classification head are custom, so it is not compatible
|
| 22 |
+
with `transformers.AutoModel`. Use the bundled `inference.py`; no remote code or
|
| 23 |
+
`trust_remote_code` is involved.
|
| 24 |
+
|
| 25 |
+
## Model details
|
| 26 |
+
|
| 27 |
+
- Architecture: RoBERTa-style byte encoder, seq 2048 bytes, 12 layers x 768 hidden; 261-token byte vocabulary
|
| 28 |
+
- Pooling: `border` with 4 heads
|
| 29 |
+
- Input: 2048-byte windows at a 512-byte evaluation stride
|
| 30 |
+
- Output: four probabilities corresponding to O0, O1, O2, and O3
|
| 31 |
+
- Framework: PyTorch with Hugging Face Transformers components
|
| 32 |
+
- License: MIT
|
| 33 |
+
|
| 34 |
+
The model follows [BinProv: Binary Code Provenance Identification without
|
| 35 |
+
Disassembly](https://doi.org/10.1145/3545948.3545956), RAID 2022. This checkpoint
|
| 36 |
+
comes from a clean reproduction run of the repository's best single-model
|
| 37 |
+
recipe.
|
| 38 |
+
Evaluation (this export): sequence accuracy **84.2255%**. Binary-level accuracy **93.8830%**. A fresh training run's numbers vary by ~1-2 points with seed.
|
| 39 |
+
|
| 40 |
+
## Direct use
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
python -m venv .venv
|
| 44 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 45 |
+
pip install -r requirements.txt
|
| 46 |
+
|
| 47 |
+
python inference.py --model model --elf /path/to/binary.elf
|
| 48 |
+
python inference.py --model model --text-bytes raw_text.bin --json
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
The script extracts `.text` from an ELF or accepts already extracted bytes. It
|
| 52 |
+
evaluates overlapping 2048-byte inputs every 512 bytes, then averages the
|
| 53 |
+
per-window probabilities for one binary. It automatically selects CUDA, Apple
|
| 54 |
+
MPS, or CPU. Use `--batch-size` to trade memory for speed.
|
| 55 |
+
|
| 56 |
+
## Training data
|
| 57 |
+
|
| 58 |
+
The model was trained and evaluated on the x86-64 subset of BinKit Normal,
|
| 59 |
+
covering GCC and Clang binaries at optimization levels O0 through O3. Splits are
|
| 60 |
+
grouped by source program so the same program does not cross train and test.
|
| 61 |
+
Exact training arguments and environment versions are in `metadata.json` and
|
| 62 |
+
`evidence/args.json`.
|
| 63 |
+
|
| 64 |
+
## Evaluation
|
| 65 |
+
|
| 66 |
+
| metric | value |
|
| 67 |
+
|---|---:|
|
| 68 |
+
| sequence accuracy | **84.2255%** |
|
| 69 |
+
| balanced sequence accuracy | **83.0951%** |
|
| 70 |
+
| binary soft-vote accuracy | **93.8830%** |
|
| 71 |
+
| test windows | 116,321 |
|
| 72 |
+
| test binaries | 376 |
|
| 73 |
+
|
| 74 |
+
The sequence metric uses 512-byte target positions with 2048-byte overlapping
|
| 75 |
+
model inputs. Binary accuracy averages the saved per-window probabilities. The
|
| 76 |
+
recomputed evaluation in `evidence/evaluation.json` matches the recorded test
|
| 77 |
+
accuracy. Results can vary with retraining and should be compared on the same
|
| 78 |
+
program-grouped split.
|
| 79 |
+
|
| 80 |
+
## Intended use and limitations
|
| 81 |
+
|
| 82 |
+
This checkpoint is intended for research and analysis of compiler optimization
|
| 83 |
+
provenance in ordinary x86-64 ELF code produced by toolchains represented in
|
| 84 |
+
BinKit. It has not been validated as a general malware, authorship, attribution,
|
| 85 |
+
or security-decision system. Packed, obfuscated, self-modifying, non-ELF, other
|
| 86 |
+
architecture, and out-of-distribution toolchain inputs may be unreliable. The
|
| 87 |
+
softmax scores are not calibrated confidence estimates.
|
| 88 |
+
|
| 89 |
+
## Files
|
| 90 |
+
|
| 91 |
+
- `model/` — `model.pt`, `binprov_config.json`, `head.json` (BinProv checkpoint)
|
| 92 |
+
- `binprov/` — minimal source package needed by `inference.py`
|
| 93 |
+
- `requirements.txt` — runtime dependencies
|
| 94 |
+
- `labels.json` — class id -> label
|
| 95 |
+
- `metadata.json` — exact training args + eval results + environment
|
| 96 |
+
- `environment.json` — runtime versions
|
| 97 |
+
- `evidence/` — args, result, evaluation report, and saved probabilities
|
| 98 |
+
- `LICENSE` — repository license
|
| 99 |
+
|
| 100 |
+
## Citation
|
| 101 |
+
|
| 102 |
+
```bibtex
|
| 103 |
+
@inproceedings{he2022binprov,
|
| 104 |
+
title={BinProv: Binary Code Provenance Identification without Disassembly},
|
| 105 |
+
author={He, Xu and Wang, Shu and Xing, Yunlong and Feng, Pengbin and Wang, Haining and Li, Qi and Chen, Songqing and Sun, Kun},
|
| 106 |
+
booktitle={RAID},
|
| 107 |
+
year={2022},
|
| 108 |
+
doi={10.1145/3545948.3545956}
|
| 109 |
+
}
|
| 110 |
+
```
|
README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
library_name: binprov
|
| 4 |
+
tags:
|
| 5 |
+
- binary-analysis
|
| 6 |
+
- compilation-provenance
|
| 7 |
+
- optimization-level-classification
|
| 8 |
+
- pytorch
|
| 9 |
+
- roberta
|
| 10 |
+
metrics:
|
| 11 |
+
- accuracy
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# BinProv Opt4
|
| 15 |
+
|
| 16 |
+
`binprov` classifies the compiler optimization level of x86-64 ELF
|
| 17 |
+
binaries from raw `.text` bytes: O0, O1, O2, O3.
|
| 18 |
+
It follows the BinProv method and does not disassemble the input.
|
| 19 |
+
|
| 20 |
+
This is a BinProv-native PyTorch checkpoint. Its byte encoder is RoBERTa-shaped,
|
| 21 |
+
but its pooling and classification head are custom, so it is not compatible
|
| 22 |
+
with `transformers.AutoModel`. Use the bundled `inference.py`; no remote code or
|
| 23 |
+
`trust_remote_code` is involved.
|
| 24 |
+
|
| 25 |
+
## Model details
|
| 26 |
+
|
| 27 |
+
- Architecture: RoBERTa-style byte encoder, seq 2048 bytes, 12 layers x 768 hidden; 261-token byte vocabulary
|
| 28 |
+
- Pooling: `border` with 4 heads
|
| 29 |
+
- Input: 2048-byte windows at a 512-byte evaluation stride
|
| 30 |
+
- Output: four probabilities corresponding to O0, O1, O2, and O3
|
| 31 |
+
- Framework: PyTorch with Hugging Face Transformers components
|
| 32 |
+
- License: MIT
|
| 33 |
+
|
| 34 |
+
The model follows [BinProv: Binary Code Provenance Identification without
|
| 35 |
+
Disassembly](https://doi.org/10.1145/3545948.3545956), RAID 2022. This checkpoint
|
| 36 |
+
comes from a clean reproduction run of the repository's best single-model
|
| 37 |
+
recipe.
|
| 38 |
+
Evaluation (this export): sequence accuracy **84.2255%**. Binary-level accuracy **93.8830%**. A fresh training run's numbers vary by ~1-2 points with seed.
|
| 39 |
+
|
| 40 |
+
## Direct use
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
python -m venv .venv
|
| 44 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 45 |
+
pip install -r requirements.txt
|
| 46 |
+
|
| 47 |
+
python inference.py --model model --elf /path/to/binary.elf
|
| 48 |
+
python inference.py --model model --text-bytes raw_text.bin --json
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
The script extracts `.text` from an ELF or accepts already extracted bytes. It
|
| 52 |
+
evaluates overlapping 2048-byte inputs every 512 bytes, then averages the
|
| 53 |
+
per-window probabilities for one binary. It automatically selects CUDA, Apple
|
| 54 |
+
MPS, or CPU. Use `--batch-size` to trade memory for speed.
|
| 55 |
+
|
| 56 |
+
## Training data
|
| 57 |
+
|
| 58 |
+
The model was trained and evaluated on the x86-64 subset of BinKit Normal,
|
| 59 |
+
covering GCC and Clang binaries at optimization levels O0 through O3. Splits are
|
| 60 |
+
grouped by source program so the same program does not cross train and test.
|
| 61 |
+
Exact training arguments and environment versions are in `metadata.json` and
|
| 62 |
+
`evidence/args.json`.
|
| 63 |
+
|
| 64 |
+
## Evaluation
|
| 65 |
+
|
| 66 |
+
| metric | value |
|
| 67 |
+
|---|---:|
|
| 68 |
+
| sequence accuracy | **84.2255%** |
|
| 69 |
+
| balanced sequence accuracy | **83.0951%** |
|
| 70 |
+
| binary soft-vote accuracy | **93.8830%** |
|
| 71 |
+
| test windows | 116,321 |
|
| 72 |
+
| test binaries | 376 |
|
| 73 |
+
|
| 74 |
+
The sequence metric uses 512-byte target positions with 2048-byte overlapping
|
| 75 |
+
model inputs. Binary accuracy averages the saved per-window probabilities. The
|
| 76 |
+
recomputed evaluation in `evidence/evaluation.json` matches the recorded test
|
| 77 |
+
accuracy. Results can vary with retraining and should be compared on the same
|
| 78 |
+
program-grouped split.
|
| 79 |
+
|
| 80 |
+
## Intended use and limitations
|
| 81 |
+
|
| 82 |
+
This checkpoint is intended for research and analysis of compiler optimization
|
| 83 |
+
provenance in ordinary x86-64 ELF code produced by toolchains represented in
|
| 84 |
+
BinKit. It has not been validated as a general malware, authorship, attribution,
|
| 85 |
+
or security-decision system. Packed, obfuscated, self-modifying, non-ELF, other
|
| 86 |
+
architecture, and out-of-distribution toolchain inputs may be unreliable. The
|
| 87 |
+
softmax scores are not calibrated confidence estimates.
|
| 88 |
+
|
| 89 |
+
## Files
|
| 90 |
+
|
| 91 |
+
- `model/` — `model.pt`, `binprov_config.json`, `head.json` (BinProv checkpoint)
|
| 92 |
+
- `binprov/` — minimal source package needed by `inference.py`
|
| 93 |
+
- `requirements.txt` — runtime dependencies
|
| 94 |
+
- `labels.json` — class id -> label
|
| 95 |
+
- `metadata.json` — exact training args + eval results + environment
|
| 96 |
+
- `environment.json` — runtime versions
|
| 97 |
+
- `evidence/` — args, result, evaluation report, and saved probabilities
|
| 98 |
+
- `LICENSE` — repository license
|
| 99 |
+
|
| 100 |
+
## Citation
|
| 101 |
+
|
| 102 |
+
```bibtex
|
| 103 |
+
@inproceedings{he2022binprov,
|
| 104 |
+
title={BinProv: Binary Code Provenance Identification without Disassembly},
|
| 105 |
+
author={He, Xu and Wang, Shu and Xing, Yunlong and Feng, Pengbin and Wang, Haining and Li, Qi and Chen, Songqing and Sun, Kun},
|
| 106 |
+
booktitle={RAID},
|
| 107 |
+
year={2022},
|
| 108 |
+
doi={10.1145/3545948.3545956}
|
| 109 |
+
}
|
| 110 |
+
```
|
binprov/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BinProv — compilation provenance identification straight from binary code.
|
| 2 |
+
|
| 3 |
+
A re-implementation of the experiment pipeline from:
|
| 4 |
+
|
| 5 |
+
Xu He, Shu Wang, Yunlong Xing, Pengbin Feng, Haining Wang, Qi Li,
|
| 6 |
+
Songqing Chen, Kun Sun. "BinProv: Binary Code Provenance Identification
|
| 7 |
+
without Disassembly." RAID 2022. https://doi.org/10.1145/3545948.3545956
|
| 8 |
+
|
| 9 |
+
The pipeline, mirroring the paper's four stages (§3):
|
| 10 |
+
|
| 11 |
+
1. pre-process .text section -> fixed-length byte sequences
|
| 12 |
+
2. embed byte-level RoBERTa/BERT encoder, MLM pre-trained
|
| 13 |
+
3. classify 2-layer FC head, fine-tuned per provenance task
|
| 14 |
+
4. joint infer majority vote over sequences of a function / a binary
|
| 15 |
+
|
| 16 |
+
The original code was lost; this rebuild follows the paper and marks every place
|
| 17 |
+
where the paper leaves a detail unspecified — see docs/REPRODUCTION.md.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
__version__ = "0.1.0"
|
| 23 |
+
|
| 24 |
+
# Only the torch-free modules are imported eagerly, so that corpus building and
|
| 25 |
+
# inspection work in an environment without torch/transformers installed.
|
| 26 |
+
# `binprov.model` and `binprov.data` are imported directly where needed.
|
| 27 |
+
from . import corpus, discover, elf, metrics, provenance, vocab, vote
|
| 28 |
+
|
| 29 |
+
__all__ = [
|
| 30 |
+
"corpus",
|
| 31 |
+
"discover",
|
| 32 |
+
"elf",
|
| 33 |
+
"metrics",
|
| 34 |
+
"provenance",
|
| 35 |
+
"vocab",
|
| 36 |
+
"vote",
|
| 37 |
+
]
|
binprov/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (1.31 kB). View file
|
|
|
binprov/__pycache__/corpus.cpython-312.pyc
ADDED
|
Binary file (22.2 kB). View file
|
|
|
binprov/__pycache__/data.cpython-312.pyc
ADDED
|
Binary file (24.3 kB). View file
|
|
|
binprov/__pycache__/discover.cpython-312.pyc
ADDED
|
Binary file (9.62 kB). View file
|
|
|
binprov/__pycache__/elf.cpython-312.pyc
ADDED
|
Binary file (13.2 kB). View file
|
|
|
binprov/__pycache__/metrics.cpython-312.pyc
ADDED
|
Binary file (5.86 kB). View file
|
|
|
binprov/__pycache__/model.cpython-312.pyc
ADDED
|
Binary file (28.5 kB). View file
|
|
|
binprov/__pycache__/provenance.cpython-312.pyc
ADDED
|
Binary file (12.5 kB). View file
|
|
|
binprov/__pycache__/vocab.cpython-312.pyc
ADDED
|
Binary file (2.76 kB). View file
|
|
|
binprov/__pycache__/vote.cpython-312.pyc
ADDED
|
Binary file (6.51 kB). View file
|
|
|
binprov/corpus.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The packed corpus: the only thing BinProv needs to keep on disk.
|
| 2 |
+
|
| 3 |
+
Motivation is disk space. A BinKit binary averages ~200 KB, but BinProv reads
|
| 4 |
+
nothing except the ``.text`` bytes and (for function-level voting) the function
|
| 5 |
+
boundaries. So corpus building concatenates every ``.text`` section into one
|
| 6 |
+
flat ``uint8`` file and records offsets alongside — after which **the binaries
|
| 7 |
+
themselves can be deleted**. In practice that is a 3-5x reduction versus keeping
|
| 8 |
+
the ELF files, and it makes training I/O a single memory-mapped sequential read.
|
| 9 |
+
|
| 10 |
+
On-disk layout of a corpus directory::
|
| 11 |
+
|
| 12 |
+
text.u8 all .text sections concatenated, no padding
|
| 13 |
+
binaries.jsonl.gz one JSON record per binary (offset, length, labels, funcs)
|
| 14 |
+
meta.json corpus-level counts, label tallies, build parameters
|
| 15 |
+
splits/<name>.json binary-id lists for train/test/pretrain
|
| 16 |
+
|
| 17 |
+
Sequences (the paper's fixed-length 512-byte units, §3.1) are *not* stored.
|
| 18 |
+
They are cut on the fly by :meth:`Corpus.sequences`, so changing the sequence
|
| 19 |
+
length or stride costs nothing and needs no rebuild.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import gzip
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import random
|
| 28 |
+
from dataclasses import asdict, dataclass, field
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
TEXT_FILE = "text.u8"
|
| 34 |
+
INDEX_FILE = "binaries.jsonl.gz"
|
| 35 |
+
META_FILE = "meta.json"
|
| 36 |
+
SPLIT_DIR = "splits"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class BinaryRecord:
|
| 41 |
+
"""One binary's slice of the packed corpus."""
|
| 42 |
+
|
| 43 |
+
bid: int
|
| 44 |
+
path: str # original path, for traceability only
|
| 45 |
+
package: str # e.g. "coreutils-8.24"
|
| 46 |
+
program: str # e.g. "ls" — the grouping key for leak-free splits
|
| 47 |
+
labels: dict # see provenance.Labels.as_dict()
|
| 48 |
+
text_off: int # offset into text.u8
|
| 49 |
+
text_len: int
|
| 50 |
+
text_vaddr: int # virtual address of .text[0], for symbol mapping
|
| 51 |
+
arch_elf: str # architecture as read from the ELF header
|
| 52 |
+
stripped: bool
|
| 53 |
+
functions: list = field(default_factory=list) # [[off, size], ...] within .text
|
| 54 |
+
func_names: list = field(default_factory=list) # parallel to functions, may be empty
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def group(self) -> str:
|
| 58 |
+
"""Split-grouping key: all compiled variants of one program share it."""
|
| 59 |
+
return f"{self.package}/{self.program}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
+
# writing
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class CorpusWriter:
|
| 68 |
+
"""Streams ``.text`` sections into a packed corpus.
|
| 69 |
+
|
| 70 |
+
Use as a context manager. ``text.u8`` is appended to as we go, so peak
|
| 71 |
+
memory stays at one binary regardless of corpus size.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
def __init__(self, out_dir: str | os.PathLike, *, build_params: dict | None = None):
|
| 75 |
+
self.dir = Path(out_dir)
|
| 76 |
+
self.dir.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
self._text = open(self.dir / TEXT_FILE, "wb")
|
| 78 |
+
self._index = gzip.open(self.dir / INDEX_FILE, "wt", compresslevel=6)
|
| 79 |
+
self._offset = 0
|
| 80 |
+
self._n = 0
|
| 81 |
+
self._build_params = dict(build_params or {})
|
| 82 |
+
self._label_counts: dict[str, dict[str, int]] = {}
|
| 83 |
+
|
| 84 |
+
def add(
|
| 85 |
+
self,
|
| 86 |
+
text: bytes,
|
| 87 |
+
*,
|
| 88 |
+
path: str,
|
| 89 |
+
package: str,
|
| 90 |
+
program: str,
|
| 91 |
+
labels: dict,
|
| 92 |
+
text_vaddr: int,
|
| 93 |
+
arch_elf: str,
|
| 94 |
+
stripped: bool,
|
| 95 |
+
functions: list | None = None,
|
| 96 |
+
func_names: list | None = None,
|
| 97 |
+
) -> BinaryRecord:
|
| 98 |
+
rec = BinaryRecord(
|
| 99 |
+
bid=self._n,
|
| 100 |
+
path=path,
|
| 101 |
+
package=package,
|
| 102 |
+
program=program,
|
| 103 |
+
labels=labels,
|
| 104 |
+
text_off=self._offset,
|
| 105 |
+
text_len=len(text),
|
| 106 |
+
text_vaddr=text_vaddr,
|
| 107 |
+
arch_elf=arch_elf,
|
| 108 |
+
stripped=stripped,
|
| 109 |
+
functions=functions or [],
|
| 110 |
+
func_names=func_names or [],
|
| 111 |
+
)
|
| 112 |
+
self._text.write(text)
|
| 113 |
+
self._index.write(json.dumps(asdict(rec), separators=(",", ":")) + "\n")
|
| 114 |
+
self._offset += len(text)
|
| 115 |
+
self._n += 1
|
| 116 |
+
for key in ("arch", "compiler", "opt", "extra", "obfuscation"):
|
| 117 |
+
val = labels.get(key)
|
| 118 |
+
if val is not None:
|
| 119 |
+
self._label_counts.setdefault(key, {})
|
| 120 |
+
self._label_counts[key][val] = self._label_counts[key].get(val, 0) + 1
|
| 121 |
+
return rec
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def num_binaries(self) -> int:
|
| 125 |
+
return self._n
|
| 126 |
+
|
| 127 |
+
@property
|
| 128 |
+
def num_bytes(self) -> int:
|
| 129 |
+
return self._offset
|
| 130 |
+
|
| 131 |
+
def close(self) -> None:
|
| 132 |
+
self._text.close()
|
| 133 |
+
self._index.close()
|
| 134 |
+
meta = {
|
| 135 |
+
"num_binaries": self._n,
|
| 136 |
+
"num_text_bytes": self._offset,
|
| 137 |
+
"label_counts": self._label_counts,
|
| 138 |
+
"build_params": self._build_params,
|
| 139 |
+
}
|
| 140 |
+
(self.dir / META_FILE).write_text(json.dumps(meta, indent=2) + "\n")
|
| 141 |
+
|
| 142 |
+
def __enter__(self) -> CorpusWriter:
|
| 143 |
+
return self
|
| 144 |
+
|
| 145 |
+
def __exit__(self, *exc) -> None:
|
| 146 |
+
self.close()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
# reading
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@dataclass
|
| 155 |
+
class SequenceIndex:
|
| 156 |
+
"""A set of fixed-length byte sequences cut out of a corpus.
|
| 157 |
+
|
| 158 |
+
Arrays are parallel and all the same length:
|
| 159 |
+
|
| 160 |
+
bid which binary each sequence came from
|
| 161 |
+
start absolute offset into ``text.u8``
|
| 162 |
+
length valid bytes (< seq_len only for the tail of a unit)
|
| 163 |
+
unit index into ``record.functions``, or -1 for a binary-level cut
|
| 164 |
+
|
| 165 |
+
``unit`` is what makes function-level majority voting (§3.4) possible: it
|
| 166 |
+
identifies the entity a sequence votes for.
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
bid: np.ndarray
|
| 170 |
+
start: np.ndarray
|
| 171 |
+
length: np.ndarray
|
| 172 |
+
unit: np.ndarray
|
| 173 |
+
seq_len: int
|
| 174 |
+
level: str # "binary" or "function"
|
| 175 |
+
|
| 176 |
+
def __len__(self) -> int:
|
| 177 |
+
return int(self.bid.shape[0])
|
| 178 |
+
|
| 179 |
+
def group_ids(self) -> np.ndarray:
|
| 180 |
+
"""Integer id of the entity each sequence votes for.
|
| 181 |
+
|
| 182 |
+
Binary level: one group per binary. Function level: one group per
|
| 183 |
+
(binary, function) pair.
|
| 184 |
+
"""
|
| 185 |
+
if self.level == "binary":
|
| 186 |
+
keys = self.bid.astype(np.int64)
|
| 187 |
+
else:
|
| 188 |
+
keys = self.bid.astype(np.int64) * (2**24) + (self.unit.astype(np.int64) + 1)
|
| 189 |
+
# dense re-indexing keeps downstream bincount cheap
|
| 190 |
+
_, inverse = np.unique(keys, return_inverse=True)
|
| 191 |
+
return inverse.astype(np.int64)
|
| 192 |
+
|
| 193 |
+
def subset(self, mask: np.ndarray) -> SequenceIndex:
|
| 194 |
+
return SequenceIndex(
|
| 195 |
+
bid=self.bid[mask],
|
| 196 |
+
start=self.start[mask],
|
| 197 |
+
length=self.length[mask],
|
| 198 |
+
unit=self.unit[mask],
|
| 199 |
+
seq_len=self.seq_len,
|
| 200 |
+
level=self.level,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class Corpus:
|
| 205 |
+
"""Read-only view over a packed corpus directory."""
|
| 206 |
+
|
| 207 |
+
def __init__(self, path: str | os.PathLike):
|
| 208 |
+
self.dir = Path(path)
|
| 209 |
+
if not (self.dir / TEXT_FILE).exists():
|
| 210 |
+
raise FileNotFoundError(f"{self.dir} is not a corpus (no {TEXT_FILE})")
|
| 211 |
+
self.meta = json.loads((self.dir / META_FILE).read_text())
|
| 212 |
+
self.records: list[BinaryRecord] = []
|
| 213 |
+
with gzip.open(self.dir / INDEX_FILE, "rt") as fh:
|
| 214 |
+
for line in fh:
|
| 215 |
+
self.records.append(BinaryRecord(**json.loads(line)))
|
| 216 |
+
# np.memmap keeps the whole thing out of RAM; the OS page cache does the
|
| 217 |
+
# right thing for the sequential-ish access pattern of training.
|
| 218 |
+
self.text = np.memmap(self.dir / TEXT_FILE, dtype=np.uint8, mode="r")
|
| 219 |
+
|
| 220 |
+
def __len__(self) -> int:
|
| 221 |
+
return len(self.records)
|
| 222 |
+
|
| 223 |
+
def __repr__(self) -> str:
|
| 224 |
+
mb = self.text.shape[0] / 1e6
|
| 225 |
+
return f"<Corpus {self.dir.name}: {len(self.records)} binaries, {mb:.1f} MB .text>"
|
| 226 |
+
|
| 227 |
+
# -- selection ---------------------------------------------------------
|
| 228 |
+
|
| 229 |
+
def filter_bids(self, **constraints) -> list[int]:
|
| 230 |
+
"""Binary ids whose labels match every constraint.
|
| 231 |
+
|
| 232 |
+
A constraint value may be a scalar or a collection; ``None`` means "no
|
| 233 |
+
constraint". Example::
|
| 234 |
+
|
| 235 |
+
corpus.filter_bids(arch="x86_64", extra="normal", opt=("O2", "O3"))
|
| 236 |
+
"""
|
| 237 |
+
out = []
|
| 238 |
+
for rec in self.records:
|
| 239 |
+
ok = True
|
| 240 |
+
for key, want in constraints.items():
|
| 241 |
+
if want is None:
|
| 242 |
+
continue
|
| 243 |
+
got = rec.labels.get(key)
|
| 244 |
+
if isinstance(want, (list, tuple, set, frozenset)):
|
| 245 |
+
if got not in want:
|
| 246 |
+
ok = False
|
| 247 |
+
break
|
| 248 |
+
elif got != want:
|
| 249 |
+
ok = False
|
| 250 |
+
break
|
| 251 |
+
if ok:
|
| 252 |
+
out.append(rec.bid)
|
| 253 |
+
return out
|
| 254 |
+
|
| 255 |
+
# -- sequence cutting --------------------------------------------------
|
| 256 |
+
|
| 257 |
+
def sequences(
|
| 258 |
+
self,
|
| 259 |
+
*,
|
| 260 |
+
seq_len: int = 512,
|
| 261 |
+
stride: int | None = None,
|
| 262 |
+
level: str = "binary",
|
| 263 |
+
bids: list[int] | None = None,
|
| 264 |
+
min_bytes: int = 16,
|
| 265 |
+
max_seqs_per_binary: int | None = None,
|
| 266 |
+
function_context: bool = False,
|
| 267 |
+
rng: random.Random | None = None,
|
| 268 |
+
) -> SequenceIndex:
|
| 269 |
+
"""Cut the corpus into fixed-length byte sequences.
|
| 270 |
+
|
| 271 |
+
Args:
|
| 272 |
+
seq_len: bytes per sequence (paper uses 512, a multiple of 8 so that
|
| 273 |
+
fixed-width ARM/MIPS instructions stay aligned).
|
| 274 |
+
stride: step between sequence starts. Defaults to ``seq_len``
|
| 275 |
+
(non-overlapping, as in §3.1). A smaller stride is used for
|
| 276 |
+
function-level evaluation, where most functions are shorter than
|
| 277 |
+
one sequence and non-overlapping cuts would give nothing to vote
|
| 278 |
+
over — see docs/REPRODUCTION.md.
|
| 279 |
+
level: ``"binary"`` cuts the whole ``.text``; ``"function"`` cuts
|
| 280 |
+
each function separately so no sequence straddles a boundary.
|
| 281 |
+
min_bytes: drop trailing fragments shorter than this.
|
| 282 |
+
max_seqs_per_binary: uniformly subsample when a binary is huge.
|
| 283 |
+
Keeps a corpus-wide cap on training cost without biasing toward
|
| 284 |
+
long binaries.
|
| 285 |
+
function_context: at ``level="function"``, give a function shorter
|
| 286 |
+
than ``seq_len`` a full-length window centred on it rather than
|
| 287 |
+
its own bytes padded. See the comment at the use site: most
|
| 288 |
+
functions are far shorter than one sequence, so without this the
|
| 289 |
+
function level measures a harder input distribution than the
|
| 290 |
+
sequence level rather than a voted one.
|
| 291 |
+
"""
|
| 292 |
+
if seq_len <= 0:
|
| 293 |
+
raise ValueError("seq_len must be positive")
|
| 294 |
+
step = stride or seq_len
|
| 295 |
+
if step <= 0:
|
| 296 |
+
raise ValueError("stride must be positive")
|
| 297 |
+
if level not in ("binary", "function"):
|
| 298 |
+
raise ValueError(f"level must be 'binary' or 'function', got {level!r}")
|
| 299 |
+
|
| 300 |
+
wanted = self.records if bids is None else [self.records[b] for b in bids]
|
| 301 |
+
rng = rng or random.Random(0)
|
| 302 |
+
b_out: list[int] = []
|
| 303 |
+
s_out: list[int] = []
|
| 304 |
+
l_out: list[int] = []
|
| 305 |
+
u_out: list[int] = []
|
| 306 |
+
|
| 307 |
+
for rec in wanted:
|
| 308 |
+
if level == "binary":
|
| 309 |
+
spans = [(-1, 0, rec.text_len)]
|
| 310 |
+
else:
|
| 311 |
+
spans = [(i, off, size) for i, (off, size) in enumerate(rec.functions)]
|
| 312 |
+
|
| 313 |
+
local: list[tuple[int, int, int]] = [] # (unit, start_abs, length)
|
| 314 |
+
for unit, span_off, span_len in spans:
|
| 315 |
+
if function_context and unit >= 0 and span_len < seq_len:
|
| 316 |
+
# A short function padded to seq_len sees far less context
|
| 317 |
+
# than an ordinary sequence, which makes it *harder*, not
|
| 318 |
+
# easier -- and in BinKit 81.5% of functions are shorter than
|
| 319 |
+
# 512 bytes (median 127), so this is the common case. Centre a
|
| 320 |
+
# full-length window on the function instead, borrowing
|
| 321 |
+
# neighbouring bytes and clamping to the section. The label is
|
| 322 |
+
# still the function's, and provenance is a property of the
|
| 323 |
+
# whole binary, so the borrowed bytes carry the same label.
|
| 324 |
+
centre = span_off + span_len // 2
|
| 325 |
+
start = max(0, min(centre - seq_len // 2, rec.text_len - seq_len))
|
| 326 |
+
n = min(seq_len, rec.text_len)
|
| 327 |
+
local.append((unit, rec.text_off + max(0, start), n))
|
| 328 |
+
continue
|
| 329 |
+
pos = 0
|
| 330 |
+
while pos < span_len:
|
| 331 |
+
n = min(seq_len, span_len - pos)
|
| 332 |
+
# keep a short unit whole (a 40-byte helper function is a
|
| 333 |
+
# legitimate sample), but drop a mere tail fragment
|
| 334 |
+
if n < min_bytes and pos > 0:
|
| 335 |
+
break
|
| 336 |
+
local.append((unit, rec.text_off + span_off + pos, n))
|
| 337 |
+
if n < seq_len:
|
| 338 |
+
break
|
| 339 |
+
pos += step
|
| 340 |
+
if max_seqs_per_binary is not None and len(local) > max_seqs_per_binary:
|
| 341 |
+
local = rng.sample(local, max_seqs_per_binary)
|
| 342 |
+
local.sort(key=lambda t: t[1])
|
| 343 |
+
for unit, start, n in local:
|
| 344 |
+
b_out.append(rec.bid)
|
| 345 |
+
s_out.append(start)
|
| 346 |
+
l_out.append(n)
|
| 347 |
+
u_out.append(unit)
|
| 348 |
+
|
| 349 |
+
return SequenceIndex(
|
| 350 |
+
bid=np.asarray(b_out, dtype=np.int32),
|
| 351 |
+
start=np.asarray(s_out, dtype=np.int64),
|
| 352 |
+
length=np.asarray(l_out, dtype=np.int32),
|
| 353 |
+
unit=np.asarray(u_out, dtype=np.int32),
|
| 354 |
+
seq_len=seq_len,
|
| 355 |
+
level=level,
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# -- splits ------------------------------------------------------------
|
| 359 |
+
|
| 360 |
+
def make_splits(
|
| 361 |
+
self,
|
| 362 |
+
*,
|
| 363 |
+
train_ratio: float = 0.8,
|
| 364 |
+
seed: int = 1234,
|
| 365 |
+
group_by: str = "program",
|
| 366 |
+
bids: list[int] | None = None,
|
| 367 |
+
) -> dict[str, list[int]]:
|
| 368 |
+
"""Split binary ids into train/test.
|
| 369 |
+
|
| 370 |
+
The paper splits 8:2 and states that train and test must not overlap so
|
| 371 |
+
that generalization is measured honestly (§4.1). ``group_by="program"``
|
| 372 |
+
goes one step further: every compiled variant of a program (all
|
| 373 |
+
compilers, all optimization levels) lands on the same side, so the model
|
| 374 |
+
cannot recognise a test program it has already seen at another
|
| 375 |
+
optimization level. Use ``group_by="binary"`` for the looser
|
| 376 |
+
binary-level split.
|
| 377 |
+
"""
|
| 378 |
+
pool = self.records if bids is None else [self.records[b] for b in bids]
|
| 379 |
+
rng = random.Random(seed)
|
| 380 |
+
if group_by == "binary":
|
| 381 |
+
keys = [str(r.bid) for r in pool]
|
| 382 |
+
elif group_by == "program":
|
| 383 |
+
keys = [r.group for r in pool]
|
| 384 |
+
elif group_by == "package":
|
| 385 |
+
keys = [r.package for r in pool]
|
| 386 |
+
else:
|
| 387 |
+
raise ValueError(f"unknown group_by {group_by!r}")
|
| 388 |
+
|
| 389 |
+
uniq = sorted(set(keys))
|
| 390 |
+
rng.shuffle(uniq)
|
| 391 |
+
n_train = int(round(len(uniq) * train_ratio))
|
| 392 |
+
train_keys = set(uniq[:n_train])
|
| 393 |
+
train, test = [], []
|
| 394 |
+
for rec, key in zip(pool, keys):
|
| 395 |
+
(train if key in train_keys else test).append(rec.bid)
|
| 396 |
+
return {"train": train, "test": test}
|
| 397 |
+
|
| 398 |
+
def pretrain_bids(
|
| 399 |
+
self,
|
| 400 |
+
*,
|
| 401 |
+
per_package: int = 1,
|
| 402 |
+
restrict_to: list[int] | None = None,
|
| 403 |
+
seed: int = 1234,
|
| 404 |
+
) -> list[int]:
|
| 405 |
+
"""Select the MLM pre-training set (paper §4.1).
|
| 406 |
+
|
| 407 |
+
"We first construct the pre-training set by selecting at least one
|
| 408 |
+
binary (2 x 4 variants) from each software project." So: pick
|
| 409 |
+
``per_package`` programs per package and take *all* their compiled
|
| 410 |
+
variants, giving broad coverage of projects at modest size.
|
| 411 |
+
|
| 412 |
+
``restrict_to`` should normally be the training split — pre-training on
|
| 413 |
+
binaries that later appear in the test set would leak, even though the
|
| 414 |
+
MLM task has no labels.
|
| 415 |
+
"""
|
| 416 |
+
allowed = None if restrict_to is None else set(restrict_to)
|
| 417 |
+
by_pkg: dict[str, dict[str, list[int]]] = {}
|
| 418 |
+
for rec in self.records:
|
| 419 |
+
if allowed is not None and rec.bid not in allowed:
|
| 420 |
+
continue
|
| 421 |
+
by_pkg.setdefault(rec.package, {}).setdefault(rec.program, []).append(rec.bid)
|
| 422 |
+
|
| 423 |
+
rng = random.Random(seed)
|
| 424 |
+
out: list[int] = []
|
| 425 |
+
for pkg in sorted(by_pkg):
|
| 426 |
+
programs = sorted(by_pkg[pkg])
|
| 427 |
+
rng.shuffle(programs)
|
| 428 |
+
for prog in programs[:per_package]:
|
| 429 |
+
out.extend(by_pkg[pkg][prog])
|
| 430 |
+
return sorted(out)
|
| 431 |
+
|
| 432 |
+
def save_splits(self, name: str, splits: dict[str, list[int]]) -> Path:
|
| 433 |
+
d = self.dir / SPLIT_DIR
|
| 434 |
+
d.mkdir(exist_ok=True)
|
| 435 |
+
path = d / f"{name}.json"
|
| 436 |
+
path.write_text(json.dumps({k: sorted(v) for k, v in splits.items()}, indent=1))
|
| 437 |
+
return path
|
| 438 |
+
|
| 439 |
+
def load_splits(self, name: str) -> dict[str, list[int]]:
|
| 440 |
+
path = self.dir / SPLIT_DIR / f"{name}.json"
|
| 441 |
+
if not path.exists():
|
| 442 |
+
raise FileNotFoundError(
|
| 443 |
+
f"no split {name!r} in {self.dir}; create one with scripts/build_corpus.py"
|
| 444 |
+
)
|
| 445 |
+
return json.loads(path.read_text())
|
binprov/data.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Torch datasets and collators over a packed corpus.
|
| 2 |
+
|
| 3 |
+
The dataset returns raw byte slices; tokenization and MLM masking happen in the
|
| 4 |
+
collator, vectorised over the batch. That keeps DataLoader workers doing almost
|
| 5 |
+
nothing but memory-mapped reads, and it makes the masking *dynamic*: a fresh
|
| 6 |
+
random mask every time a sequence is drawn, which is what the paper means by
|
| 7 |
+
"these masked bytes are different at each epoch" (§4.1).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
from torch.utils.data import Dataset
|
| 15 |
+
|
| 16 |
+
from .corpus import Corpus, SequenceIndex
|
| 17 |
+
from .provenance import Task
|
| 18 |
+
from .vocab import BYTE_OFFSET, EOS_ID, BOS_ID, MASK_ID, PAD_ID, VOCAB_SIZE
|
| 19 |
+
|
| 20 |
+
IGNORE_INDEX = -100
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def sequence_labels(corpus: Corpus, index: SequenceIndex, task: Task) -> np.ndarray:
|
| 24 |
+
"""Per-sequence class index, ``-1`` where the binary is out of task scope."""
|
| 25 |
+
per_bid = np.full(len(corpus.records), -1, dtype=np.int64)
|
| 26 |
+
for rec in corpus.records:
|
| 27 |
+
lab = task.label_of(rec.labels)
|
| 28 |
+
if lab is not None:
|
| 29 |
+
per_bid[rec.bid] = lab
|
| 30 |
+
return per_bid[index.bid]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def drop_unlabelled(index: SequenceIndex, labels: np.ndarray) -> tuple[SequenceIndex, np.ndarray]:
|
| 34 |
+
"""Filter out sequences whose binary the task does not cover.
|
| 35 |
+
|
| 36 |
+
Needed for the O0/O1 and O2/O3 tasks of Table 4, which each use half the
|
| 37 |
+
optimization levels.
|
| 38 |
+
"""
|
| 39 |
+
keep = labels >= 0
|
| 40 |
+
return index.subset(keep), labels[keep]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ByteSequenceDataset(Dataset):
|
| 44 |
+
"""Fixed-length byte sequences cut from a corpus.
|
| 45 |
+
|
| 46 |
+
Yields ``(bytes, label, position)`` where ``position`` is the sequence's
|
| 47 |
+
index in the :class:`SequenceIndex` — evaluation needs it to map predictions
|
| 48 |
+
back to the voting group.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(self, corpus: Corpus, index: SequenceIndex, labels: np.ndarray | None = None):
|
| 52 |
+
if labels is not None and len(labels) != len(index):
|
| 53 |
+
raise ValueError(f"labels ({len(labels)}) and index ({len(index)}) disagree")
|
| 54 |
+
self.text = corpus.text
|
| 55 |
+
self.index = index
|
| 56 |
+
self.labels = labels
|
| 57 |
+
|
| 58 |
+
def __len__(self) -> int:
|
| 59 |
+
return len(self.index)
|
| 60 |
+
|
| 61 |
+
def __getitem__(self, i: int):
|
| 62 |
+
start = int(self.index.start[i])
|
| 63 |
+
n = int(self.index.length[i])
|
| 64 |
+
chunk = np.asarray(self.text[start : start + n], dtype=np.uint8)
|
| 65 |
+
label = -1 if self.labels is None else int(self.labels[i])
|
| 66 |
+
return chunk, label, i
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class JitteredByteSequenceDataset(ByteSequenceDataset):
|
| 71 |
+
"""Training sequences whose window start is re-drawn every time they are read.
|
| 72 |
+
|
| 73 |
+
Motivation: a non-overlapping cut (§3.1) gives a *fixed* set of windows, so
|
| 74 |
+
an N-epoch fine-tune shows the model the identical byte strings N times. The
|
| 75 |
+
corpus holds 211 MB of ``.text`` but only 315,710 distinct 512-byte windows,
|
| 76 |
+
and where a window happens to begin is an artefact of the cut, not a
|
| 77 |
+
property of the code. Re-drawing the start turns those 315,710 samples into
|
| 78 |
+
a continuum, and it also stops the model keying on "byte 0 of a window is
|
| 79 |
+
usually an instruction boundary" -- which is true of the training cut and
|
| 80 |
+
only accidentally true at deployment.
|
| 81 |
+
|
| 82 |
+
``jitter`` is the maximum shift in bytes. ``jitter < 0`` means "start
|
| 83 |
+
anywhere in this binary's ``.text``", the strongest form. The window is
|
| 84 |
+
always clamped to stay inside the binary it came from, so a jittered sample
|
| 85 |
+
never mixes two binaries and its label stays correct.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
corpus: Corpus,
|
| 91 |
+
index: SequenceIndex,
|
| 92 |
+
labels: np.ndarray | None = None,
|
| 93 |
+
*,
|
| 94 |
+
jitter: int = 256,
|
| 95 |
+
seed: int = 0,
|
| 96 |
+
):
|
| 97 |
+
super().__init__(corpus, index, labels)
|
| 98 |
+
self.jitter = jitter
|
| 99 |
+
self._rng = np.random.default_rng(seed)
|
| 100 |
+
# Per-sequence bounds on the window start, so the clamp needs no lookup
|
| 101 |
+
# into corpus.records at __getitem__ time (which runs in a worker).
|
| 102 |
+
off = np.asarray([r.text_off for r in corpus.records], dtype=np.int64)
|
| 103 |
+
ln = np.asarray([r.text_len for r in corpus.records], dtype=np.int64)
|
| 104 |
+
bid = index.bid.astype(np.int64)
|
| 105 |
+
self._lo = off[bid]
|
| 106 |
+
self._hi = np.maximum(off[bid], off[bid] + ln[bid] - index.length.astype(np.int64))
|
| 107 |
+
|
| 108 |
+
def __getitem__(self, i: int):
|
| 109 |
+
start = int(self.index.start[i])
|
| 110 |
+
n = int(self.index.length[i])
|
| 111 |
+
lo, hi = int(self._lo[i]), int(self._hi[i])
|
| 112 |
+
if self.jitter < 0:
|
| 113 |
+
start = int(self._rng.integers(lo, hi + 1)) if hi > lo else lo
|
| 114 |
+
elif self.jitter > 0:
|
| 115 |
+
shift = int(self._rng.integers(-self.jitter, self.jitter + 1))
|
| 116 |
+
start = min(max(start + shift, lo), hi)
|
| 117 |
+
chunk = np.asarray(self.text[start : start + n], dtype=np.uint8)
|
| 118 |
+
label = -1 if self.labels is None else int(self.labels[i])
|
| 119 |
+
return chunk, label, i
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ContextWindowDataset(ByteSequenceDataset):
|
| 124 |
+
"""The paper's window set, each window widened to ``length`` centred bytes.
|
| 125 |
+
|
| 126 |
+
This exists to make a longer-input model *comparable*. Training on 2048-byte
|
| 127 |
+
sequences changes the unit being classified, so per-sequence accuracy at 2048
|
| 128 |
+
bytes and at 512 bytes are different metrics and must not be put in the same
|
| 129 |
+
column. Keeping the 512-byte index and only widening what the model reads
|
| 130 |
+
around each window fixes that: the row count, the labels and the voting
|
| 131 |
+
groups are identical to the baseline's, and the only thing that changed is
|
| 132 |
+
how much context the prediction had.
|
| 133 |
+
|
| 134 |
+
It is the same manoeuvre as ``--function-context``, and it carries the same
|
| 135 |
+
caveat: the widened window contains bytes the 512-byte window does not, so
|
| 136 |
+
this is not the paper's sequence level. Provenance is a property of the whole
|
| 137 |
+
binary, so those bytes share the label and this is not label leakage — but it
|
| 138 |
+
is a different, clearly-labelled operating point.
|
| 139 |
+
"""
|
| 140 |
+
|
| 141 |
+
def __init__(self, corpus: Corpus, index: SequenceIndex, labels=None, *, length: int = 2048):
|
| 142 |
+
super().__init__(corpus, index, labels)
|
| 143 |
+
self.length = length
|
| 144 |
+
off = np.asarray([r.text_off for r in corpus.records], dtype=np.int64)
|
| 145 |
+
ln = np.asarray([r.text_len for r in corpus.records], dtype=np.int64)
|
| 146 |
+
bid = index.bid.astype(np.int64)
|
| 147 |
+
self._lo = off[bid]
|
| 148 |
+
self._hi = off[bid] + ln[bid]
|
| 149 |
+
|
| 150 |
+
def __getitem__(self, i: int):
|
| 151 |
+
start = int(self.index.start[i])
|
| 152 |
+
n = int(self.index.length[i])
|
| 153 |
+
lo, hi = int(self._lo[i]), int(self._hi[i])
|
| 154 |
+
want = min(self.length, hi - lo)
|
| 155 |
+
centre = start + n // 2
|
| 156 |
+
s = min(max(centre - want // 2, lo), max(lo, hi - want))
|
| 157 |
+
chunk = np.asarray(self.text[s : s + want], dtype=np.uint8)
|
| 158 |
+
label = -1 if self.labels is None else int(self.labels[i])
|
| 159 |
+
return chunk, label, i
|
| 160 |
+
|
| 161 |
+
class MultiViewByteSequenceDataset(ByteSequenceDataset):
|
| 162 |
+
"""One sequence, several views of it — the evaluation side of jitter.
|
| 163 |
+
|
| 164 |
+
Item ``i`` of this dataset is view ``i % n_views`` of sequence
|
| 165 |
+
``i // n_views``, so a plain non-shuffled pass yields every view of every
|
| 166 |
+
sequence and the caller averages the ``n_views`` probability rows back down.
|
| 167 |
+
The ``positions`` field still carries ``i``, so :func:`engine.predict`
|
| 168 |
+
scatters correctly without knowing anything about views.
|
| 169 |
+
|
| 170 |
+
Two modes, kept separate because they differ in what information the
|
| 171 |
+
prediction is allowed to use:
|
| 172 |
+
|
| 173 |
+
``crop``
|
| 174 |
+
Views are sub-windows *inside* the sequence's own bytes (length
|
| 175 |
+
``seq_len - k*shift`` for view ``k``). Nothing outside the 512 bytes is
|
| 176 |
+
read, so an averaged prediction is still a sequence-level prediction and
|
| 177 |
+
is comparable to the paper's number.
|
| 178 |
+
|
| 179 |
+
``shift``
|
| 180 |
+
Views slide the full-length window off the sequence's start by up to
|
| 181 |
+
``span`` bytes, clamped to the binary. This reads bytes the sequence does
|
| 182 |
+
not contain, so it is *not* the paper's sequence level -- report it as
|
| 183 |
+
its own row, the way ``--function-context`` is.
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
def __init__(
|
| 187 |
+
self,
|
| 188 |
+
corpus: Corpus,
|
| 189 |
+
index: SequenceIndex,
|
| 190 |
+
labels: np.ndarray | None = None,
|
| 191 |
+
*,
|
| 192 |
+
mode: str = "crop",
|
| 193 |
+
n_views: int = 4,
|
| 194 |
+
span: int = 128,
|
| 195 |
+
):
|
| 196 |
+
super().__init__(corpus, index, labels)
|
| 197 |
+
if mode not in ("crop", "shift"):
|
| 198 |
+
raise ValueError(f"mode must be 'crop' or 'shift', got {mode!r}")
|
| 199 |
+
if n_views < 1:
|
| 200 |
+
raise ValueError("n_views must be >= 1")
|
| 201 |
+
self.mode = mode
|
| 202 |
+
self.n_views = n_views
|
| 203 |
+
self.span = span
|
| 204 |
+
off = np.asarray([r.text_off for r in corpus.records], dtype=np.int64)
|
| 205 |
+
ln = np.asarray([r.text_len for r in corpus.records], dtype=np.int64)
|
| 206 |
+
bid = index.bid.astype(np.int64)
|
| 207 |
+
self._lo = off[bid]
|
| 208 |
+
self._hi = np.maximum(off[bid], off[bid] + ln[bid] - index.length.astype(np.int64))
|
| 209 |
+
|
| 210 |
+
def __len__(self) -> int:
|
| 211 |
+
return len(self.index) * self.n_views
|
| 212 |
+
|
| 213 |
+
def _offsets(self) -> list[int]:
|
| 214 |
+
"""Signed shifts for the views, symmetric around 0 and including 0."""
|
| 215 |
+
if self.n_views == 1:
|
| 216 |
+
return [0]
|
| 217 |
+
half = self.n_views // 2
|
| 218 |
+
step = max(1, self.span // max(1, half))
|
| 219 |
+
out = [0]
|
| 220 |
+
for k in range(1, half + 1):
|
| 221 |
+
out.append(-k * step)
|
| 222 |
+
if len(out) < self.n_views:
|
| 223 |
+
out.append(k * step)
|
| 224 |
+
return out[: self.n_views]
|
| 225 |
+
|
| 226 |
+
def __getitem__(self, j: int):
|
| 227 |
+
i, view = divmod(j, self.n_views)
|
| 228 |
+
start = int(self.index.start[i])
|
| 229 |
+
n = int(self.index.length[i])
|
| 230 |
+
delta = self._offsets()[view]
|
| 231 |
+
if self.mode == "crop":
|
| 232 |
+
# keep the window inside its own bytes: trim from one end
|
| 233 |
+
trim = abs(delta)
|
| 234 |
+
if delta >= 0:
|
| 235 |
+
start, n = start + trim, max(16, n - trim)
|
| 236 |
+
else:
|
| 237 |
+
n = max(16, n - trim)
|
| 238 |
+
else:
|
| 239 |
+
start = min(max(start + delta, int(self._lo[i])), int(self._hi[i]))
|
| 240 |
+
chunk = np.asarray(self.text[start : start + n], dtype=np.uint8)
|
| 241 |
+
label = -1 if self.labels is None else int(self.labels[i])
|
| 242 |
+
return chunk, label, j
|
| 243 |
+
|
| 244 |
+
class PairedByteSequenceDataset(ByteSequenceDataset):
|
| 245 |
+
"""Optionally splices two half-sequences from different binaries.
|
| 246 |
+
|
| 247 |
+
The paper's segment embedding ``E_s`` exists to mark "which binary program
|
| 248 |
+
each byte belongs to, when a byte sequence contains multiple fragments from
|
| 249 |
+
different programs" (§3.2). With plain single-binary sequences that embedding
|
| 250 |
+
never varies and stays dead weight. Setting ``pair_prob > 0`` during
|
| 251 |
+
pre-training makes it meaningful. Off by default, since the paper does not
|
| 252 |
+
say it used spliced inputs.
|
| 253 |
+
"""
|
| 254 |
+
|
| 255 |
+
def __init__(self, corpus: Corpus, index: SequenceIndex, *, pair_prob: float = 0.0, seed: int = 0):
|
| 256 |
+
super().__init__(corpus, index, None)
|
| 257 |
+
self.pair_prob = pair_prob
|
| 258 |
+
self._rng = np.random.default_rng(seed)
|
| 259 |
+
|
| 260 |
+
def __getitem__(self, i: int):
|
| 261 |
+
chunk, _, _ = super().__getitem__(i)
|
| 262 |
+
if self.pair_prob <= 0 or self._rng.random() >= self.pair_prob or len(chunk) < 16:
|
| 263 |
+
return chunk, -1, i
|
| 264 |
+
j = int(self._rng.integers(0, len(self.index)))
|
| 265 |
+
other, _, _ = super().__getitem__(j)
|
| 266 |
+
if self.index.bid[j] == self.index.bid[i] or len(other) < 16:
|
| 267 |
+
return chunk, -1, i
|
| 268 |
+
cut = len(chunk) // 2
|
| 269 |
+
spliced = np.concatenate([chunk[:cut], other[: len(chunk) - cut]])
|
| 270 |
+
# negative position marks "segment boundary at `cut`" for the collator
|
| 271 |
+
return spliced, -(cut + 1), i
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def _tokenize_batch(batch, seq_tokens: int):
|
| 275 |
+
"""Build ``(input_ids, attention_mask, token_type_ids)`` for a batch.
|
| 276 |
+
|
| 277 |
+
Layout per row: ``<s> b0 b1 ... bn-1 </s> <pad> ...``
|
| 278 |
+
"""
|
| 279 |
+
bsz = len(batch)
|
| 280 |
+
ids = np.full((bsz, seq_tokens), PAD_ID, dtype=np.int64)
|
| 281 |
+
attn = np.zeros((bsz, seq_tokens), dtype=np.int64)
|
| 282 |
+
types = np.zeros((bsz, seq_tokens), dtype=np.int64)
|
| 283 |
+
|
| 284 |
+
max_bytes = seq_tokens - 2
|
| 285 |
+
for row, (chunk, label, _pos) in enumerate(batch):
|
| 286 |
+
n = min(len(chunk), max_bytes)
|
| 287 |
+
ids[row, 0] = BOS_ID
|
| 288 |
+
ids[row, 1 : 1 + n] = chunk[:n].astype(np.int64) + BYTE_OFFSET
|
| 289 |
+
ids[row, 1 + n] = EOS_ID
|
| 290 |
+
attn[row, : n + 2] = 1
|
| 291 |
+
if label is not None and label < -1: # spliced pair, see dataset above
|
| 292 |
+
cut = -label - 1
|
| 293 |
+
types[row, 1 + cut : 1 + n] = 1
|
| 294 |
+
return ids, attn, types
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class MLMCollator:
|
| 298 |
+
"""Masked-language-model batches (paper §3.2 / §4.1).
|
| 299 |
+
|
| 300 |
+
Masking follows Pei et al. as the paper states: 20% of bytes are chosen; of
|
| 301 |
+
those, 50% become ``<mask>`` and 50% become a random byte value. Note there
|
| 302 |
+
is no BERT-style "keep original 10%" bucket here — that is the paper's
|
| 303 |
+
setting, not an omission.
|
| 304 |
+
"""
|
| 305 |
+
|
| 306 |
+
def __init__(
|
| 307 |
+
self,
|
| 308 |
+
seq_tokens: int,
|
| 309 |
+
*,
|
| 310 |
+
mask_prob: float = 0.20,
|
| 311 |
+
mask_replace: float = 0.5,
|
| 312 |
+
random_replace: float = 0.5,
|
| 313 |
+
seed: int | None = None,
|
| 314 |
+
):
|
| 315 |
+
if mask_replace + random_replace > 1.0 + 1e-9:
|
| 316 |
+
raise ValueError("mask_replace + random_replace must not exceed 1")
|
| 317 |
+
self.seq_tokens = seq_tokens
|
| 318 |
+
self.mask_prob = mask_prob
|
| 319 |
+
self.mask_replace = mask_replace
|
| 320 |
+
self.random_replace = random_replace
|
| 321 |
+
self.rng = np.random.default_rng(seed)
|
| 322 |
+
|
| 323 |
+
def __call__(self, batch):
|
| 324 |
+
ids, attn, types = _tokenize_batch(batch, self.seq_tokens)
|
| 325 |
+
|
| 326 |
+
# only real byte tokens are maskable: never <s>, </s> or padding
|
| 327 |
+
maskable = (ids >= BYTE_OFFSET) & (attn == 1)
|
| 328 |
+
selected = maskable & (self.rng.random(ids.shape) < self.mask_prob)
|
| 329 |
+
|
| 330 |
+
labels = np.full(ids.shape, IGNORE_INDEX, dtype=np.int64)
|
| 331 |
+
labels[selected] = ids[selected]
|
| 332 |
+
|
| 333 |
+
draw = self.rng.random(ids.shape)
|
| 334 |
+
to_mask = selected & (draw < self.mask_replace)
|
| 335 |
+
to_random = selected & (draw >= self.mask_replace) & (
|
| 336 |
+
draw < self.mask_replace + self.random_replace
|
| 337 |
+
)
|
| 338 |
+
ids[to_mask] = MASK_ID
|
| 339 |
+
n_rand = int(to_random.sum())
|
| 340 |
+
if n_rand:
|
| 341 |
+
ids[to_random] = self.rng.integers(BYTE_OFFSET, VOCAB_SIZE, size=n_rand)
|
| 342 |
+
|
| 343 |
+
return {
|
| 344 |
+
"input_ids": torch.from_numpy(ids),
|
| 345 |
+
"attention_mask": torch.from_numpy(attn),
|
| 346 |
+
"token_type_ids": torch.from_numpy(types),
|
| 347 |
+
"labels": torch.from_numpy(labels),
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
class ClassificationCollator:
|
| 352 |
+
"""Batches for fine-tuning and evaluation."""
|
| 353 |
+
|
| 354 |
+
def __init__(self, seq_tokens: int):
|
| 355 |
+
self.seq_tokens = seq_tokens
|
| 356 |
+
|
| 357 |
+
def __call__(self, batch):
|
| 358 |
+
ids, attn, types = _tokenize_batch(batch, self.seq_tokens)
|
| 359 |
+
labels = np.asarray([max(b[1], 0) for b in batch], dtype=np.int64)
|
| 360 |
+
positions = np.asarray([b[2] for b in batch], dtype=np.int64)
|
| 361 |
+
return {
|
| 362 |
+
"input_ids": torch.from_numpy(ids),
|
| 363 |
+
"attention_mask": torch.from_numpy(attn),
|
| 364 |
+
"token_type_ids": torch.from_numpy(types),
|
| 365 |
+
"labels": torch.from_numpy(labels),
|
| 366 |
+
"positions": torch.from_numpy(positions),
|
| 367 |
+
}
|
binprov/discover.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Find labelled binaries in a directory tree.
|
| 2 |
+
|
| 3 |
+
Two layouts are handled, because the two data paths differ:
|
| 4 |
+
|
| 5 |
+
**BinKit** puts the whole toolchain in the *filename*, two levels deep::
|
| 6 |
+
|
| 7 |
+
gnu_debug/a2ps/a2ps-4.14_clang-7.0_x86_64_O2_a2ps.elf
|
| 8 |
+
| | | | | `-- program
|
| 9 |
+
| | | | `-- optimization level
|
| 10 |
+
| | | `-- architecture
|
| 11 |
+
| | `-- compiler and version
|
| 12 |
+
| `-- package and version
|
| 13 |
+
`-- package directory
|
| 14 |
+
|
| 15 |
+
**scripts/build_local_dataset.py** puts it in a directory instead::
|
| 16 |
+
|
| 17 |
+
<root>/<package>/<arch>-<compiler>-<version>-<opt>/<program>
|
| 18 |
+
|
| 19 |
+
Labels come from :func:`binprov.provenance.parse_labels`, which scans every path
|
| 20 |
+
component for recognisable tokens and so copes with both. Verified against all
|
| 21 |
+
67,680 paths of BinKit Normal: architecture, compiler, compiler version and
|
| 22 |
+
optimization level are recovered correctly for every one.
|
| 23 |
+
|
| 24 |
+
The *program name* needs more care than the labels do. Under the BinKit layout a
|
| 25 |
+
naive ``path.name`` makes each compiled variant look like a different program,
|
| 26 |
+
which would silently defeat the program-grouped train/test split — the same
|
| 27 |
+
binary at O2 could train while its O3 twin is tested. :func:`program_name`
|
| 28 |
+
therefore parses the filename grammar to recover the real program.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import os
|
| 34 |
+
import re
|
| 35 |
+
from collections import Counter
|
| 36 |
+
from dataclasses import dataclass
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
|
| 39 |
+
from . import elf
|
| 40 |
+
from .provenance import Labels, parse_labels, toolchain_key
|
| 41 |
+
|
| 42 |
+
#: never worth parsing — build artefacts, sources, docs
|
| 43 |
+
SKIP_SUFFIXES = frozenset(
|
| 44 |
+
""".c .h .cc .cpp .hpp .py .sh .m4 .am .in .ac .md .txt .log .json .yaml .yml
|
| 45 |
+
.gz .xz .bz2 .zip .tar .patch .diff .po .pot .1 .3 .html .css .js .png .jpg
|
| 46 |
+
.o .lo .la .a .cmake .pc .mk .d .gmo .info .texi""".split()
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class Candidate:
|
| 52 |
+
"""A file on disk that looks like a labelled binary."""
|
| 53 |
+
|
| 54 |
+
path: Path
|
| 55 |
+
package: str
|
| 56 |
+
program: str
|
| 57 |
+
labels: Labels
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def toolchain(self) -> str:
|
| 61 |
+
return toolchain_key(self.labels.as_dict())
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _looks_like_source_tree(name: str) -> bool:
|
| 65 |
+
return name in {".git", ".svn", "autom4te.cache", "__pycache__", "node_modules"}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# BinKit's filename grammar:
|
| 69 |
+
# {package}-{version}_{compiler}-{cver}_{arch}_{width}_{opt}_{program}
|
| 70 |
+
# The program is the trailing group, so names containing underscores survive
|
| 71 |
+
# (gdbm_dump, gdbm_load are the two real cases in BinKit Normal).
|
| 72 |
+
_BINKIT_STEM = re.compile(
|
| 73 |
+
r"^(?P<pkgver>.+?)"
|
| 74 |
+
r"_(?P<compiler>(?:gcc|clang|g\+\+|clang\+\+)-[0-9][0-9.]*)"
|
| 75 |
+
r"_(?P<arch>(?:x86|x64|i386|arm|aarch64|mips|mipseb|mipsel|ppc|riscv)_(?:32|64))"
|
| 76 |
+
r"_(?P<opt>O[0-3s]|Ofast)"
|
| 77 |
+
r"_(?P<program>.+)$"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def program_name(path: Path) -> str:
|
| 82 |
+
"""The program a binary was built from, stripped of provenance tokens.
|
| 83 |
+
|
| 84 |
+
Returns the trailing program field for a BinKit-style filename, and the plain
|
| 85 |
+
filename otherwise (the directory-based layout, where the filename already
|
| 86 |
+
*is* the program). This is the grouping key for leak-free splits, so getting
|
| 87 |
+
it wrong does not fail loudly — it just quietly inflates accuracy.
|
| 88 |
+
"""
|
| 89 |
+
stem = path.name
|
| 90 |
+
for suffix in (".elf", ".exe", ".so"):
|
| 91 |
+
if stem.endswith(suffix):
|
| 92 |
+
stem = stem[: -len(suffix)]
|
| 93 |
+
break
|
| 94 |
+
m = _BINKIT_STEM.match(stem)
|
| 95 |
+
return m.group("program") if m else path.name
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def walk(root: str | os.PathLike, *, follow_symlinks: bool = False):
|
| 99 |
+
"""Yield paths of ELF files under ``root``, cheaply.
|
| 100 |
+
|
| 101 |
+
Filters by extension first and reads only 4 bytes to confirm the ELF magic,
|
| 102 |
+
so walking a build tree full of objects and sources stays fast.
|
| 103 |
+
"""
|
| 104 |
+
root = Path(root)
|
| 105 |
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
|
| 106 |
+
dirnames[:] = [d for d in dirnames if not _looks_like_source_tree(d)]
|
| 107 |
+
for fn in filenames:
|
| 108 |
+
p = Path(dirpath) / fn
|
| 109 |
+
if p.suffix.lower() in SKIP_SUFFIXES:
|
| 110 |
+
continue
|
| 111 |
+
if p.is_symlink() and not follow_symlinks:
|
| 112 |
+
continue
|
| 113 |
+
try:
|
| 114 |
+
if p.stat().st_size < 128:
|
| 115 |
+
continue
|
| 116 |
+
except OSError:
|
| 117 |
+
continue
|
| 118 |
+
if elf.is_elf(p):
|
| 119 |
+
yield p
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def discover(
|
| 123 |
+
root: str | os.PathLike,
|
| 124 |
+
*,
|
| 125 |
+
arch=None,
|
| 126 |
+
compiler=None,
|
| 127 |
+
compiler_version=None,
|
| 128 |
+
opt=None,
|
| 129 |
+
extra=None,
|
| 130 |
+
obfuscation=None,
|
| 131 |
+
require_complete: bool = True,
|
| 132 |
+
per_toolchain_limit: int | None = None,
|
| 133 |
+
packages=None,
|
| 134 |
+
):
|
| 135 |
+
"""Yield :class:`Candidate` objects for binaries matching the filters.
|
| 136 |
+
|
| 137 |
+
Filters accept a scalar or a collection; ``None`` means "no constraint".
|
| 138 |
+
They are applied *before* the file is read, which is the whole point — the
|
| 139 |
+
paper uses 4 architectures x 2 compilers x 4 opt levels, i.e. 32 of BinKit
|
| 140 |
+
Normal's 288 toolchains, so filtering here avoids touching ~89% of the
|
| 141 |
+
dataset.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
per_toolchain_limit: keep at most this many binaries per compilation
|
| 145 |
+
configuration. The cheapest way to bound corpus size while staying
|
| 146 |
+
balanced across configurations.
|
| 147 |
+
packages: restrict to these package names.
|
| 148 |
+
"""
|
| 149 |
+
root = Path(root)
|
| 150 |
+
want = {
|
| 151 |
+
"arch": arch,
|
| 152 |
+
"compiler": compiler,
|
| 153 |
+
"compiler_version": compiler_version,
|
| 154 |
+
"opt": opt,
|
| 155 |
+
"extra": extra,
|
| 156 |
+
"obfuscation": obfuscation,
|
| 157 |
+
}
|
| 158 |
+
pkg_filter = None if packages is None else set(packages)
|
| 159 |
+
seen_per_toolchain: Counter[str] = Counter()
|
| 160 |
+
|
| 161 |
+
for path in walk(root):
|
| 162 |
+
rel = path.relative_to(root)
|
| 163 |
+
parts = rel.parts
|
| 164 |
+
package = parts[0] if len(parts) > 1 else "_root"
|
| 165 |
+
if pkg_filter is not None and package not in pkg_filter:
|
| 166 |
+
continue
|
| 167 |
+
|
| 168 |
+
labels = parse_labels(str(rel))
|
| 169 |
+
if require_complete and not labels.is_complete():
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
d = labels.as_dict()
|
| 173 |
+
if not _matches(d, want):
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
if per_toolchain_limit is not None:
|
| 177 |
+
key = toolchain_key(d)
|
| 178 |
+
if seen_per_toolchain[key] >= per_toolchain_limit:
|
| 179 |
+
continue
|
| 180 |
+
seen_per_toolchain[key] += 1
|
| 181 |
+
|
| 182 |
+
yield Candidate(
|
| 183 |
+
path=path, package=package, program=program_name(path), labels=labels
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _matches(got: dict, want: dict) -> bool:
|
| 188 |
+
for key, expected in want.items():
|
| 189 |
+
if expected is None:
|
| 190 |
+
continue
|
| 191 |
+
value = got.get(key)
|
| 192 |
+
if isinstance(expected, (list, tuple, set, frozenset)):
|
| 193 |
+
if value not in expected:
|
| 194 |
+
return False
|
| 195 |
+
elif value != expected:
|
| 196 |
+
return False
|
| 197 |
+
return True
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def summarize(candidates) -> str:
|
| 201 |
+
"""A short report of what discovery found, for ``--dry-run``."""
|
| 202 |
+
cands = list(candidates)
|
| 203 |
+
if not cands:
|
| 204 |
+
return "no binaries matched"
|
| 205 |
+
by_tc = Counter(c.toolchain for c in cands)
|
| 206 |
+
by_pkg = Counter(c.package for c in cands)
|
| 207 |
+
lines = [
|
| 208 |
+
f"{len(cands)} binaries, {len(by_pkg)} packages, {len(by_tc)} toolchains",
|
| 209 |
+
"",
|
| 210 |
+
"toolchains (count):",
|
| 211 |
+
]
|
| 212 |
+
for tc, n in sorted(by_tc.items()):
|
| 213 |
+
lines.append(f" {n:7d} {tc}")
|
| 214 |
+
lines += ["", "sample paths:"]
|
| 215 |
+
for c in cands[:8]:
|
| 216 |
+
lines.append(f" {c.package} | {c.program} | {c.toolchain}")
|
| 217 |
+
return "\n".join(lines)
|
binprov/elf.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A small, dependency-free ELF reader.
|
| 2 |
+
|
| 3 |
+
BinProv's whole input is the ``.text`` section (paper §3.1), plus — only for the
|
| 4 |
+
function-level majority voting of §3.4 — the function boundaries recorded in the
|
| 5 |
+
symbol table. That is all this module extracts. It deliberately avoids
|
| 6 |
+
``pyelftools``/``lief`` so that corpus building has no native dependencies and
|
| 7 |
+
so that the same code path handles every architecture in the paper
|
| 8 |
+
(x86_32, x86_64, ARM_64, MIPS_64), including big-endian MIPS.
|
| 9 |
+
|
| 10 |
+
Everything is parsed from an in-memory ``bytes``/``memoryview`` buffer.
|
| 11 |
+
Malformed or truncated files raise :class:`ElfError` rather than crashing, so a
|
| 12 |
+
corpus build can skip them and keep going.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import struct
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
|
| 20 |
+
ELF_MAGIC = b"\x7fELF"
|
| 21 |
+
|
| 22 |
+
# section header types we care about
|
| 23 |
+
SHT_SYMTAB = 2
|
| 24 |
+
SHT_DYNSYM = 11
|
| 25 |
+
SHT_NOBITS = 8
|
| 26 |
+
|
| 27 |
+
STT_FUNC = 2
|
| 28 |
+
|
| 29 |
+
# e_machine -> canonical architecture name. The width/endianness suffix is
|
| 30 |
+
# resolved from EI_CLASS / EI_DATA, because e.g. EM_MIPS covers 4 variants.
|
| 31 |
+
_MACHINE = {
|
| 32 |
+
3: "x86",
|
| 33 |
+
62: "x86", # EM_X86_64 (width comes from EI_CLASS)
|
| 34 |
+
40: "arm",
|
| 35 |
+
183: "arm", # EM_AARCH64
|
| 36 |
+
8: "mips",
|
| 37 |
+
10: "mips", # EM_MIPS_RS3_LE
|
| 38 |
+
20: "ppc",
|
| 39 |
+
21: "ppc",
|
| 40 |
+
22: "s390",
|
| 41 |
+
243: "riscv",
|
| 42 |
+
258: "loongarch",
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class ElfError(Exception):
|
| 47 |
+
"""Raised for anything that is not a parseable ELF file."""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass(frozen=True)
|
| 51 |
+
class Function:
|
| 52 |
+
"""A function boundary taken from the symbol table."""
|
| 53 |
+
|
| 54 |
+
name: str
|
| 55 |
+
offset: int # byte offset within the .text section
|
| 56 |
+
size: int # size in bytes
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class TextSection:
|
| 61 |
+
"""The extracted ``.text`` section and its metadata."""
|
| 62 |
+
|
| 63 |
+
data: bytes
|
| 64 |
+
vaddr: int # virtual address of .text[0]
|
| 65 |
+
file_offset: int
|
| 66 |
+
arch: str # e.g. "x86_64", "mips_64", "mipseb_64"
|
| 67 |
+
bits: int # 32 or 64
|
| 68 |
+
little_endian: bool
|
| 69 |
+
functions: list[Function] = field(default_factory=list)
|
| 70 |
+
comment: str = "" # contents of .comment, if present
|
| 71 |
+
stripped: bool = True # no SHT_SYMTAB present
|
| 72 |
+
|
| 73 |
+
def __len__(self) -> int:
|
| 74 |
+
return len(self.data)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _u(buf: memoryview, fmt: str, off: int):
|
| 78 |
+
"""Unpack ``fmt`` at ``off``, raising ElfError on truncation."""
|
| 79 |
+
size = struct.calcsize(fmt)
|
| 80 |
+
if off < 0 or off + size > len(buf):
|
| 81 |
+
raise ElfError(f"truncated file: want {size} bytes at {off}, have {len(buf)}")
|
| 82 |
+
return struct.unpack_from(fmt, buf, off)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _cstr(buf: memoryview, off: int) -> str:
|
| 86 |
+
"""Read a NUL-terminated string; tolerates a missing terminator."""
|
| 87 |
+
if not 0 <= off < len(buf):
|
| 88 |
+
return ""
|
| 89 |
+
end = bytes(buf[off:]).find(b"\x00")
|
| 90 |
+
raw = bytes(buf[off:] if end < 0 else buf[off : off + end])
|
| 91 |
+
return raw.decode("utf-8", "replace")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _arch_name(machine: int, bits: int, little_endian: bool) -> str:
|
| 95 |
+
base = _MACHINE.get(machine, f"machine{machine}")
|
| 96 |
+
# BinKit names its big-endian MIPS targets "mipseb"; mirror that so labels
|
| 97 |
+
# from paths and from ELF headers agree.
|
| 98 |
+
if base == "mips" and not little_endian:
|
| 99 |
+
base = "mipseb"
|
| 100 |
+
return f"{base}_{bits}"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def parse(buf: bytes, *, want_functions: bool = True) -> TextSection:
|
| 104 |
+
"""Extract ``.text`` (and optionally function boundaries) from an ELF image.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
buf: the whole file contents.
|
| 108 |
+
want_functions: parse the symbol table for function boundaries. Skip it
|
| 109 |
+
when only doing binary-level work — it saves a scan over the
|
| 110 |
+
symbol table, which in a debug build can be larger than ``.text``.
|
| 111 |
+
|
| 112 |
+
Raises:
|
| 113 |
+
ElfError: not an ELF, unsupported class, or no ``.text`` section.
|
| 114 |
+
"""
|
| 115 |
+
mv = memoryview(buf)
|
| 116 |
+
if len(mv) < 64 or bytes(mv[:4]) != ELF_MAGIC:
|
| 117 |
+
raise ElfError("not an ELF file")
|
| 118 |
+
|
| 119 |
+
ei_class, ei_data = mv[4], mv[5]
|
| 120 |
+
if ei_class == 1:
|
| 121 |
+
bits = 32
|
| 122 |
+
elif ei_class == 2:
|
| 123 |
+
bits = 64
|
| 124 |
+
else:
|
| 125 |
+
raise ElfError(f"bad EI_CLASS {ei_class}")
|
| 126 |
+
if ei_data == 1:
|
| 127 |
+
little_endian = True
|
| 128 |
+
elif ei_data == 2:
|
| 129 |
+
little_endian = False
|
| 130 |
+
else:
|
| 131 |
+
raise ElfError(f"bad EI_DATA {ei_data}")
|
| 132 |
+
|
| 133 |
+
e = "<" if little_endian else ">"
|
| 134 |
+
(machine,) = _u(mv, e + "H", 18)
|
| 135 |
+
|
| 136 |
+
# --- ELF header: locate the section header table -----------------------
|
| 137 |
+
if bits == 64:
|
| 138 |
+
e_shoff, = _u(mv, e + "Q", 40)
|
| 139 |
+
e_shentsize, e_shnum, e_shstrndx = _u(mv, e + "HHH", 58)
|
| 140 |
+
else:
|
| 141 |
+
e_shoff, = _u(mv, e + "I", 32)
|
| 142 |
+
e_shentsize, e_shnum, e_shstrndx = _u(mv, e + "HHH", 46)
|
| 143 |
+
|
| 144 |
+
if e_shoff == 0 or e_shnum == 0:
|
| 145 |
+
raise ElfError("no section header table (stripped of sections?)")
|
| 146 |
+
if e_shentsize < (64 if bits == 64 else 40):
|
| 147 |
+
raise ElfError(f"implausible e_shentsize {e_shentsize}")
|
| 148 |
+
|
| 149 |
+
# --- section headers ---------------------------------------------------
|
| 150 |
+
# (name_off, type, addr, offset, size, link, entsize)
|
| 151 |
+
sections: list[tuple[int, int, int, int, int, int, int]] = []
|
| 152 |
+
for i in range(e_shnum):
|
| 153 |
+
base = e_shoff + i * e_shentsize
|
| 154 |
+
if bits == 64:
|
| 155 |
+
sh_name, sh_type = _u(mv, e + "II", base)
|
| 156 |
+
sh_addr, sh_offset, sh_size = _u(mv, e + "QQQ", base + 16)
|
| 157 |
+
sh_link, = _u(mv, e + "I", base + 40)
|
| 158 |
+
sh_entsize, = _u(mv, e + "Q", base + 56)
|
| 159 |
+
else:
|
| 160 |
+
sh_name, sh_type = _u(mv, e + "II", base)
|
| 161 |
+
sh_addr, sh_offset, sh_size = _u(mv, e + "III", base + 12)
|
| 162 |
+
sh_link, = _u(mv, e + "I", base + 24)
|
| 163 |
+
sh_entsize, = _u(mv, e + "I", base + 36)
|
| 164 |
+
sections.append((sh_name, sh_type, sh_addr, sh_offset, sh_size, sh_link, sh_entsize))
|
| 165 |
+
|
| 166 |
+
# section-name string table
|
| 167 |
+
shstr_off = 0
|
| 168 |
+
if 0 <= e_shstrndx < len(sections):
|
| 169 |
+
shstr_off = sections[e_shstrndx][3]
|
| 170 |
+
|
| 171 |
+
def sec_name(idx: int) -> str:
|
| 172 |
+
return _cstr(mv, shstr_off + sections[idx][0]) if shstr_off else ""
|
| 173 |
+
|
| 174 |
+
names = [sec_name(i) for i in range(len(sections))]
|
| 175 |
+
|
| 176 |
+
# --- .text -------------------------------------------------------------
|
| 177 |
+
try:
|
| 178 |
+
text_idx = names.index(".text")
|
| 179 |
+
except ValueError:
|
| 180 |
+
raise ElfError("no .text section") from None
|
| 181 |
+
|
| 182 |
+
_, t_type, t_addr, t_off, t_size, _, _ = sections[text_idx]
|
| 183 |
+
if t_type == SHT_NOBITS or t_size == 0:
|
| 184 |
+
raise ElfError(".text is empty")
|
| 185 |
+
if t_off + t_size > len(mv):
|
| 186 |
+
# Truncated download / corrupt file: take what is actually there.
|
| 187 |
+
t_size = max(0, len(mv) - t_off)
|
| 188 |
+
if t_size == 0:
|
| 189 |
+
raise ElfError(".text lies past end of file")
|
| 190 |
+
text = bytes(mv[t_off : t_off + t_size])
|
| 191 |
+
|
| 192 |
+
# --- .comment (compiler fingerprint, used to sanity-check labels) ------
|
| 193 |
+
comment = ""
|
| 194 |
+
if ".comment" in names:
|
| 195 |
+
_, _, _, c_off, c_size, _, _ = sections[names.index(".comment")]
|
| 196 |
+
if c_size and c_off + c_size <= len(mv):
|
| 197 |
+
comment = bytes(mv[c_off : c_off + c_size]).replace(b"\x00", b" ").decode(
|
| 198 |
+
"utf-8", "replace"
|
| 199 |
+
).strip()
|
| 200 |
+
|
| 201 |
+
stripped = not any(s[1] == SHT_SYMTAB for s in sections)
|
| 202 |
+
|
| 203 |
+
result = TextSection(
|
| 204 |
+
data=text,
|
| 205 |
+
vaddr=t_addr,
|
| 206 |
+
file_offset=t_off,
|
| 207 |
+
arch=_arch_name(machine, bits, little_endian),
|
| 208 |
+
bits=bits,
|
| 209 |
+
little_endian=little_endian,
|
| 210 |
+
comment=comment,
|
| 211 |
+
stripped=stripped,
|
| 212 |
+
)
|
| 213 |
+
if want_functions:
|
| 214 |
+
result.functions = _read_functions(mv, e, bits, sections, text_idx, t_addr, t_size, machine)
|
| 215 |
+
return result
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _read_functions(
|
| 219 |
+
mv: memoryview,
|
| 220 |
+
e: str,
|
| 221 |
+
bits: int,
|
| 222 |
+
sections,
|
| 223 |
+
text_idx: int,
|
| 224 |
+
text_vaddr: int,
|
| 225 |
+
text_size: int,
|
| 226 |
+
machine: int,
|
| 227 |
+
) -> list[Function]:
|
| 228 |
+
"""Collect STT_FUNC symbols that live inside ``.text``.
|
| 229 |
+
|
| 230 |
+
Prefers SHT_SYMTAB; falls back to SHT_DYNSYM for stripped binaries (which
|
| 231 |
+
yields far fewer functions, but is better than nothing).
|
| 232 |
+
"""
|
| 233 |
+
symtabs = [s for s in sections if s[1] == SHT_SYMTAB]
|
| 234 |
+
if not symtabs:
|
| 235 |
+
symtabs = [s for s in sections if s[1] == SHT_DYNSYM]
|
| 236 |
+
if not symtabs:
|
| 237 |
+
return []
|
| 238 |
+
|
| 239 |
+
sym_size = 24 if bits == 64 else 16
|
| 240 |
+
out: dict[int, Function] = {} # offset -> Function, de-duplicates aliases
|
| 241 |
+
|
| 242 |
+
for _, _, _, sh_offset, sh_size, sh_link, sh_entsize in symtabs:
|
| 243 |
+
entsize = sh_entsize or sym_size
|
| 244 |
+
if entsize < sym_size or sh_size == 0:
|
| 245 |
+
continue
|
| 246 |
+
# linked string table
|
| 247 |
+
strtab_off = sections[sh_link][3] if 0 <= sh_link < len(sections) else 0
|
| 248 |
+
n = sh_size // entsize
|
| 249 |
+
for i in range(n):
|
| 250 |
+
base = sh_offset + i * entsize
|
| 251 |
+
try:
|
| 252 |
+
if bits == 64:
|
| 253 |
+
st_name, = _u(mv, e + "I", base)
|
| 254 |
+
st_info = mv[base + 4]
|
| 255 |
+
st_shndx, = _u(mv, e + "H", base + 6)
|
| 256 |
+
st_value, st_sz = _u(mv, e + "QQ", base + 8)
|
| 257 |
+
else:
|
| 258 |
+
st_name, st_value, st_sz = _u(mv, e + "III", base)
|
| 259 |
+
st_info = mv[base + 12]
|
| 260 |
+
st_shndx, = _u(mv, e + "H", base + 14)
|
| 261 |
+
except ElfError:
|
| 262 |
+
break # truncated symbol table; keep what we have
|
| 263 |
+
|
| 264 |
+
if (st_info & 0xF) != STT_FUNC or st_sz == 0 or st_shndx != text_idx:
|
| 265 |
+
continue
|
| 266 |
+
# ARM/Thumb interworking sets bit 0 of st_value; MIPS16/microMIPS
|
| 267 |
+
# does the same. Clear it so the offset lands on a real byte.
|
| 268 |
+
if machine in (40, 8, 10):
|
| 269 |
+
st_value &= ~1
|
| 270 |
+
off = st_value - text_vaddr
|
| 271 |
+
if off < 0 or off >= text_size:
|
| 272 |
+
continue
|
| 273 |
+
size = min(st_sz, text_size - off)
|
| 274 |
+
if size <= 0 or off in out:
|
| 275 |
+
continue
|
| 276 |
+
out[off] = Function(name=_cstr(mv, strtab_off + st_name), offset=off, size=size)
|
| 277 |
+
|
| 278 |
+
return sorted(out.values(), key=lambda f: f.offset)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def guess_compiler(comment: str) -> tuple[str | None, str | None]:
|
| 282 |
+
"""Best-effort ``(family, version)`` from a ``.comment`` string.
|
| 283 |
+
|
| 284 |
+
Used to cross-check labels derived from file paths — a cheap guard against a
|
| 285 |
+
mislabeled corpus. Returns ``(None, None)`` when nothing is recognisable.
|
| 286 |
+
Note that a binary linked from both GCC- and Clang-compiled objects can
|
| 287 |
+
carry both fingerprints; the first match wins.
|
| 288 |
+
"""
|
| 289 |
+
import re
|
| 290 |
+
|
| 291 |
+
if not comment:
|
| 292 |
+
return None, None
|
| 293 |
+
low = comment.lower()
|
| 294 |
+
m = re.search(r"clang version ([0-9]+(?:\.[0-9]+)*)", low)
|
| 295 |
+
if m:
|
| 296 |
+
return "clang", m.group(1)
|
| 297 |
+
m = re.search(r"gcc[:\s(]+.*?([0-9]+\.[0-9]+\.[0-9]+)", low)
|
| 298 |
+
if m:
|
| 299 |
+
return "gcc", m.group(1)
|
| 300 |
+
if "clang" in low:
|
| 301 |
+
return "clang", None
|
| 302 |
+
if "gcc" in low or "gnu" in low:
|
| 303 |
+
return "gcc", None
|
| 304 |
+
return None, None
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def is_elf(path) -> bool:
|
| 308 |
+
"""Cheap check used to filter build trees before a full parse."""
|
| 309 |
+
try:
|
| 310 |
+
with open(path, "rb") as fh:
|
| 311 |
+
return fh.read(4) == ELF_MAGIC
|
| 312 |
+
except OSError:
|
| 313 |
+
return False
|
binprov/metrics.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metrics and table formatting.
|
| 2 |
+
|
| 3 |
+
The paper reports accuracy as its headline number, because the dataset is
|
| 4 |
+
balanced across compilers and optimization levels (§4.2), and adds
|
| 5 |
+
precision/recall/F1 per optimization level to expose *where* the O2/O3
|
| 6 |
+
confusion sits (Table 5). Both are here, with no sklearn dependency so that
|
| 7 |
+
evaluation runs in any environment that can run the model.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def accuracy(pred: np.ndarray, true: np.ndarray) -> float:
|
| 16 |
+
if len(true) == 0:
|
| 17 |
+
return 0.0
|
| 18 |
+
return float((pred == true).mean())
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def confusion_matrix(pred: np.ndarray, true: np.ndarray, num_classes: int) -> np.ndarray:
|
| 22 |
+
"""Rows are ground truth, columns are predictions."""
|
| 23 |
+
flat = true.astype(np.int64) * num_classes + pred.astype(np.int64)
|
| 24 |
+
return np.bincount(flat, minlength=num_classes**2).reshape(num_classes, num_classes)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def per_class_prf(pred: np.ndarray, true: np.ndarray, num_classes: int) -> dict:
|
| 28 |
+
"""Precision / recall / F1 per class, plus macro averages (Table 5)."""
|
| 29 |
+
cm = confusion_matrix(pred, true, num_classes)
|
| 30 |
+
tp = np.diag(cm).astype(np.float64)
|
| 31 |
+
predicted = cm.sum(axis=0).astype(np.float64)
|
| 32 |
+
actual = cm.sum(axis=1).astype(np.float64)
|
| 33 |
+
with np.errstate(divide="ignore", invalid="ignore"):
|
| 34 |
+
precision = np.where(predicted > 0, tp / predicted, 0.0)
|
| 35 |
+
recall = np.where(actual > 0, tp / actual, 0.0)
|
| 36 |
+
denom = precision + recall
|
| 37 |
+
f1 = np.where(denom > 0, 2 * precision * recall / denom, 0.0)
|
| 38 |
+
return {
|
| 39 |
+
"precision": precision,
|
| 40 |
+
"recall": recall,
|
| 41 |
+
"f1": f1,
|
| 42 |
+
"support": actual.astype(np.int64),
|
| 43 |
+
"macro_precision": float(precision.mean()),
|
| 44 |
+
"macro_recall": float(recall.mean()),
|
| 45 |
+
"macro_f1": float(f1.mean()),
|
| 46 |
+
"accuracy": float(tp.sum() / cm.sum()) if cm.sum() else 0.0,
|
| 47 |
+
"confusion": cm,
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def pct(x: float) -> str:
|
| 52 |
+
return f"{100 * x:.2f}%"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def markdown_table(header: list[str], rows: list[list[str]]) -> str:
|
| 56 |
+
"""Render a markdown table with columns padded to a readable width."""
|
| 57 |
+
widths = [len(h) for h in header]
|
| 58 |
+
for row in rows:
|
| 59 |
+
for i, cell in enumerate(row):
|
| 60 |
+
widths[i] = max(widths[i], len(str(cell)))
|
| 61 |
+
out = ["| " + " | ".join(h.ljust(widths[i]) for i, h in enumerate(header)) + " |"]
|
| 62 |
+
out.append("|" + "|".join("-" * (w + 2) for w in widths) + "|")
|
| 63 |
+
for row in rows:
|
| 64 |
+
out.append("| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(row)) + " |")
|
| 65 |
+
return "\n".join(out)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def confusion_as_markdown(cm: np.ndarray, classes) -> str:
|
| 69 |
+
header = ["true \\ pred", *classes]
|
| 70 |
+
rows = [[classes[i], *[str(int(v)) for v in cm[i]]] for i in range(len(classes))]
|
| 71 |
+
return markdown_table(header, rows)
|
binprov/model.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BinProv's networks: a byte-level RoBERTa encoder and a provenance classifier.
|
| 2 |
+
|
| 3 |
+
Two pieces, matching the paper's two training phases:
|
| 4 |
+
|
| 5 |
+
* :func:`build_mlm_model` — the embedding model ``f_e`` with a softmax head,
|
| 6 |
+
pre-trained on masked language modeling (§3.2, Eq. 1). 12 transformer layers,
|
| 7 |
+
hidden size 768, which lands at ~85M trainable parameters — the paper's
|
| 8 |
+
"around 80 million".
|
| 9 |
+
* :class:`BinProvForProvenance` — ``f_c(f_e(...))``, the encoder plus the
|
| 10 |
+
two-layer fully connected classifier of §3.3, fine-tuned end-to-end.
|
| 11 |
+
|
| 12 |
+
The paper describes the classifier's first layer as one that "reshape[s] the
|
| 13 |
+
input vectors and weaken[s] the border weights of the embeddings", to cope with
|
| 14 |
+
x86's variable-length instructions being cut mid-instruction at a sequence
|
| 15 |
+
boundary. It does not give the mechanism. :class:`BorderTaperedPool` implements
|
| 16 |
+
it as a learned attention over token positions whose logits are *initialised*
|
| 17 |
+
with a taper at both ends: borders start down-weighted, and training is free to
|
| 18 |
+
change that. Ablate it with ``--pool mean`` to see what it buys.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import math
|
| 25 |
+
from dataclasses import dataclass, asdict
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
import torch.nn.functional as F
|
| 31 |
+
from transformers import RobertaConfig, RobertaForMaskedLM, RobertaModel
|
| 32 |
+
|
| 33 |
+
from .vocab import BOS_ID, EOS_ID, MASK_ID, PAD_ID, VOCAB_SIZE
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class BinProvConfig:
|
| 38 |
+
"""Architecture knobs. Stored next to every checkpoint."""
|
| 39 |
+
|
| 40 |
+
seq_bytes: int = 512 # bytes per sequence (paper §3.1)
|
| 41 |
+
hidden_size: int = 768
|
| 42 |
+
num_hidden_layers: int = 12 # "consists of 12 transformers" (§3.2)
|
| 43 |
+
num_attention_heads: int = 12
|
| 44 |
+
intermediate_size: int = 3072
|
| 45 |
+
max_segments: int = 2 # segment embedding E_s (§3.2)
|
| 46 |
+
hidden_dropout_prob: float = 0.1
|
| 47 |
+
attention_probs_dropout_prob: float = 0.1
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def seq_tokens(self) -> int:
|
| 51 |
+
"""Sequence length in tokens: the bytes plus <s> and </s>."""
|
| 52 |
+
return self.seq_bytes + 2
|
| 53 |
+
|
| 54 |
+
def to_roberta(self) -> RobertaConfig:
|
| 55 |
+
return RobertaConfig(
|
| 56 |
+
vocab_size=VOCAB_SIZE,
|
| 57 |
+
hidden_size=self.hidden_size,
|
| 58 |
+
num_hidden_layers=self.num_hidden_layers,
|
| 59 |
+
num_attention_heads=self.num_attention_heads,
|
| 60 |
+
intermediate_size=self.intermediate_size,
|
| 61 |
+
# HF's RoBERTa derives position ids as padding_idx + 1 + i, so the
|
| 62 |
+
# table needs two slots of headroom beyond the token count.
|
| 63 |
+
max_position_embeddings=self.seq_tokens + 2,
|
| 64 |
+
type_vocab_size=self.max_segments,
|
| 65 |
+
pad_token_id=PAD_ID,
|
| 66 |
+
bos_token_id=BOS_ID,
|
| 67 |
+
eos_token_id=EOS_ID,
|
| 68 |
+
mask_token_id=MASK_ID,
|
| 69 |
+
hidden_dropout_prob=self.hidden_dropout_prob,
|
| 70 |
+
attention_probs_dropout_prob=self.attention_probs_dropout_prob,
|
| 71 |
+
position_embedding_type="absolute",
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
def save(self, path: str | Path) -> None:
|
| 75 |
+
Path(path).write_text(json.dumps(asdict(self), indent=2) + "\n")
|
| 76 |
+
|
| 77 |
+
@classmethod
|
| 78 |
+
def load(cls, path: str | Path) -> BinProvConfig:
|
| 79 |
+
return cls(**json.loads(Path(path).read_text()))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def build_mlm_model(cfg: BinProvConfig) -> RobertaForMaskedLM:
|
| 83 |
+
"""The pre-training model: encoder + softmax head over the 261-byte vocab."""
|
| 84 |
+
return RobertaForMaskedLM(cfg.to_roberta())
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class BorderTaperedPool(nn.Module):
|
| 88 |
+
"""Pool token embeddings into one vector, down-weighting sequence borders.
|
| 89 |
+
|
| 90 |
+
This is the first of the classifier's two layers (§3.3). Weights are a
|
| 91 |
+
softmax over learned per-position logits, masked to the valid tokens, so
|
| 92 |
+
padding and the <s>/</s> markers contribute nothing. Logits start as a
|
| 93 |
+
linear ramp over the outermost ``taper`` byte positions, encoding the prior
|
| 94 |
+
that a byte near the cut is likely part of a broken instruction.
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
def __init__(self, n_positions: int, hidden_size: int, taper: int = 32, taper_floor: float = -2.0):
|
| 98 |
+
super().__init__()
|
| 99 |
+
logits = torch.zeros(n_positions)
|
| 100 |
+
taper = max(0, min(taper, n_positions // 2))
|
| 101 |
+
if taper:
|
| 102 |
+
ramp = torch.linspace(taper_floor, 0.0, taper + 1)[:-1]
|
| 103 |
+
logits[:taper] = ramp
|
| 104 |
+
logits[n_positions - taper :] = ramp.flip(0)
|
| 105 |
+
self.pos_logits = nn.Parameter(logits)
|
| 106 |
+
self.proj = nn.Linear(hidden_size, hidden_size)
|
| 107 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 108 |
+
|
| 109 |
+
def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 110 |
+
# hidden: (B, T, H); attention_mask: (B, T) with 1 on real tokens
|
| 111 |
+
n = hidden.size(1)
|
| 112 |
+
logits = self.pos_logits[:n].unsqueeze(0).expand(hidden.size(0), -1)
|
| 113 |
+
logits = logits.masked_fill(attention_mask == 0, torch.finfo(logits.dtype).min)
|
| 114 |
+
weights = logits.softmax(dim=-1).unsqueeze(-1)
|
| 115 |
+
pooled = (hidden * weights).sum(dim=1)
|
| 116 |
+
return self.norm(F.gelu(self.proj(pooled)))
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class MeanPool(nn.Module):
|
| 120 |
+
"""Plain masked mean pooling — the ablation baseline for the head."""
|
| 121 |
+
|
| 122 |
+
def __init__(self, hidden_size: int):
|
| 123 |
+
super().__init__()
|
| 124 |
+
self.proj = nn.Linear(hidden_size, hidden_size)
|
| 125 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 126 |
+
|
| 127 |
+
def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 128 |
+
mask = attention_mask.unsqueeze(-1).to(hidden.dtype)
|
| 129 |
+
pooled = (hidden * mask).sum(1) / mask.sum(1).clamp(min=1.0)
|
| 130 |
+
return self.norm(F.gelu(self.proj(pooled)))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class AttentionPool(nn.Module):
|
| 135 |
+
"""Content-based attention pooling: weight a position by *what* is there.
|
| 136 |
+
|
| 137 |
+
:class:`BorderTaperedPool` weights a position by *where* it is, which makes
|
| 138 |
+
it a fixed weighted mean. That is the right prior for the border problem it
|
| 139 |
+
was written for, and the wrong one for O2/O3. The byte patterns that
|
| 140 |
+
separate the two levels -- a vectorised loop body, one copy of an unrolled
|
| 141 |
+
loop, a particular alignment pad -- occupy a handful of positions in a
|
| 142 |
+
512-byte window and are absent from the rest of it, so any weighted mean
|
| 143 |
+
dilutes the evidence by the fraction of the window that carries it.
|
| 144 |
+
Attention can concentrate on those positions instead.
|
| 145 |
+
|
| 146 |
+
``heads > 1`` lets the head attend to several kinds of evidence at once
|
| 147 |
+
(say, a vector opcode in one head and a padding pattern in another).
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
def __init__(self, hidden_size: int, heads: int = 4):
|
| 151 |
+
super().__init__()
|
| 152 |
+
self.heads = heads
|
| 153 |
+
self.score = nn.Linear(hidden_size, heads)
|
| 154 |
+
self.proj = nn.Linear(hidden_size * heads, hidden_size)
|
| 155 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 156 |
+
|
| 157 |
+
def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 158 |
+
scores = self.score(hidden) # (B, T, heads)
|
| 159 |
+
keep = attention_mask.unsqueeze(-1) == 0
|
| 160 |
+
scores = scores.masked_fill(keep, torch.finfo(scores.dtype).min)
|
| 161 |
+
weights = scores.softmax(dim=1) # over positions
|
| 162 |
+
pooled = torch.einsum("bth,btd->bhd", weights, hidden).flatten(1)
|
| 163 |
+
return self.norm(F.gelu(self.proj(pooled)))
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
class MaxPool(nn.Module):
|
| 167 |
+
"""Per-dimension max over positions -- the hard version of AttentionPool.
|
| 168 |
+
|
| 169 |
+
Cheaper and with no parameters of its own in the aggregation step, at the
|
| 170 |
+
cost of committing to one position per feature dimension.
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
def __init__(self, hidden_size: int):
|
| 174 |
+
super().__init__()
|
| 175 |
+
self.proj = nn.Linear(hidden_size, hidden_size)
|
| 176 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 177 |
+
|
| 178 |
+
def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 179 |
+
keep = attention_mask.unsqueeze(-1) == 0
|
| 180 |
+
pooled = hidden.masked_fill(keep, torch.finfo(hidden.dtype).min).max(dim=1).values
|
| 181 |
+
return self.norm(F.gelu(self.proj(pooled)))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
class MILHead(nn.Module):
|
| 185 |
+
"""Per-token classifier plus a smooth max over positions (returns logits).
|
| 186 |
+
|
| 187 |
+
The multiple-instance view of the task: a window is O3 if it *contains* an
|
| 188 |
+
O3 fingerprint, so the window score should be a max over per-token scores
|
| 189 |
+
rather than a score computed from an average of tokens. Pooling after the
|
| 190 |
+
classifier rather than before it is the whole point -- it keeps a single
|
| 191 |
+
strongly-O3 position from being averaged into 500 neutral ones.
|
| 192 |
+
|
| 193 |
+
``logsumexp`` is the differentiable max: ``tau -> 0`` recovers a hard max,
|
| 194 |
+
large ``tau`` approaches the mean, so one module spans both ends and ``tau``
|
| 195 |
+
is a knob rather than a commitment. ``log n`` is subtracted so the result is
|
| 196 |
+
a log-*mean*-exp and therefore invariant to how many valid tokens a window
|
| 197 |
+
has -- without it a short window would score systematically lower on every
|
| 198 |
+
class, which is exactly the confound that makes padded short functions look
|
| 199 |
+
hard.
|
| 200 |
+
"""
|
| 201 |
+
|
| 202 |
+
def __init__(self, hidden_size: int, num_labels: int, *, tau: float = 1.0, dropout: float = 0.1):
|
| 203 |
+
super().__init__()
|
| 204 |
+
self.proj = nn.Linear(hidden_size, hidden_size)
|
| 205 |
+
self.norm = nn.LayerNorm(hidden_size)
|
| 206 |
+
self.drop = nn.Dropout(dropout)
|
| 207 |
+
self.token_clf = nn.Linear(hidden_size, num_labels)
|
| 208 |
+
self.tau = tau
|
| 209 |
+
nn.init.normal_(self.token_clf.weight, std=0.02)
|
| 210 |
+
nn.init.zeros_(self.token_clf.bias)
|
| 211 |
+
|
| 212 |
+
def forward(self, hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 213 |
+
h = self.norm(F.gelu(self.proj(hidden)))
|
| 214 |
+
tok = self.token_clf(self.drop(h)) # (B, T, C)
|
| 215 |
+
keep = attention_mask.unsqueeze(-1) == 0
|
| 216 |
+
tok = tok.masked_fill(keep, torch.finfo(tok.dtype).min)
|
| 217 |
+
n = attention_mask.sum(dim=1, keepdim=True).clamp(min=1).to(tok.dtype)
|
| 218 |
+
return self.tau * (torch.logsumexp(tok / self.tau, dim=1) - torch.log(n))
|
| 219 |
+
|
| 220 |
+
def token_logits(self, hidden: torch.Tensor) -> torch.Tensor:
|
| 221 |
+
"""Per-position class scores, for locating *where* the evidence is."""
|
| 222 |
+
return self.token_clf(self.norm(F.gelu(self.proj(hidden))))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class BinProvForProvenance(nn.Module):
|
| 226 |
+
"""Encoder + classifier, i.e. Eq. (2) of the paper.
|
| 227 |
+
|
| 228 |
+
``y_hat = f_c(f_e(concat(E_b, E_p, E_s)))``
|
| 229 |
+
"""
|
| 230 |
+
|
| 231 |
+
def __init__(
|
| 232 |
+
self,
|
| 233 |
+
cfg: BinProvConfig,
|
| 234 |
+
num_labels: int,
|
| 235 |
+
*,
|
| 236 |
+
pool: str = "border",
|
| 237 |
+
classifier_dropout: float = 0.1,
|
| 238 |
+
taper: int = 32,
|
| 239 |
+
pool_heads: int = 4,
|
| 240 |
+
mil_tau: float = 1.0,
|
| 241 |
+
label_smoothing: float = 0.0,
|
| 242 |
+
):
|
| 243 |
+
super().__init__()
|
| 244 |
+
self.cfg = cfg
|
| 245 |
+
self.num_labels = num_labels
|
| 246 |
+
self.pool_kind = pool
|
| 247 |
+
self.pool_heads = pool_heads
|
| 248 |
+
self.mil_tau = mil_tau
|
| 249 |
+
self.label_smoothing = label_smoothing
|
| 250 |
+
self.encoder = RobertaModel(cfg.to_roberta(), add_pooling_layer=False)
|
| 251 |
+
# A "mil" head produces class logits itself, so there is no pooled
|
| 252 |
+
# vector for a separate classifier to consume; `pools_to_logits` records
|
| 253 |
+
# which of the two shapes we are in.
|
| 254 |
+
self.pools_to_logits = pool == "mil"
|
| 255 |
+
if pool == "border":
|
| 256 |
+
self.pool = BorderTaperedPool(cfg.seq_tokens, cfg.hidden_size, taper=taper)
|
| 257 |
+
elif pool == "mean":
|
| 258 |
+
self.pool = MeanPool(cfg.hidden_size)
|
| 259 |
+
elif pool == "attn":
|
| 260 |
+
self.pool = AttentionPool(cfg.hidden_size, heads=pool_heads)
|
| 261 |
+
elif pool == "max":
|
| 262 |
+
self.pool = MaxPool(cfg.hidden_size)
|
| 263 |
+
elif pool == "mil":
|
| 264 |
+
self.pool = MILHead(
|
| 265 |
+
cfg.hidden_size, num_labels, tau=mil_tau, dropout=classifier_dropout
|
| 266 |
+
)
|
| 267 |
+
elif pool == "cls":
|
| 268 |
+
self.pool = None
|
| 269 |
+
else:
|
| 270 |
+
raise ValueError(f"unknown pool {pool!r} (border|mean|attn|max|mil|cls)")
|
| 271 |
+
self.dropout = nn.Dropout(classifier_dropout)
|
| 272 |
+
if self.pools_to_logits:
|
| 273 |
+
self.classifier = nn.Identity()
|
| 274 |
+
else:
|
| 275 |
+
self.classifier = nn.Linear(cfg.hidden_size, num_labels)
|
| 276 |
+
nn.init.normal_(self.classifier.weight, std=0.02)
|
| 277 |
+
nn.init.zeros_(self.classifier.bias)
|
| 278 |
+
|
| 279 |
+
def forward(
|
| 280 |
+
self,
|
| 281 |
+
input_ids: torch.Tensor,
|
| 282 |
+
attention_mask: torch.Tensor,
|
| 283 |
+
token_type_ids: torch.Tensor | None = None,
|
| 284 |
+
labels: torch.Tensor | None = None,
|
| 285 |
+
):
|
| 286 |
+
out = self.encoder(
|
| 287 |
+
input_ids=input_ids,
|
| 288 |
+
attention_mask=attention_mask,
|
| 289 |
+
token_type_ids=token_type_ids,
|
| 290 |
+
)
|
| 291 |
+
hidden = out.last_hidden_state # E_final, shape (B, T, H)
|
| 292 |
+
if self.pools_to_logits:
|
| 293 |
+
logits = self.pool(hidden, attention_mask)
|
| 294 |
+
else:
|
| 295 |
+
pooled = hidden[:, 0] if self.pool is None else self.pool(hidden, attention_mask)
|
| 296 |
+
logits = self.classifier(self.dropout(pooled))
|
| 297 |
+
loss = None
|
| 298 |
+
if labels is not None:
|
| 299 |
+
loss = F.cross_entropy(
|
| 300 |
+
logits.float(), labels, label_smoothing=self.label_smoothing
|
| 301 |
+
)
|
| 302 |
+
return {"loss": loss, "logits": logits}
|
| 303 |
+
|
| 304 |
+
# -- checkpointing -----------------------------------------------------
|
| 305 |
+
|
| 306 |
+
@staticmethod
|
| 307 |
+
def _resize_position_embeddings(state: dict, want: int, how: str = "tile") -> dict:
|
| 308 |
+
"""Extend a pre-trained absolute position table to a longer sequence.
|
| 309 |
+
|
| 310 |
+
The MLM checkpoint learned 516 position embeddings because the paper's
|
| 311 |
+
sequence is 512 bytes. A longer-sequence encoder needs more, and the extra
|
| 312 |
+
rows have to come from somewhere. Two ways, and the difference is not
|
| 313 |
+
cosmetic — measured on the O2/O3 task, ``interp`` costs 5 points against
|
| 314 |
+
the 512-byte baseline while ``tile`` does not:
|
| 315 |
+
|
| 316 |
+
``interp``
|
| 317 |
+
Linear interpolation, the move that extends a vision transformer to a
|
| 318 |
+
larger image. It preserves the table's global *shape* and destroys its
|
| 319 |
+
local structure: stretching 2x makes each adjacent pair of positions
|
| 320 |
+
nearly identical. For an image patch grid that is harmless. For a byte
|
| 321 |
+
encoder whose pre-training learned that neighbouring positions differ
|
| 322 |
+
— instruction boundaries, alignment padding — it is not, and it shows
|
| 323 |
+
up as an encoder that will not fit the training set any more.
|
| 324 |
+
|
| 325 |
+
``tile``
|
| 326 |
+
Position ``k`` reuses the learned row ``k mod (have - 1)``. Local
|
| 327 |
+
structure is preserved exactly; what is lost is the ability to tell
|
| 328 |
+
absolute position 100 from position 614. For this task that is the
|
| 329 |
+
cheaper thing to give up, since a provenance cue means the same thing
|
| 330 |
+
wherever in the window it appears.
|
| 331 |
+
|
| 332 |
+
Rows are handled from index 1 up, because HF derives RoBERTa position ids
|
| 333 |
+
as ``padding_idx + 1 + i`` and so never uses row 0.
|
| 334 |
+
"""
|
| 335 |
+
key = "embeddings.position_embeddings.weight"
|
| 336 |
+
if key not in state or state[key].shape[0] == want:
|
| 337 |
+
return state
|
| 338 |
+
have = state[key]
|
| 339 |
+
head, tail = have[:1], have[1:]
|
| 340 |
+
if how == "interp":
|
| 341 |
+
grown = F.interpolate(
|
| 342 |
+
tail.T.unsqueeze(0).float(), size=want - 1, mode="linear", align_corners=True
|
| 343 |
+
).squeeze(0).T.to(have.dtype)
|
| 344 |
+
elif how == "tile":
|
| 345 |
+
reps = -(-(want - 1) // tail.shape[0])
|
| 346 |
+
grown = tail.repeat(reps, 1)[: want - 1]
|
| 347 |
+
else:
|
| 348 |
+
raise ValueError(f"unknown position extension {how!r} (tile|interp)")
|
| 349 |
+
state = dict(state)
|
| 350 |
+
state[key] = torch.cat([head, grown], dim=0)
|
| 351 |
+
# position_ids is a plain arange buffer; regenerate rather than stretch
|
| 352 |
+
for k in list(state):
|
| 353 |
+
if k.endswith("embeddings.position_ids") or k.endswith("embeddings.token_type_ids"):
|
| 354 |
+
state.pop(k)
|
| 355 |
+
return state
|
| 356 |
+
|
| 357 |
+
def load_encoder_from_mlm(
|
| 358 |
+
self, mlm_dir: str | Path, *, strict: bool = False, pos_extend: str = "tile"
|
| 359 |
+
) -> list[str]:
|
| 360 |
+
"""Warm-start the encoder from an MLM checkpoint (the transfer step).
|
| 361 |
+
|
| 362 |
+
Returns the list of encoder parameters that were *not* found, which
|
| 363 |
+
should be empty for a matching config — worth asserting in a run log,
|
| 364 |
+
since a silent mismatch here would quietly turn fine-tuning into
|
| 365 |
+
training from scratch.
|
| 366 |
+
"""
|
| 367 |
+
mlm = RobertaForMaskedLM.from_pretrained(str(mlm_dir))
|
| 368 |
+
state = self._resize_position_embeddings(
|
| 369 |
+
mlm.roberta.state_dict(),
|
| 370 |
+
self.encoder.embeddings.position_embeddings.weight.shape[0],
|
| 371 |
+
pos_extend,
|
| 372 |
+
)
|
| 373 |
+
missing, unexpected = self.encoder.load_state_dict(state, strict=strict)
|
| 374 |
+
del mlm
|
| 375 |
+
if strict and (missing or unexpected):
|
| 376 |
+
raise RuntimeError(f"encoder mismatch: missing={missing} unexpected={unexpected}")
|
| 377 |
+
return list(missing)
|
| 378 |
+
|
| 379 |
+
def save(self, out_dir: str | Path, *, extra: dict | None = None) -> None:
|
| 380 |
+
out = Path(out_dir)
|
| 381 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 382 |
+
torch.save(self.state_dict(), out / "model.pt")
|
| 383 |
+
self.cfg.save(out / "binprov_config.json")
|
| 384 |
+
(out / "head.json").write_text(
|
| 385 |
+
json.dumps(
|
| 386 |
+
{
|
| 387 |
+
"num_labels": self.num_labels,
|
| 388 |
+
"pool": self.pool_kind,
|
| 389 |
+
"pool_heads": self.pool_heads,
|
| 390 |
+
"mil_tau": self.mil_tau,
|
| 391 |
+
**(extra or {}),
|
| 392 |
+
},
|
| 393 |
+
indent=2,
|
| 394 |
+
)
|
| 395 |
+
+ "\n"
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
@classmethod
|
| 399 |
+
def load(cls, ckpt_dir: str | Path, *, map_location="cpu") -> tuple[BinProvForProvenance, dict]:
|
| 400 |
+
d = Path(ckpt_dir)
|
| 401 |
+
cfg = BinProvConfig.load(d / "binprov_config.json")
|
| 402 |
+
head = json.loads((d / "head.json").read_text())
|
| 403 |
+
model = cls(
|
| 404 |
+
cfg,
|
| 405 |
+
head["num_labels"],
|
| 406 |
+
pool=head.get("pool", "border"),
|
| 407 |
+
pool_heads=head.get("pool_heads", 4),
|
| 408 |
+
mil_tau=head.get("mil_tau", 1.0),
|
| 409 |
+
)
|
| 410 |
+
state = torch.load(d / "model.pt", map_location=map_location, weights_only=True)
|
| 411 |
+
model.load_state_dict(state)
|
| 412 |
+
return model, head
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def count_parameters(model: nn.Module) -> int:
|
| 416 |
+
return sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def describe(model: nn.Module) -> str:
|
| 420 |
+
n = count_parameters(model)
|
| 421 |
+
return f"{type(model).__name__}: {n:,} trainable parameters ({n / 1e6:.1f}M)"
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def param_groups(model: nn.Module, weight_decay: float = 0.01):
|
| 425 |
+
"""AdamW groups that exempt biases and LayerNorm from weight decay."""
|
| 426 |
+
decay, no_decay = [], []
|
| 427 |
+
for name, p in model.named_parameters():
|
| 428 |
+
if not p.requires_grad:
|
| 429 |
+
continue
|
| 430 |
+
if p.ndim == 1 or name.endswith(".bias") or "LayerNorm" in name or "norm" in name:
|
| 431 |
+
no_decay.append(p)
|
| 432 |
+
else:
|
| 433 |
+
decay.append(p)
|
| 434 |
+
# Empty groups are dropped: a frozen encoder would otherwise hand AdamW a
|
| 435 |
+
# group with no parameters.
|
| 436 |
+
groups = [
|
| 437 |
+
{"params": decay, "weight_decay": weight_decay},
|
| 438 |
+
{"params": no_decay, "weight_decay": 0.0},
|
| 439 |
+
]
|
| 440 |
+
return [g for g in groups if g["params"]]
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def cosine_schedule_with_warmup(optimizer, warmup_steps: int, total_steps: int, min_ratio: float = 0.02):
|
| 444 |
+
"""Linear warmup then cosine decay — standard for MLM pre-training."""
|
| 445 |
+
|
| 446 |
+
def lr_lambda(step: int) -> float:
|
| 447 |
+
if warmup_steps > 0 and step < warmup_steps:
|
| 448 |
+
return step / max(1, warmup_steps)
|
| 449 |
+
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
|
| 450 |
+
progress = min(1.0, max(0.0, progress))
|
| 451 |
+
return min_ratio + (1 - min_ratio) * 0.5 * (1 + math.cos(math.pi * progress))
|
| 452 |
+
|
| 453 |
+
return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
|
binprov/provenance.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provenance labels and the classification tasks defined on them.
|
| 2 |
+
|
| 3 |
+
The paper decomposes provenance identification into independent sub-tasks
|
| 4 |
+
(§3.3) rather than predicting the 8-way compiler×optimization product, because
|
| 5 |
+
"specialized classifiers have better performance". Each entry in :data:`TASKS`
|
| 6 |
+
is one such sub-task: which binaries it keeps, and how a kept binary maps to a
|
| 7 |
+
class index.
|
| 8 |
+
|
| 9 |
+
Tasks (with the paper table they feed):
|
| 10 |
+
|
| 11 |
+
compiler GCC vs Clang Tables 3, 6, 7
|
| 12 |
+
opt_hl low (O0/O1) vs high (O2/O3) Tables 3, 6
|
| 13 |
+
opt4 O0 vs O1 vs O2 vs O3 Tables 4, 5, 7
|
| 14 |
+
opt_o0o1 O0 vs O1 (only O0/O1 binaries) Table 4
|
| 15 |
+
opt_o2o3 O2 vs O3 (only O2/O3 binaries) Tables 4, 6, 7
|
| 16 |
+
arch instruction set architecture §5.1(d)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from typing import Callable, Sequence
|
| 24 |
+
|
| 25 |
+
COMPILERS = ("gcc", "clang")
|
| 26 |
+
OPT_LEVELS = ("O0", "O1", "O2", "O3")
|
| 27 |
+
#: architectures present in BinKit; the paper evaluates the first four
|
| 28 |
+
ARCHES = (
|
| 29 |
+
"x86_64",
|
| 30 |
+
"x86_32",
|
| 31 |
+
"arm_64",
|
| 32 |
+
"arm_32",
|
| 33 |
+
"mips_64",
|
| 34 |
+
"mips_32",
|
| 35 |
+
"mipseb_64",
|
| 36 |
+
"mipseb_32",
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# label extraction from a path
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
# BinKit's on-disk layout has changed between releases (and mirrors repack it),
|
| 44 |
+
# so rather than hard-coding one directory template we scan every path
|
| 45 |
+
# component for recognisable tokens. `scripts/build_corpus.py --dry-run` prints
|
| 46 |
+
# what this produced so a new layout can be spotted before a long build.
|
| 47 |
+
_ARCH_RE = re.compile(
|
| 48 |
+
r"(?<![a-z0-9])(x86|x64|i386|arm|aarch64|mips|mipseb|mipsel|ppc|riscv)"
|
| 49 |
+
r"(?:[-_]?(32|64))?(?![a-z0-9])",
|
| 50 |
+
re.I,
|
| 51 |
+
)
|
| 52 |
+
_COMPILER_RE = re.compile(
|
| 53 |
+
r"(?<![a-z])(gcc|g\+\+|clang|clang\+\+)(?:[-_]?([0-9]+(?:\.[0-9]+){0,2}))?(?![a-z])", re.I
|
| 54 |
+
)
|
| 55 |
+
_OPT_RE = re.compile(r"(?<![a-z0-9])-?O(0|1|2|3|s|fast)(?![a-z0-9])")
|
| 56 |
+
_EXTRA_RE = re.compile(
|
| 57 |
+
r"(?<![a-z])(normal|nopie|pie|noinline|lto|sizeopt|obfus(?:2loop)?|bcf|fla|sub|all)(?![a-z])",
|
| 58 |
+
re.I,
|
| 59 |
+
)
|
| 60 |
+
# Obfuscator-LLVM transforms, reported separately in Table 7
|
| 61 |
+
_OBF_TOKENS = {"bcf", "fla", "sub", "all"}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class Labels:
|
| 66 |
+
"""Everything we know about how one binary was produced."""
|
| 67 |
+
|
| 68 |
+
arch: str | None = None
|
| 69 |
+
compiler: str | None = None
|
| 70 |
+
compiler_version: str | None = None
|
| 71 |
+
opt: str | None = None
|
| 72 |
+
extra: str = "normal" # normal | pie | lto | noinline | sizeopt | ...
|
| 73 |
+
obfuscation: str | None = None # bcf | fla | sub | all
|
| 74 |
+
|
| 75 |
+
def is_complete(self) -> bool:
|
| 76 |
+
"""True when the binary can be used for every task in :data:`TASKS`."""
|
| 77 |
+
return bool(self.arch and self.compiler and self.opt)
|
| 78 |
+
|
| 79 |
+
def as_dict(self) -> dict:
|
| 80 |
+
return {
|
| 81 |
+
"arch": self.arch,
|
| 82 |
+
"compiler": self.compiler,
|
| 83 |
+
"compiler_version": self.compiler_version,
|
| 84 |
+
"opt": self.opt,
|
| 85 |
+
"extra": self.extra,
|
| 86 |
+
"obfuscation": self.obfuscation,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _norm_arch(base: str, width: str | None) -> str:
|
| 91 |
+
base = base.lower()
|
| 92 |
+
if base in ("x64",):
|
| 93 |
+
return "x86_64"
|
| 94 |
+
if base in ("i386",):
|
| 95 |
+
return "x86_32"
|
| 96 |
+
if base == "aarch64":
|
| 97 |
+
return "arm_64"
|
| 98 |
+
if base == "mipsel":
|
| 99 |
+
base = "mips"
|
| 100 |
+
if width:
|
| 101 |
+
return f"{base}_{width}"
|
| 102 |
+
# widths that are implied by the architecture name alone
|
| 103 |
+
return {"arm": "arm_32", "x86": "x86_32", "mips": "mips_32"}.get(base, base)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def parse_labels(path: str) -> Labels:
|
| 107 |
+
"""Derive provenance labels by scanning the components of ``path``.
|
| 108 |
+
|
| 109 |
+
Later components win, so a per-binary directory overrides a package-level
|
| 110 |
+
one. Compiler family/version are only taken together, to avoid pairing
|
| 111 |
+
"gcc" from one component with a version number from another.
|
| 112 |
+
"""
|
| 113 |
+
lab = Labels()
|
| 114 |
+
parts = [p for p in str(path).replace("\\", "/").split("/") if p]
|
| 115 |
+
for part in parts:
|
| 116 |
+
if m := _ARCH_RE.search(part):
|
| 117 |
+
lab.arch = _norm_arch(m.group(1), m.group(2))
|
| 118 |
+
if m := _COMPILER_RE.search(part):
|
| 119 |
+
fam = m.group(1).lower().rstrip("+")
|
| 120 |
+
lab.compiler = "gcc" if fam in ("gcc", "g") else "clang"
|
| 121 |
+
if m.group(2):
|
| 122 |
+
lab.compiler_version = m.group(2)
|
| 123 |
+
if m := _OPT_RE.search(part):
|
| 124 |
+
lab.opt = "O" + m.group(1)
|
| 125 |
+
# finditer, not search: a component can carry both the family and the
|
| 126 |
+
# specific transform, e.g. "x86_64-clang-obfus-sub-O2"
|
| 127 |
+
for m in _EXTRA_RE.finditer(part):
|
| 128 |
+
tok = m.group(1).lower()
|
| 129 |
+
if tok in _OBF_TOKENS:
|
| 130 |
+
lab.obfuscation = tok
|
| 131 |
+
lab.extra = "obfus"
|
| 132 |
+
elif tok.startswith("obfus"):
|
| 133 |
+
lab.extra = "obfus"
|
| 134 |
+
else:
|
| 135 |
+
lab.extra = tok
|
| 136 |
+
return lab
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# tasks
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@dataclass(frozen=True)
|
| 145 |
+
class Task:
|
| 146 |
+
"""A classification task: a filter plus a label map.
|
| 147 |
+
|
| 148 |
+
``marginal_of``/``marginal_map`` describe a *factorized* task: one whose
|
| 149 |
+
classes are finer than the classes we actually want to score, together with
|
| 150 |
+
the map from its class index to the coarse one. Training on the finer split
|
| 151 |
+
and summing the probabilities back down is a different estimator of the same
|
| 152 |
+
quantity -- it hands the model the compiler as extra supervision instead of
|
| 153 |
+
making it marginalise the compiler out on its own.
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
name: str
|
| 157 |
+
classes: tuple[str, ...]
|
| 158 |
+
keep: Callable[[dict], bool]
|
| 159 |
+
to_class: Callable[[dict], str]
|
| 160 |
+
note: str = "" # which paper table this task feeds
|
| 161 |
+
marginal_of: str | None = None
|
| 162 |
+
marginal_map: tuple[int, ...] = ()
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def num_labels(self) -> int:
|
| 166 |
+
return len(self.classes)
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def scored_classes(self) -> tuple[str, ...]:
|
| 170 |
+
"""The classes metrics are reported over — coarse ones if factorized."""
|
| 171 |
+
if self.marginal_of is None:
|
| 172 |
+
return self.classes
|
| 173 |
+
return TASKS[self.marginal_of].classes
|
| 174 |
+
|
| 175 |
+
def label_of(self, labels: dict) -> int | None:
|
| 176 |
+
"""Class index for a binary, or None if it is out of scope."""
|
| 177 |
+
if not self.keep(labels):
|
| 178 |
+
return None
|
| 179 |
+
cls = self.to_class(labels)
|
| 180 |
+
try:
|
| 181 |
+
return self.classes.index(cls)
|
| 182 |
+
except ValueError:
|
| 183 |
+
return None
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _opt_in(*levels: str) -> Callable[[dict], bool]:
|
| 187 |
+
allowed = set(levels)
|
| 188 |
+
return lambda d: d.get("opt") in allowed
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
TASKS: dict[str, Task] = {
|
| 192 |
+
"compiler": Task(
|
| 193 |
+
name="compiler",
|
| 194 |
+
classes=COMPILERS,
|
| 195 |
+
keep=lambda d: d.get("compiler") in COMPILERS,
|
| 196 |
+
to_class=lambda d: d["compiler"],
|
| 197 |
+
note="Table 3/6/7: GCC vs Clang",
|
| 198 |
+
),
|
| 199 |
+
"opt_hl": Task(
|
| 200 |
+
name="opt_hl",
|
| 201 |
+
classes=("low", "high"),
|
| 202 |
+
keep=_opt_in(*OPT_LEVELS),
|
| 203 |
+
to_class=lambda d: "low" if d["opt"] in ("O0", "O1") else "high",
|
| 204 |
+
note="Table 3/6: O0,O1 vs O2,O3",
|
| 205 |
+
),
|
| 206 |
+
"opt4": Task(
|
| 207 |
+
name="opt4",
|
| 208 |
+
classes=OPT_LEVELS,
|
| 209 |
+
keep=_opt_in(*OPT_LEVELS),
|
| 210 |
+
to_class=lambda d: d["opt"],
|
| 211 |
+
note="Table 4/5/7: O0/O1/O2/O3",
|
| 212 |
+
),
|
| 213 |
+
"opt_o0o1": Task(
|
| 214 |
+
name="opt_o0o1",
|
| 215 |
+
classes=("O0", "O1"),
|
| 216 |
+
keep=_opt_in("O0", "O1"),
|
| 217 |
+
to_class=lambda d: d["opt"],
|
| 218 |
+
note="Table 4: O0 vs O1",
|
| 219 |
+
),
|
| 220 |
+
"opt_o2o3": Task(
|
| 221 |
+
name="opt_o2o3",
|
| 222 |
+
classes=("O2", "O3"),
|
| 223 |
+
keep=_opt_in("O2", "O3"),
|
| 224 |
+
to_class=lambda d: d["opt"],
|
| 225 |
+
note="Table 4/6/7: O2 vs O3 (the hard one)",
|
| 226 |
+
),
|
| 227 |
+
"opt_o2o3_x": Task(
|
| 228 |
+
name="opt_o2o3_x",
|
| 229 |
+
classes=("gcc_O2", "gcc_O3", "clang_O2", "clang_O3"),
|
| 230 |
+
keep=lambda d: d.get("opt") in ("O2", "O3") and d.get("compiler") in COMPILERS,
|
| 231 |
+
to_class=lambda d: f"{d['compiler']}_{d['opt']}",
|
| 232 |
+
note="O2 vs O3 factorized by compiler, marginalized back for scoring",
|
| 233 |
+
marginal_of="opt_o2o3",
|
| 234 |
+
marginal_map=(0, 1, 0, 1),
|
| 235 |
+
),
|
| 236 |
+
"opt4_x": Task(
|
| 237 |
+
name="opt4_x",
|
| 238 |
+
classes=tuple(f"{c}_{o}" for c in COMPILERS for o in OPT_LEVELS),
|
| 239 |
+
keep=lambda d: d.get("opt") in OPT_LEVELS and d.get("compiler") in COMPILERS,
|
| 240 |
+
to_class=lambda d: f"{d['compiler']}_{d['opt']}",
|
| 241 |
+
note="O0/O1/O2/O3 factorized by compiler, marginalized back for scoring",
|
| 242 |
+
marginal_of="opt4",
|
| 243 |
+
marginal_map=(0, 1, 2, 3, 0, 1, 2, 3),
|
| 244 |
+
),
|
| 245 |
+
"arch": Task(
|
| 246 |
+
name="arch",
|
| 247 |
+
classes=ARCHES,
|
| 248 |
+
keep=lambda d: d.get("arch") in ARCHES,
|
| 249 |
+
to_class=lambda d: d["arch"],
|
| 250 |
+
note="§5.1(d): instruction set architecture",
|
| 251 |
+
),
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def get_task(name: str) -> Task:
|
| 256 |
+
try:
|
| 257 |
+
return TASKS[name]
|
| 258 |
+
except KeyError:
|
| 259 |
+
raise SystemExit(
|
| 260 |
+
f"unknown task {name!r}; available: {', '.join(sorted(TASKS))}"
|
| 261 |
+
) from None
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def toolchain_key(labels: dict) -> str:
|
| 265 |
+
"""Stable identifier for a compilation configuration.
|
| 266 |
+
|
| 267 |
+
Used to balance the corpus across configurations and to report per-cell
|
| 268 |
+
numbers (e.g. Table 5, which splits optimization results by compiler).
|
| 269 |
+
"""
|
| 270 |
+
parts: Sequence[str] = (
|
| 271 |
+
labels.get("arch") or "?",
|
| 272 |
+
labels.get("compiler") or "?",
|
| 273 |
+
labels.get("compiler_version") or "-",
|
| 274 |
+
labels.get("opt") or "?",
|
| 275 |
+
labels.get("obfuscation") or labels.get("extra") or "normal",
|
| 276 |
+
)
|
| 277 |
+
return "/".join(parts)
|
binprov/vocab.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Byte vocabulary for BinProv (paper §3.2, "Input representation E").
|
| 2 |
+
|
| 3 |
+
The paper defines a vocabulary of 256 possible byte values plus 5 special
|
| 4 |
+
tags: <PAD>, <S>, </S>, <UNK> and <MASK>, for a total of 261 tokens.
|
| 5 |
+
|
| 6 |
+
Layout used here (ids are stable and must not be reshuffled once a model has
|
| 7 |
+
been pre-trained, since they are baked into the embedding matrix):
|
| 8 |
+
|
| 9 |
+
0 <pad>
|
| 10 |
+
1 <s>
|
| 11 |
+
2 </s>
|
| 12 |
+
3 <unk>
|
| 13 |
+
4 <mask>
|
| 14 |
+
5 0x00
|
| 15 |
+
6 0x01
|
| 16 |
+
...
|
| 17 |
+
260 0xff
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
PAD_ID = 0
|
| 23 |
+
BOS_ID = 1
|
| 24 |
+
EOS_ID = 2
|
| 25 |
+
UNK_ID = 3
|
| 26 |
+
MASK_ID = 4
|
| 27 |
+
|
| 28 |
+
NUM_SPECIAL = 5
|
| 29 |
+
BYTE_OFFSET = NUM_SPECIAL
|
| 30 |
+
VOCAB_SIZE = NUM_SPECIAL + 256 # 261
|
| 31 |
+
|
| 32 |
+
SPECIAL_TOKENS = ("<pad>", "<s>", "</s>", "<unk>", "<mask>")
|
| 33 |
+
|
| 34 |
+
#: ids that are never a prediction target of the MLM task
|
| 35 |
+
NON_BYTE_IDS = frozenset({PAD_ID, BOS_ID, EOS_ID, UNK_ID, MASK_ID})
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def byte_to_id(b: int) -> int:
|
| 39 |
+
"""Map a raw byte value (0..255) to its token id."""
|
| 40 |
+
if not 0 <= b <= 255:
|
| 41 |
+
raise ValueError(f"not a byte value: {b}")
|
| 42 |
+
return b + BYTE_OFFSET
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def id_to_byte(i: int) -> int:
|
| 46 |
+
"""Inverse of :func:`byte_to_id`. Raises for special-token ids."""
|
| 47 |
+
if i < BYTE_OFFSET or i >= VOCAB_SIZE:
|
| 48 |
+
raise ValueError(f"not a byte token id: {i}")
|
| 49 |
+
return i - BYTE_OFFSET
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def encode(data: bytes) -> list[int]:
|
| 53 |
+
"""Encode raw bytes as token ids, without <s>/</s>."""
|
| 54 |
+
return [b + BYTE_OFFSET for b in data]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def decode(ids) -> bytes:
|
| 58 |
+
"""Decode token ids back to bytes, dropping special tokens."""
|
| 59 |
+
return bytes(i - BYTE_OFFSET for i in ids if i >= BYTE_OFFSET)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def token_repr(i: int) -> str:
|
| 63 |
+
"""Human-readable form of a token id, e.g. ``0x8b`` or ``<mask>``."""
|
| 64 |
+
if i < NUM_SPECIAL:
|
| 65 |
+
return SPECIAL_TOKENS[i]
|
| 66 |
+
return f"0x{i - BYTE_OFFSET:02x}"
|
binprov/vote.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Joint inference by majority voting (paper §3.4).
|
| 2 |
+
|
| 3 |
+
A single 512-byte sequence is not a meaningful entity: it may hold a fragment of
|
| 4 |
+
one function, or span several. The paper therefore aggregates sequence-level
|
| 5 |
+
predictions over all sequences belonging to the same function, or the same
|
| 6 |
+
binary, and takes the majority. That relies on the §2.3 measurement that >96% of
|
| 7 |
+
real projects compile an individual binary with a single optimization level, so
|
| 8 |
+
the sequences of one binary do share a label.
|
| 9 |
+
|
| 10 |
+
Voting is where most of the paper's headline numbers come from: on the hard
|
| 11 |
+
O2/O3 task it lifts accuracy from 83.6% (sequence) to 94.7% (function) to 99.8%
|
| 12 |
+
(binary), Tables 4 and 6.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def majority_vote(
|
| 21 |
+
group_ids: np.ndarray,
|
| 22 |
+
predictions: np.ndarray,
|
| 23 |
+
num_classes: int,
|
| 24 |
+
*,
|
| 25 |
+
probs: np.ndarray | None = None,
|
| 26 |
+
mode: str = "hard",
|
| 27 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 28 |
+
"""Aggregate per-sequence predictions into per-group predictions.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
group_ids: dense group index per sequence (see
|
| 32 |
+
:meth:`~binprov.corpus.SequenceIndex.group_ids`).
|
| 33 |
+
predictions: predicted class per sequence.
|
| 34 |
+
num_classes: size of the label set.
|
| 35 |
+
probs: per-sequence class probabilities, required for ``mode="soft"``.
|
| 36 |
+
mode: ``"hard"`` counts votes, as the paper does. ``"soft"`` sums
|
| 37 |
+
probabilities, which breaks ties more gracefully when a group has
|
| 38 |
+
few sequences.
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
``(groups, voted, n_seqs)`` — the sorted unique group ids, the voted
|
| 42 |
+
class for each, and how many sequences each group had.
|
| 43 |
+
"""
|
| 44 |
+
if len(group_ids) != len(predictions):
|
| 45 |
+
raise ValueError("group_ids and predictions must be the same length")
|
| 46 |
+
if len(group_ids) == 0:
|
| 47 |
+
empty_i = np.zeros(0, dtype=np.int64)
|
| 48 |
+
return empty_i, empty_i, empty_i
|
| 49 |
+
|
| 50 |
+
groups = np.unique(group_ids)
|
| 51 |
+
n_groups = int(groups.max()) + 1 # group_ids are dense, so this is exact
|
| 52 |
+
|
| 53 |
+
if mode == "hard":
|
| 54 |
+
flat = group_ids.astype(np.int64) * num_classes + predictions.astype(np.int64)
|
| 55 |
+
tally = np.bincount(flat, minlength=n_groups * num_classes).reshape(n_groups, num_classes)
|
| 56 |
+
elif mode == "soft":
|
| 57 |
+
if probs is None:
|
| 58 |
+
raise ValueError("mode='soft' needs probs")
|
| 59 |
+
tally = np.zeros((n_groups, num_classes), dtype=np.float64)
|
| 60 |
+
np.add.at(tally, group_ids.astype(np.int64), probs.astype(np.float64))
|
| 61 |
+
else:
|
| 62 |
+
raise ValueError(f"unknown mode {mode!r} (hard|soft)")
|
| 63 |
+
|
| 64 |
+
voted = tally.argmax(axis=1)
|
| 65 |
+
counts = np.bincount(group_ids.astype(np.int64), minlength=n_groups)
|
| 66 |
+
return groups, voted[groups], counts[groups]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def group_truth(group_ids: np.ndarray, labels: np.ndarray) -> np.ndarray:
|
| 70 |
+
"""The ground-truth label of each group.
|
| 71 |
+
|
| 72 |
+
Every sequence in a group must carry the same label — a group is one
|
| 73 |
+
function or one binary, and a function has exactly one provenance. A
|
| 74 |
+
mismatch means the corpus or the grouping is wrong, so this raises rather
|
| 75 |
+
than silently picking one.
|
| 76 |
+
"""
|
| 77 |
+
groups = np.unique(group_ids)
|
| 78 |
+
n_groups = int(groups.max()) + 1 if len(groups) else 0
|
| 79 |
+
first = np.full(n_groups, -1, dtype=np.int64)
|
| 80 |
+
order = np.argsort(group_ids, kind="stable")
|
| 81 |
+
g_sorted = group_ids[order]
|
| 82 |
+
l_sorted = labels[order]
|
| 83 |
+
starts = np.searchsorted(g_sorted, groups, side="left")
|
| 84 |
+
first[groups] = l_sorted[starts]
|
| 85 |
+
|
| 86 |
+
expanded = first[group_ids]
|
| 87 |
+
bad = expanded != labels
|
| 88 |
+
if bad.any():
|
| 89 |
+
n = int(bad.sum())
|
| 90 |
+
raise ValueError(
|
| 91 |
+
f"{n} sequences disagree with their group's label; "
|
| 92 |
+
"the voting groups do not match the label granularity"
|
| 93 |
+
)
|
| 94 |
+
return first[groups]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def vote_report(
|
| 98 |
+
group_ids: np.ndarray,
|
| 99 |
+
predictions: np.ndarray,
|
| 100 |
+
labels: np.ndarray,
|
| 101 |
+
num_classes: int,
|
| 102 |
+
*,
|
| 103 |
+
probs: np.ndarray | None = None,
|
| 104 |
+
mode: str = "hard",
|
| 105 |
+
) -> dict:
|
| 106 |
+
"""Voting accuracy plus the sequence-level accuracy it was built from."""
|
| 107 |
+
groups, voted, counts = majority_vote(
|
| 108 |
+
group_ids, predictions, num_classes, probs=probs, mode=mode
|
| 109 |
+
)
|
| 110 |
+
truth = group_truth(group_ids, labels)
|
| 111 |
+
correct = voted == truth
|
| 112 |
+
return {
|
| 113 |
+
"num_groups": int(len(groups)),
|
| 114 |
+
"num_sequences": int(len(predictions)),
|
| 115 |
+
"seq_accuracy": float((predictions == labels).mean()) if len(predictions) else 0.0,
|
| 116 |
+
"vote_accuracy": float(correct.mean()) if len(groups) else 0.0,
|
| 117 |
+
"mean_seqs_per_group": float(counts.mean()) if len(counts) else 0.0,
|
| 118 |
+
"median_seqs_per_group": float(np.median(counts)) if len(counts) else 0.0,
|
| 119 |
+
"mode": mode,
|
| 120 |
+
# kept for the per-binary drill-down in the analysis experiments
|
| 121 |
+
"_groups": groups,
|
| 122 |
+
"_voted": voted,
|
| 123 |
+
"_truth": truth,
|
| 124 |
+
"_counts": counts,
|
| 125 |
+
}
|
binprov_config.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seq_bytes": 2048,
|
| 3 |
+
"hidden_size": 768,
|
| 4 |
+
"num_hidden_layers": 12,
|
| 5 |
+
"num_attention_heads": 12,
|
| 6 |
+
"intermediate_size": 3072,
|
| 7 |
+
"max_segments": 2,
|
| 8 |
+
"hidden_dropout_prob": 0.1,
|
| 9 |
+
"attention_probs_dropout_prob": 0.1
|
| 10 |
+
}
|
environment.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"python": "3.12.1",
|
| 3 |
+
"platform": "Linux-4.18.0-553.137.1.el8_10.x86_64-x86_64-with-glibc2.28",
|
| 4 |
+
"sys.platform": "linux",
|
| 5 |
+
"torch": "2.8.0+cu128",
|
| 6 |
+
"transformers": "4.55.4",
|
| 7 |
+
"numpy": "2.3.3"
|
| 8 |
+
}
|
evidence/args.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"corpus": "data/corpus/binkit_x86_64",
|
| 3 |
+
"task": "opt4",
|
| 4 |
+
"out": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 5 |
+
"splits": "default",
|
| 6 |
+
"init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
|
| 7 |
+
"from_scratch": false,
|
| 8 |
+
"tag": "opt4_wide_seed29",
|
| 9 |
+
"arch": null,
|
| 10 |
+
"extra": null,
|
| 11 |
+
"compiler": null,
|
| 12 |
+
"version": null,
|
| 13 |
+
"test_version": null,
|
| 14 |
+
"train_compiler": null,
|
| 15 |
+
"max_seqs_per_binary": null,
|
| 16 |
+
"stride": 512,
|
| 17 |
+
"val_frac": 0.15,
|
| 18 |
+
"val_seed": 1234,
|
| 19 |
+
"split_mode": "program",
|
| 20 |
+
"seq_bytes": 2048,
|
| 21 |
+
"eval_window_bytes": 512,
|
| 22 |
+
"jitter": 0,
|
| 23 |
+
"pos_extend": "tile",
|
| 24 |
+
"pool": "border",
|
| 25 |
+
"pool_heads": 4,
|
| 26 |
+
"mil_tau": 1.0,
|
| 27 |
+
"taper": 32,
|
| 28 |
+
"dropout": null,
|
| 29 |
+
"classifier_dropout": 0.1,
|
| 30 |
+
"epochs": 6,
|
| 31 |
+
"batch_size": 4,
|
| 32 |
+
"grad_accum": 8,
|
| 33 |
+
"lr": 3e-05,
|
| 34 |
+
"head_lr": 0.0001,
|
| 35 |
+
"llrd": 1.0,
|
| 36 |
+
"weight_decay": 0.01,
|
| 37 |
+
"warmup_ratio": 0.06,
|
| 38 |
+
"clip_grad": 1.0,
|
| 39 |
+
"label_smoothing": 0.0,
|
| 40 |
+
"use_pairs": false,
|
| 41 |
+
"pair_loss": 0.0,
|
| 42 |
+
"pair_ce": 1.0,
|
| 43 |
+
"pair_margin": 1.0,
|
| 44 |
+
"pairs_per_batch": 32,
|
| 45 |
+
"pair_min_size": 32,
|
| 46 |
+
"pair_jitter": 64,
|
| 47 |
+
"workers": 6,
|
| 48 |
+
"seed": 29,
|
| 49 |
+
"resume": true,
|
| 50 |
+
"log_every": 100,
|
| 51 |
+
"val_max_seqs": 40000,
|
| 52 |
+
"tta": "none",
|
| 53 |
+
"tta_views": 4,
|
| 54 |
+
"tta_span": 128,
|
| 55 |
+
"save_probs": true,
|
| 56 |
+
"no_save_weights": false
|
| 57 |
+
}
|
evidence/evaluation.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"profile": "opt4_wide_seed29",
|
| 3 |
+
"task": "opt4",
|
| 4 |
+
"scored_classes": [
|
| 5 |
+
"O0",
|
| 6 |
+
"O1",
|
| 7 |
+
"O2",
|
| 8 |
+
"O3"
|
| 9 |
+
],
|
| 10 |
+
"sequence_accuracy": 0.842255,
|
| 11 |
+
"balanced_accuracy": 0.830951,
|
| 12 |
+
"binary_accuracy": 0.93883,
|
| 13 |
+
"num_sequences": 116321,
|
| 14 |
+
"num_binaries": 376,
|
| 15 |
+
"recorded_test_accuracy": 0.8422554826729481,
|
| 16 |
+
"recomputed_matches_recorded": true,
|
| 17 |
+
"checkpoint": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 18 |
+
"result_json": {
|
| 19 |
+
"accuracy": 0.8422554826729481,
|
| 20 |
+
"balanced_accuracy": 0.8309510174499452,
|
| 21 |
+
"confusion": [
|
| 22 |
+
[
|
| 23 |
+
37645,
|
| 24 |
+
14,
|
| 25 |
+
34,
|
| 26 |
+
19
|
| 27 |
+
],
|
| 28 |
+
[
|
| 29 |
+
10,
|
| 30 |
+
21157,
|
| 31 |
+
1149,
|
| 32 |
+
636
|
| 33 |
+
],
|
| 34 |
+
[
|
| 35 |
+
1,
|
| 36 |
+
2449,
|
| 37 |
+
17545,
|
| 38 |
+
6190
|
| 39 |
+
],
|
| 40 |
+
[
|
| 41 |
+
1,
|
| 42 |
+
1860,
|
| 43 |
+
5986,
|
| 44 |
+
21625
|
| 45 |
+
]
|
| 46 |
+
],
|
| 47 |
+
"n": 116321
|
| 48 |
+
},
|
| 49 |
+
"evaluated_at": "2026-09-06T03:48:35"
|
| 50 |
+
}
|
evidence/probs.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:58cc99e63e97eed6dc99c56e7e334671c8bdbd9353bf96d5af45ea12f5e86eaf
|
| 3 |
+
size 1415873
|
evidence/result.json
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"tag": "opt4_wide_seed29",
|
| 3 |
+
"task": "opt4",
|
| 4 |
+
"scored_classes": [
|
| 5 |
+
"O0",
|
| 6 |
+
"O1",
|
| 7 |
+
"O2",
|
| 8 |
+
"O3"
|
| 9 |
+
],
|
| 10 |
+
"args": {
|
| 11 |
+
"corpus": "data/corpus/binkit_x86_64",
|
| 12 |
+
"task": "opt4",
|
| 13 |
+
"out": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 14 |
+
"splits": "default",
|
| 15 |
+
"init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
|
| 16 |
+
"from_scratch": false,
|
| 17 |
+
"tag": "opt4_wide_seed29",
|
| 18 |
+
"arch": null,
|
| 19 |
+
"extra": null,
|
| 20 |
+
"compiler": null,
|
| 21 |
+
"version": null,
|
| 22 |
+
"test_version": null,
|
| 23 |
+
"train_compiler": null,
|
| 24 |
+
"max_seqs_per_binary": null,
|
| 25 |
+
"stride": 512,
|
| 26 |
+
"val_frac": 0.15,
|
| 27 |
+
"val_seed": 1234,
|
| 28 |
+
"split_mode": "program",
|
| 29 |
+
"seq_bytes": 2048,
|
| 30 |
+
"eval_window_bytes": 512,
|
| 31 |
+
"jitter": 0,
|
| 32 |
+
"pos_extend": "tile",
|
| 33 |
+
"pool": "border",
|
| 34 |
+
"pool_heads": 4,
|
| 35 |
+
"mil_tau": 1.0,
|
| 36 |
+
"taper": 32,
|
| 37 |
+
"dropout": null,
|
| 38 |
+
"classifier_dropout": 0.1,
|
| 39 |
+
"epochs": 6,
|
| 40 |
+
"batch_size": 4,
|
| 41 |
+
"grad_accum": 8,
|
| 42 |
+
"lr": 3e-05,
|
| 43 |
+
"head_lr": 0.0001,
|
| 44 |
+
"llrd": 1.0,
|
| 45 |
+
"weight_decay": 0.01,
|
| 46 |
+
"warmup_ratio": 0.06,
|
| 47 |
+
"clip_grad": 1.0,
|
| 48 |
+
"label_smoothing": 0.0,
|
| 49 |
+
"use_pairs": false,
|
| 50 |
+
"pair_loss": 0.0,
|
| 51 |
+
"pair_ce": 1.0,
|
| 52 |
+
"pair_margin": 1.0,
|
| 53 |
+
"pairs_per_batch": 32,
|
| 54 |
+
"pair_min_size": 32,
|
| 55 |
+
"pair_jitter": 64,
|
| 56 |
+
"workers": 6,
|
| 57 |
+
"seed": 29,
|
| 58 |
+
"resume": true,
|
| 59 |
+
"log_every": 100,
|
| 60 |
+
"val_max_seqs": 40000,
|
| 61 |
+
"tta": "none",
|
| 62 |
+
"tta_views": 4,
|
| 63 |
+
"tta_span": 128,
|
| 64 |
+
"save_probs": true,
|
| 65 |
+
"no_save_weights": false
|
| 66 |
+
},
|
| 67 |
+
"best_epoch": 4,
|
| 68 |
+
"best_val_accuracy": 0.776125,
|
| 69 |
+
"test": {
|
| 70 |
+
"accuracy": 0.8422554826729481,
|
| 71 |
+
"balanced_accuracy": 0.8309510174499452,
|
| 72 |
+
"confusion": [
|
| 73 |
+
[
|
| 74 |
+
37645,
|
| 75 |
+
14,
|
| 76 |
+
34,
|
| 77 |
+
19
|
| 78 |
+
],
|
| 79 |
+
[
|
| 80 |
+
10,
|
| 81 |
+
21157,
|
| 82 |
+
1149,
|
| 83 |
+
636
|
| 84 |
+
],
|
| 85 |
+
[
|
| 86 |
+
1,
|
| 87 |
+
2449,
|
| 88 |
+
17545,
|
| 89 |
+
6190
|
| 90 |
+
],
|
| 91 |
+
[
|
| 92 |
+
1,
|
| 93 |
+
1860,
|
| 94 |
+
5986,
|
| 95 |
+
21625
|
| 96 |
+
]
|
| 97 |
+
],
|
| 98 |
+
"n": 116321
|
| 99 |
+
},
|
| 100 |
+
"test_tta": null,
|
| 101 |
+
"history": [
|
| 102 |
+
{
|
| 103 |
+
"epoch": 0,
|
| 104 |
+
"val_accuracy": 0.714775,
|
| 105 |
+
"val_balanced_accuracy": 0.7049004306882054,
|
| 106 |
+
"val_n": 40000
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"epoch": 1,
|
| 110 |
+
"val_accuracy": 0.75455,
|
| 111 |
+
"val_balanced_accuracy": 0.735723491217099,
|
| 112 |
+
"val_n": 40000
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"epoch": 2,
|
| 116 |
+
"val_accuracy": 0.75315,
|
| 117 |
+
"val_balanced_accuracy": 0.7330662254462548,
|
| 118 |
+
"val_n": 40000
|
| 119 |
+
},
|
| 120 |
+
{
|
| 121 |
+
"epoch": 3,
|
| 122 |
+
"val_accuracy": 0.767475,
|
| 123 |
+
"val_balanced_accuracy": 0.7509352274830212,
|
| 124 |
+
"val_n": 40000
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"epoch": 4,
|
| 128 |
+
"val_accuracy": 0.776125,
|
| 129 |
+
"val_balanced_accuracy": 0.7567212069146406,
|
| 130 |
+
"val_n": 40000
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"epoch": 5,
|
| 134 |
+
"val_accuracy": 0.7684,
|
| 135 |
+
"val_balanced_accuracy": 0.7490872252223905,
|
| 136 |
+
"val_n": 40000
|
| 137 |
+
}
|
| 138 |
+
],
|
| 139 |
+
"n_train_sequences": 264459,
|
| 140 |
+
"wall_clock": "6h54m"
|
| 141 |
+
}
|
head.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"num_labels": 4,
|
| 3 |
+
"pool": "border",
|
| 4 |
+
"pool_heads": 4,
|
| 5 |
+
"mil_tau": 1.0,
|
| 6 |
+
"task": "opt4",
|
| 7 |
+
"tag": "opt4_wide_seed29"
|
| 8 |
+
}
|
inference.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Minimal BinProv inference for a packaged export directory.
|
| 3 |
+
|
| 4 |
+
This file is copied verbatim into every ``export_hf.py`` output and is the
|
| 5 |
+
supported way to run a released checkpoint. It loads the BinProv-native model
|
| 6 |
+
from ``model/`` next to this file, reads an ELF's ``.text`` with BinProv's own
|
| 7 |
+
dependency-free reader (or raw ``.text`` bytes), replicates the training cut into
|
| 8 |
+
``seq_bytes``-byte windows, predicts each window, and soft-votes per class over
|
| 9 |
+
the windows.
|
| 10 |
+
|
| 11 |
+
The end-to-end model is NOT a transformers AutoModel (the head is BinProv's), so
|
| 12 |
+
this script needs the BinProv package installed::
|
| 13 |
+
|
| 14 |
+
pip install -r requirements.txt
|
| 15 |
+
|
| 16 |
+
No remote code is executed anywhere; there is no ``trust_remote_code``.
|
| 17 |
+
|
| 18 |
+
Example::
|
| 19 |
+
|
| 20 |
+
python inference.py --model model --elf /path/to/binary.elf
|
| 21 |
+
python inference.py --model model --text-bytes raw_text.bin
|
| 22 |
+
python inference.py --model model --elf a.elf --json # machine-readable
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import sys
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
import torch
|
| 34 |
+
|
| 35 |
+
EXPORT_ROOT = Path(__file__).resolve().parent
|
| 36 |
+
DEFAULT_MODEL = EXPORT_ROOT / "model"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_model(model_dir: Path):
|
| 40 |
+
from binprov.model import BinProvForProvenance
|
| 41 |
+
|
| 42 |
+
return BinProvForProvenance.load(model_dir)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cut_windows(data: bytes, seq_bytes: int, stride: int, min_bytes: int = 16):
|
| 46 |
+
"""Cut overlapping model inputs at the evaluation stride."""
|
| 47 |
+
out = []
|
| 48 |
+
pos = 0
|
| 49 |
+
n = len(data)
|
| 50 |
+
i = 0
|
| 51 |
+
while pos < n:
|
| 52 |
+
ln = min(seq_bytes, n - pos)
|
| 53 |
+
if ln < min_bytes and pos > 0:
|
| 54 |
+
break
|
| 55 |
+
out.append((np.frombuffer(data[pos : pos + ln], dtype=np.uint8), i))
|
| 56 |
+
i += 1
|
| 57 |
+
if ln < seq_bytes:
|
| 58 |
+
break
|
| 59 |
+
pos += stride
|
| 60 |
+
return out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def predict(model, data: bytes, *, stride: int, batch_size: int, device):
|
| 64 |
+
"""Per-class mean probability over the binary's windows.
|
| 65 |
+
|
| 66 |
+
Returns ``(probabilities, num_windows)`` with probabilities the soft vote of
|
| 67 |
+
every non-overlapping ``seq_bytes`` window of ``data``.
|
| 68 |
+
"""
|
| 69 |
+
from binprov.data import ClassificationCollator
|
| 70 |
+
|
| 71 |
+
seq = model.cfg.seq_bytes
|
| 72 |
+
coll = ClassificationCollator(model.cfg.seq_tokens)
|
| 73 |
+
windows = cut_windows(data, seq, stride)
|
| 74 |
+
if not windows:
|
| 75 |
+
raise ValueError("no usable windows: input is empty or shorter than 16 bytes")
|
| 76 |
+
model.to(device).eval()
|
| 77 |
+
total = np.zeros(model.num_labels, dtype=np.float64)
|
| 78 |
+
with torch.no_grad():
|
| 79 |
+
for start in range(0, len(windows), batch_size):
|
| 80 |
+
items = [(chunk, -1, idx) for chunk, idx in windows[start:start + batch_size]]
|
| 81 |
+
out = coll(items)
|
| 82 |
+
ids = out["input_ids"].to(device)
|
| 83 |
+
attn = out["attention_mask"].to(device)
|
| 84 |
+
types = out["token_type_ids"].to(device)
|
| 85 |
+
logits = model(ids, attn, types)["logits"]
|
| 86 |
+
total += logits.float().softmax(-1).sum(0).cpu().numpy()
|
| 87 |
+
return total / len(windows), len(windows)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def choose_device(requested: str):
|
| 91 |
+
if requested != "auto":
|
| 92 |
+
return torch.device(requested)
|
| 93 |
+
if torch.cuda.is_available():
|
| 94 |
+
return torch.device("cuda")
|
| 95 |
+
if torch.backends.mps.is_available():
|
| 96 |
+
return torch.device("mps")
|
| 97 |
+
return torch.device("cpu")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def main() -> int:
|
| 101 |
+
ap = argparse.ArgumentParser(
|
| 102 |
+
description="BinProv inference on an ELF or raw .text bytes",
|
| 103 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 104 |
+
epilog=__doc__,
|
| 105 |
+
)
|
| 106 |
+
ap.add_argument("--model", default=str(DEFAULT_MODEL),
|
| 107 |
+
help="model dir (model.pt + configs); default: ./model")
|
| 108 |
+
src = ap.add_mutually_exclusive_group(required=True)
|
| 109 |
+
src.add_argument("--elf", default=None, help="ELF binary to classify")
|
| 110 |
+
src.add_argument("--text-bytes", default=None,
|
| 111 |
+
help="file containing raw .text bytes")
|
| 112 |
+
ap.add_argument("--json", action="store_true", help="print JSON only")
|
| 113 |
+
ap.add_argument("--stride", type=int, default=None,
|
| 114 |
+
help="window stride in bytes; default: packaged evaluation stride")
|
| 115 |
+
ap.add_argument("--batch-size", type=int, default=1,
|
| 116 |
+
help="inference batch size; default 1 limits memory use")
|
| 117 |
+
ap.add_argument("--device", default="auto",
|
| 118 |
+
help="torch device (auto, cuda, mps, cpu); default: auto")
|
| 119 |
+
args = ap.parse_args()
|
| 120 |
+
|
| 121 |
+
if args.elf:
|
| 122 |
+
from binprov.elf import parse
|
| 123 |
+
|
| 124 |
+
buf = Path(args.elf).read_bytes()
|
| 125 |
+
text = parse(buf).data
|
| 126 |
+
source = f"ELF {args.elf} (.text, {len(text)} bytes)"
|
| 127 |
+
else:
|
| 128 |
+
text = Path(args.text_bytes).read_bytes()
|
| 129 |
+
source = f"{args.text_bytes} ({len(text)} bytes)"
|
| 130 |
+
|
| 131 |
+
model_dir = Path(args.model)
|
| 132 |
+
model, head = load_model(model_dir)
|
| 133 |
+
labels_file = model_dir / "labels.json"
|
| 134 |
+
labels = json.loads(labels_file.read_text()) if labels_file.is_file() else {}
|
| 135 |
+
classes = labels.get("classes") or []
|
| 136 |
+
inference_cfg_file = model_dir / "inference_config.json"
|
| 137 |
+
inference_cfg = json.loads(inference_cfg_file.read_text()) if inference_cfg_file.is_file() else {}
|
| 138 |
+
stride = args.stride or int(inference_cfg.get("stride", model.cfg.seq_bytes))
|
| 139 |
+
if stride <= 0 or args.batch_size <= 0:
|
| 140 |
+
ap.error("--stride and --batch-size must be positive")
|
| 141 |
+
device = choose_device(args.device)
|
| 142 |
+
prob, n_windows = predict(
|
| 143 |
+
model, text, stride=stride, batch_size=args.batch_size, device=device
|
| 144 |
+
)
|
| 145 |
+
pred = int(prob.argmax())
|
| 146 |
+
result = {
|
| 147 |
+
"source": source,
|
| 148 |
+
"num_windows": n_windows,
|
| 149 |
+
"seq_bytes": model.cfg.seq_bytes,
|
| 150 |
+
"stride": stride,
|
| 151 |
+
"device": str(device),
|
| 152 |
+
"classes": classes,
|
| 153 |
+
"probabilities": [round(float(x), 6) for x in prob],
|
| 154 |
+
"prediction": pred,
|
| 155 |
+
"predicted_label": classes[pred] if pred < len(classes) else str(pred),
|
| 156 |
+
"task": labels.get("task"),
|
| 157 |
+
}
|
| 158 |
+
if args.json:
|
| 159 |
+
print(json.dumps(result, indent=2))
|
| 160 |
+
else:
|
| 161 |
+
print(f"input: {source}")
|
| 162 |
+
print(f"task: {result['task']} classes: {classes}")
|
| 163 |
+
print(f"windows: {n_windows} input: {model.cfg.seq_bytes} B stride: {stride} B")
|
| 164 |
+
print(f"device: {device}")
|
| 165 |
+
for i, p in enumerate(prob):
|
| 166 |
+
name = classes[i] if i < len(classes) else str(i)
|
| 167 |
+
print(f" {name:>6}: {100 * p:.2f}%")
|
| 168 |
+
print(f"=> {result['predicted_label']}")
|
| 169 |
+
return 0
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
raise SystemExit(main())
|
inference_config.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seq_bytes": 2048,
|
| 3 |
+
"stride": 512,
|
| 4 |
+
"min_bytes": 16
|
| 5 |
+
}
|
labels.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "opt4",
|
| 3 |
+
"classes": [
|
| 4 |
+
"O0",
|
| 5 |
+
"O1",
|
| 6 |
+
"O2",
|
| 7 |
+
"O3"
|
| 8 |
+
],
|
| 9 |
+
"label_map": {
|
| 10 |
+
"0": "O0",
|
| 11 |
+
"1": "O1",
|
| 12 |
+
"2": "O2",
|
| 13 |
+
"3": "O3"
|
| 14 |
+
},
|
| 15 |
+
"num_labels": 4
|
| 16 |
+
}
|
metadata.json
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"args.json": {
|
| 3 |
+
"corpus": "data/corpus/binkit_x86_64",
|
| 4 |
+
"task": "opt4",
|
| 5 |
+
"out": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 6 |
+
"splits": "default",
|
| 7 |
+
"init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
|
| 8 |
+
"from_scratch": false,
|
| 9 |
+
"tag": "opt4_wide_seed29",
|
| 10 |
+
"arch": null,
|
| 11 |
+
"extra": null,
|
| 12 |
+
"compiler": null,
|
| 13 |
+
"version": null,
|
| 14 |
+
"test_version": null,
|
| 15 |
+
"train_compiler": null,
|
| 16 |
+
"max_seqs_per_binary": null,
|
| 17 |
+
"stride": 512,
|
| 18 |
+
"val_frac": 0.15,
|
| 19 |
+
"val_seed": 1234,
|
| 20 |
+
"split_mode": "program",
|
| 21 |
+
"seq_bytes": 2048,
|
| 22 |
+
"eval_window_bytes": 512,
|
| 23 |
+
"jitter": 0,
|
| 24 |
+
"pos_extend": "tile",
|
| 25 |
+
"pool": "border",
|
| 26 |
+
"pool_heads": 4,
|
| 27 |
+
"mil_tau": 1.0,
|
| 28 |
+
"taper": 32,
|
| 29 |
+
"dropout": null,
|
| 30 |
+
"classifier_dropout": 0.1,
|
| 31 |
+
"epochs": 6,
|
| 32 |
+
"batch_size": 4,
|
| 33 |
+
"grad_accum": 8,
|
| 34 |
+
"lr": 3e-05,
|
| 35 |
+
"head_lr": 0.0001,
|
| 36 |
+
"llrd": 1.0,
|
| 37 |
+
"weight_decay": 0.01,
|
| 38 |
+
"warmup_ratio": 0.06,
|
| 39 |
+
"clip_grad": 1.0,
|
| 40 |
+
"label_smoothing": 0.0,
|
| 41 |
+
"use_pairs": false,
|
| 42 |
+
"pair_loss": 0.0,
|
| 43 |
+
"pair_ce": 1.0,
|
| 44 |
+
"pair_margin": 1.0,
|
| 45 |
+
"pairs_per_batch": 32,
|
| 46 |
+
"pair_min_size": 32,
|
| 47 |
+
"pair_jitter": 64,
|
| 48 |
+
"workers": 6,
|
| 49 |
+
"seed": 29,
|
| 50 |
+
"resume": true,
|
| 51 |
+
"log_every": 100,
|
| 52 |
+
"val_max_seqs": 40000,
|
| 53 |
+
"tta": "none",
|
| 54 |
+
"tta_views": 4,
|
| 55 |
+
"tta_span": 128,
|
| 56 |
+
"save_probs": true,
|
| 57 |
+
"no_save_weights": false
|
| 58 |
+
},
|
| 59 |
+
"result.json": {
|
| 60 |
+
"tag": "opt4_wide_seed29",
|
| 61 |
+
"task": "opt4",
|
| 62 |
+
"scored_classes": [
|
| 63 |
+
"O0",
|
| 64 |
+
"O1",
|
| 65 |
+
"O2",
|
| 66 |
+
"O3"
|
| 67 |
+
],
|
| 68 |
+
"args": {
|
| 69 |
+
"corpus": "data/corpus/binkit_x86_64",
|
| 70 |
+
"task": "opt4",
|
| 71 |
+
"out": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 72 |
+
"splits": "default",
|
| 73 |
+
"init_from": "results/hopper_release/opt4_wide_seed29/mlm2048",
|
| 74 |
+
"from_scratch": false,
|
| 75 |
+
"tag": "opt4_wide_seed29",
|
| 76 |
+
"arch": null,
|
| 77 |
+
"extra": null,
|
| 78 |
+
"compiler": null,
|
| 79 |
+
"version": null,
|
| 80 |
+
"test_version": null,
|
| 81 |
+
"train_compiler": null,
|
| 82 |
+
"max_seqs_per_binary": null,
|
| 83 |
+
"stride": 512,
|
| 84 |
+
"val_frac": 0.15,
|
| 85 |
+
"val_seed": 1234,
|
| 86 |
+
"split_mode": "program",
|
| 87 |
+
"seq_bytes": 2048,
|
| 88 |
+
"eval_window_bytes": 512,
|
| 89 |
+
"jitter": 0,
|
| 90 |
+
"pos_extend": "tile",
|
| 91 |
+
"pool": "border",
|
| 92 |
+
"pool_heads": 4,
|
| 93 |
+
"mil_tau": 1.0,
|
| 94 |
+
"taper": 32,
|
| 95 |
+
"dropout": null,
|
| 96 |
+
"classifier_dropout": 0.1,
|
| 97 |
+
"epochs": 6,
|
| 98 |
+
"batch_size": 4,
|
| 99 |
+
"grad_accum": 8,
|
| 100 |
+
"lr": 3e-05,
|
| 101 |
+
"head_lr": 0.0001,
|
| 102 |
+
"llrd": 1.0,
|
| 103 |
+
"weight_decay": 0.01,
|
| 104 |
+
"warmup_ratio": 0.06,
|
| 105 |
+
"clip_grad": 1.0,
|
| 106 |
+
"label_smoothing": 0.0,
|
| 107 |
+
"use_pairs": false,
|
| 108 |
+
"pair_loss": 0.0,
|
| 109 |
+
"pair_ce": 1.0,
|
| 110 |
+
"pair_margin": 1.0,
|
| 111 |
+
"pairs_per_batch": 32,
|
| 112 |
+
"pair_min_size": 32,
|
| 113 |
+
"pair_jitter": 64,
|
| 114 |
+
"workers": 6,
|
| 115 |
+
"seed": 29,
|
| 116 |
+
"resume": true,
|
| 117 |
+
"log_every": 100,
|
| 118 |
+
"val_max_seqs": 40000,
|
| 119 |
+
"tta": "none",
|
| 120 |
+
"tta_views": 4,
|
| 121 |
+
"tta_span": 128,
|
| 122 |
+
"save_probs": true,
|
| 123 |
+
"no_save_weights": false
|
| 124 |
+
},
|
| 125 |
+
"best_epoch": 4,
|
| 126 |
+
"best_val_accuracy": 0.776125,
|
| 127 |
+
"test": {
|
| 128 |
+
"accuracy": 0.8422554826729481,
|
| 129 |
+
"balanced_accuracy": 0.8309510174499452,
|
| 130 |
+
"confusion": [
|
| 131 |
+
[
|
| 132 |
+
37645,
|
| 133 |
+
14,
|
| 134 |
+
34,
|
| 135 |
+
19
|
| 136 |
+
],
|
| 137 |
+
[
|
| 138 |
+
10,
|
| 139 |
+
21157,
|
| 140 |
+
1149,
|
| 141 |
+
636
|
| 142 |
+
],
|
| 143 |
+
[
|
| 144 |
+
1,
|
| 145 |
+
2449,
|
| 146 |
+
17545,
|
| 147 |
+
6190
|
| 148 |
+
],
|
| 149 |
+
[
|
| 150 |
+
1,
|
| 151 |
+
1860,
|
| 152 |
+
5986,
|
| 153 |
+
21625
|
| 154 |
+
]
|
| 155 |
+
],
|
| 156 |
+
"n": 116321
|
| 157 |
+
},
|
| 158 |
+
"test_tta": null,
|
| 159 |
+
"history": [
|
| 160 |
+
{
|
| 161 |
+
"epoch": 0,
|
| 162 |
+
"val_accuracy": 0.714775,
|
| 163 |
+
"val_balanced_accuracy": 0.7049004306882054,
|
| 164 |
+
"val_n": 40000
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"epoch": 1,
|
| 168 |
+
"val_accuracy": 0.75455,
|
| 169 |
+
"val_balanced_accuracy": 0.735723491217099,
|
| 170 |
+
"val_n": 40000
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"epoch": 2,
|
| 174 |
+
"val_accuracy": 0.75315,
|
| 175 |
+
"val_balanced_accuracy": 0.7330662254462548,
|
| 176 |
+
"val_n": 40000
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"epoch": 3,
|
| 180 |
+
"val_accuracy": 0.767475,
|
| 181 |
+
"val_balanced_accuracy": 0.7509352274830212,
|
| 182 |
+
"val_n": 40000
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"epoch": 4,
|
| 186 |
+
"val_accuracy": 0.776125,
|
| 187 |
+
"val_balanced_accuracy": 0.7567212069146406,
|
| 188 |
+
"val_n": 40000
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"epoch": 5,
|
| 192 |
+
"val_accuracy": 0.7684,
|
| 193 |
+
"val_balanced_accuracy": 0.7490872252223905,
|
| 194 |
+
"val_n": 40000
|
| 195 |
+
}
|
| 196 |
+
],
|
| 197 |
+
"n_train_sequences": 264459,
|
| 198 |
+
"wall_clock": "6h54m"
|
| 199 |
+
},
|
| 200 |
+
"head.json": {
|
| 201 |
+
"num_labels": 4,
|
| 202 |
+
"pool": "border",
|
| 203 |
+
"pool_heads": 4,
|
| 204 |
+
"mil_tau": 1.0,
|
| 205 |
+
"task": "opt4",
|
| 206 |
+
"tag": "opt4_wide_seed29"
|
| 207 |
+
},
|
| 208 |
+
"binprov_config.json": {
|
| 209 |
+
"seq_bytes": 2048,
|
| 210 |
+
"hidden_size": 768,
|
| 211 |
+
"num_hidden_layers": 12,
|
| 212 |
+
"num_attention_heads": 12,
|
| 213 |
+
"intermediate_size": 3072,
|
| 214 |
+
"max_segments": 2,
|
| 215 |
+
"hidden_dropout_prob": 0.1,
|
| 216 |
+
"attention_probs_dropout_prob": 0.1
|
| 217 |
+
},
|
| 218 |
+
"source_checkpoint": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 219 |
+
"labels": {
|
| 220 |
+
"task": "opt4",
|
| 221 |
+
"classes": [
|
| 222 |
+
"O0",
|
| 223 |
+
"O1",
|
| 224 |
+
"O2",
|
| 225 |
+
"O3"
|
| 226 |
+
],
|
| 227 |
+
"label_map": {
|
| 228 |
+
"0": "O0",
|
| 229 |
+
"1": "O1",
|
| 230 |
+
"2": "O2",
|
| 231 |
+
"3": "O3"
|
| 232 |
+
},
|
| 233 |
+
"num_labels": 4
|
| 234 |
+
},
|
| 235 |
+
"environment": {
|
| 236 |
+
"python": "3.12.1",
|
| 237 |
+
"platform": "Linux-4.18.0-553.137.1.el8_10.x86_64-x86_64-with-glibc2.28",
|
| 238 |
+
"sys.platform": "linux",
|
| 239 |
+
"torch": "2.8.0+cu128",
|
| 240 |
+
"transformers": "4.55.4",
|
| 241 |
+
"numpy": "2.3.3"
|
| 242 |
+
},
|
| 243 |
+
"summary": {
|
| 244 |
+
"model_name": "binprov",
|
| 245 |
+
"exported_from": "results/hopper_release/opt4_wide_seed29/opt4",
|
| 246 |
+
"created_at": "2026-09-06T07:48:37+00:00",
|
| 247 |
+
"files": [
|
| 248 |
+
"LICENSE",
|
| 249 |
+
"MODEL_CARD.md",
|
| 250 |
+
"README.md",
|
| 251 |
+
"binprov/__init__.py",
|
| 252 |
+
"binprov/corpus.py",
|
| 253 |
+
"binprov/data.py",
|
| 254 |
+
"binprov/discover.py",
|
| 255 |
+
"binprov/elf.py",
|
| 256 |
+
"binprov/metrics.py",
|
| 257 |
+
"binprov/model.py",
|
| 258 |
+
"binprov/provenance.py",
|
| 259 |
+
"binprov/vocab.py",
|
| 260 |
+
"binprov/vote.py",
|
| 261 |
+
"binprov_config.json",
|
| 262 |
+
"environment.json",
|
| 263 |
+
"evidence/args.json",
|
| 264 |
+
"evidence/evaluation.json",
|
| 265 |
+
"evidence/probs.npz",
|
| 266 |
+
"evidence/result.json",
|
| 267 |
+
"head.json",
|
| 268 |
+
"inference.py",
|
| 269 |
+
"inference_config.json",
|
| 270 |
+
"labels.json",
|
| 271 |
+
"model/binprov_config.json",
|
| 272 |
+
"model/head.json",
|
| 273 |
+
"model/inference_config.json",
|
| 274 |
+
"model/labels.json",
|
| 275 |
+
"model/model.pt",
|
| 276 |
+
"requirements.txt",
|
| 277 |
+
"metadata.json"
|
| 278 |
+
],
|
| 279 |
+
"sequence_accuracy": 0.842255,
|
| 280 |
+
"binary_accuracy": 0.93883,
|
| 281 |
+
"transformers_compatible": false,
|
| 282 |
+
"note": "BinProv-native PyTorch checkpoint; use the bundled inference.py runtime.",
|
| 283 |
+
"verification": {
|
| 284 |
+
"verified": true,
|
| 285 |
+
"device": "cpu",
|
| 286 |
+
"windows": 4,
|
| 287 |
+
"mean_prob": [
|
| 288 |
+
0.5000002359981011,
|
| 289 |
+
0.4999849506011037,
|
| 290 |
+
6.91615203089746e-06,
|
| 291 |
+
7.897442639431818e-06
|
| 292 |
+
],
|
| 293 |
+
"prediction": 0
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
}
|
model/binprov_config.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seq_bytes": 2048,
|
| 3 |
+
"hidden_size": 768,
|
| 4 |
+
"num_hidden_layers": 12,
|
| 5 |
+
"num_attention_heads": 12,
|
| 6 |
+
"intermediate_size": 3072,
|
| 7 |
+
"max_segments": 2,
|
| 8 |
+
"hidden_dropout_prob": 0.1,
|
| 9 |
+
"attention_probs_dropout_prob": 0.1
|
| 10 |
+
}
|
model/head.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"num_labels": 4,
|
| 3 |
+
"pool": "border",
|
| 4 |
+
"pool_heads": 4,
|
| 5 |
+
"mil_tau": 1.0,
|
| 6 |
+
"task": "opt4",
|
| 7 |
+
"tag": "opt4_wide_seed29"
|
| 8 |
+
}
|
model/inference_config.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"seq_bytes": 2048,
|
| 3 |
+
"stride": 512,
|
| 4 |
+
"min_bytes": 16
|
| 5 |
+
}
|
model/labels.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"task": "opt4",
|
| 3 |
+
"classes": [
|
| 4 |
+
"O0",
|
| 5 |
+
"O1",
|
| 6 |
+
"O2",
|
| 7 |
+
"O3"
|
| 8 |
+
],
|
| 9 |
+
"label_map": {
|
| 10 |
+
"0": "O0",
|
| 11 |
+
"1": "O1",
|
| 12 |
+
"2": "O2",
|
| 13 |
+
"3": "O3"
|
| 14 |
+
},
|
| 15 |
+
"num_labels": 4
|
| 16 |
+
}
|
model/model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:93efdc354956bd8e3f5abc254b97538947b856e185c737f891f00ddb95065247
|
| 3 |
+
size 349795459
|
requirements.txt
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# BinProv runtime dependencies.
|
| 2 |
+
#
|
| 3 |
+
# Deliberately short. The original paper built the network with fairseq; this
|
| 4 |
+
# re-implementation uses HuggingFace transformers instead, which is compatible
|
| 5 |
+
# with current PyTorch (fairseq pins old torch versions and no longer builds
|
| 6 |
+
# cleanly). The architecture is the same: a 12-layer, 768-hidden RoBERTa/BERT
|
| 7 |
+
# encoder over a 261-token byte vocabulary.
|
| 8 |
+
#
|
| 9 |
+
# ELF parsing is done in-tree (binprov/elf.py) so corpus building needs no
|
| 10 |
+
# native dependencies, and the metrics are hand-rolled so evaluation runs
|
| 11 |
+
# anywhere the model runs.
|
| 12 |
+
|
| 13 |
+
torch>=2.0
|
| 14 |
+
transformers>=4.35
|
| 15 |
+
numpy>=1.23
|
| 16 |
+
|
| 17 |
+
# Optional, only for scripts/fetch_binkit.sh:
|
| 18 |
+
# gdown>=4.7 BinKit is hosted on Google Drive
|
| 19 |
+
# py7zr>=0.20 fallback extractor; BinKit ships .7z archives. Preferred is
|
| 20 |
+
# bsdtar/libarchive, which is usually already installed and
|
| 21 |
+
# reads .7z with the same wildcard syntax as tar.
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Versions the results in docs/ were produced with. The bounds above are what
|
| 25 |
+
# the code needs; these are what it ran on. Accuracy numbers on this task move
|
| 26 |
+
# by 1-2 points between seeds, so a different torch or transformers version is
|
| 27 |
+
# a plausible source of a similar-sized shift -- pin these if you are comparing
|
| 28 |
+
# against the tables rather than just running the pipeline.
|
| 29 |
+
#
|
| 30 |
+
# python 3.12.11
|
| 31 |
+
# torch 2.8.0+cu128
|
| 32 |
+
# transformers 4.55.4
|
| 33 |
+
# numpy 2.3.3
|
| 34 |
+
#
|
| 35 |
+
# GPU: one NVIDIA H200 (141 GB). bf16 autocast throughout. The 2048-byte arms
|
| 36 |
+
# need ~33 GB; the 512-byte arms ~26 GB at batch 128.
|