binstat 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
binstat/VERSION.txt ADDED
@@ -0,0 +1 @@
1
+ 1.0.0
binstat/__info__.py ADDED
@@ -0,0 +1,19 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """BinStat package information.
3
+
4
+ """
5
+ import os
6
+ from datetime import datetime
7
+
8
+ __y = str(datetime.now().year)
9
+ __s = "2026"
10
+
11
+ __author__ = "Alexandre D'Hondt"
12
+ __copyright__ = "© {} Alexandre D'Hondt".format([__y, __s + "-" + __y][__y != __s])
13
+ __license__ = "GPLv3 (https://www.gnu.org/licenses/gpl-3.0.fr.html)"
14
+ __reference__ = "https://doi.org/10.5769/J201101003"
15
+ __source__ = "https://github.com/dhondta/binstat"
16
+
17
+ with open(os.path.join(os.path.dirname(__file__), "VERSION.txt")) as f:
18
+ __version__ = f.read().strip()
19
+
binstat/__init__.py ADDED
@@ -0,0 +1,110 @@
1
+ # -*- coding: UTF-8 -*-
2
+ import numpy as np
3
+ import os
4
+
5
+ from .features import *
6
+ from .features import __all__ as _features
7
+ from .model import *
8
+ from .model import __all__ as _model
9
+
10
+
11
+ __all__ = ["BinStat", "DEFAULT_MODEL", "THRESHOLD"] + _features + _model
12
+
13
+
14
+ DEFAULT_MODEL = os.path.join(os.path.dirname(__file__), "model.json")
15
+ THRESHOLD = .5
16
+
17
+
18
+ def _files(*paths):
19
+ """ Yield the files from the input paths, walking folders recursively (sorted, for reproducibility). """
20
+ for path in paths:
21
+ path = str(path)
22
+ if os.path.isdir(path):
23
+ for root, dirs, files in os.walk(path):
24
+ dirs.sort()
25
+ for f in sorted(files):
26
+ yield os.path.join(root, f)
27
+ else:
28
+ yield path
29
+
30
+
31
+ class BinStat:
32
+ def __init__(self, model=None, threshold=THRESHOLD, logger=None, **kwargs):
33
+ """ Configure the detector with a model (Model instance or path to a JSON model ; None for the default one)
34
+ and the minimum ratio of packed blocks for an executable to be packed. """
35
+ if not 0. <= threshold <= 1.:
36
+ raise ValueError("threshold must be between 0 and 1")
37
+ self.model = model if isinstance(model, Model) else Model.load(model or DEFAULT_MODEL)
38
+ self.threshold = threshold
39
+ self.logger = logger
40
+
41
+ def blocks(self, executable):
42
+ """ Classify each block of the input executable (1 = packed, 0 = not packed). """
43
+ return self.model.predict(file_features(str(executable), **self.model.features_kwargs))
44
+
45
+ def detect(self, executable):
46
+ """ Analyze the input executable file: it is packed when the ratio of its packed blocks reaches the
47
+ threshold. """
48
+ r = self.ratio(executable)
49
+ return r if r == "?" else bool(r >= self.threshold)
50
+
51
+ def ratio(self, executable):
52
+ """ Compute the ratio of packed blocks of the input executable. """
53
+ try:
54
+ blocks = self.blocks(executable)
55
+ except OSError as e:
56
+ self._log(f"{executable}: {e.strerror or e}", OSError)
57
+ return "?"
58
+ if len(blocks) == 0:
59
+ self._log(f"{executable} has no block to analyze", ValueError)
60
+ return "?"
61
+ r = float(blocks.mean())
62
+ if self.logger:
63
+ self.logger.debug(f"{blocks.sum()}/{len(blocks)} blocks recognized as packed ({r:.2%}, threshold: "
64
+ f"{self.threshold:.2%})")
65
+ return r
66
+
67
+ def _log(self, msg, exception, level="error"):
68
+ """ Helper for either logging a message if a logger is defined or raising an exception. """
69
+ if self.logger:
70
+ getattr(self.logger, level)(msg)
71
+ else:
72
+ raise exception(msg)
73
+
74
+ @classmethod
75
+ def train(cls, packed, not_packed, ngram=1, block_size=BLOCK_SIZE, minkowski_order=MINKOWSKI_ORDER, boost=30,
76
+ balanced=False, max_blocks=None, max_leaf_nodes=None, min_samples_leaf=1, seed=None, threshold=THRESHOLD,
77
+ logger=None, **kwargs):
78
+ """ Train a detector on packed and not packed executables, labelling all the blocks of an executable with its
79
+ own label.
80
+
81
+ :param packed: list of packed executables or folders holding them
82
+ :param not_packed: list of not packed executables or folders holding them
83
+ :param balanced: weight the blocks so that both classes weigh the same (packed blocks usually outnumber
84
+ the others)
85
+ :param max_blocks: maximum number of blocks taken (at random) from each executable (None for all)
86
+ :return: BinStat instance (its model can be saved with .model.save(path))
87
+ """
88
+ fkw = {'ngram': ngram, 'block_size': block_size, 'minkowski_order': minkowski_order}
89
+ rng, X, y = np.random.default_rng(seed), [], []
90
+ for label, paths in [(1, packed), (0, not_packed)]:
91
+ for path in _files(*paths):
92
+ try:
93
+ F = file_features(path, **fkw)
94
+ except OSError as e:
95
+ if logger:
96
+ logger.warning(f"{path}: {e.strerror or e}")
97
+ continue
98
+ if max_blocks is not None and len(F) > max_blocks:
99
+ F = F[np.sort(rng.choice(len(F), max_blocks, replace=False))]
100
+ X.append(F)
101
+ y.append(np.full(len(F), label))
102
+ if logger:
103
+ logger.debug(f"{path}: {len(F)} blocks ({['not packed', 'packed'][label]})")
104
+ X, y = np.vstack(X or [np.zeros((0, len(FEATURES)))]), np.concatenate(y or [np.zeros(0, dtype="int64")])
105
+ if logger:
106
+ logger.info(f"training on {len(y)} blocks ({int(y.sum())} packed, {int(len(y) - y.sum())} not packed)")
107
+ w = (len(y) / (2 * np.bincount(y, minlength=2)))[y] if balanced and len(set(y)) == 2 else None
108
+ m = train(X, y, boost=boost, max_leaf_nodes=max_leaf_nodes, min_samples_leaf=min_samples_leaf, seed=seed,
109
+ sample_weight=w, balanced=balanced, **fkw)
110
+ return cls(m, threshold, logger)
binstat/__main__.py ADDED
@@ -0,0 +1,134 @@
1
+ # -*- coding: UTF-8 -*-
2
+ import hashlib
3
+ import logging
4
+ from argparse import ArgumentParser, RawTextHelpFormatter
5
+ from ast import literal_eval
6
+ from os.path import exists
7
+ from time import perf_counter
8
+
9
+ from .__info__ import __author__, __copyright__, __license__, __reference__, __source__, __version__
10
+ from .__init__ import BinStat, BLOCK_SIZE, MINKOWSKI_ORDER, NGRAMS, THRESHOLD, file_features
11
+
12
+
13
+ def valid_file(path):
14
+ if not exists(path):
15
+ raise ValueError("input file does not exist")
16
+ return path
17
+
18
+
19
+ class Positive:
20
+ def __init__(self, *types, maximum=None, zero=True):
21
+ self._types, self._max, self._zero = types, maximum, zero
22
+
23
+ def __call__(self, string):
24
+ try:
25
+ n = literal_eval(string)
26
+ except (SyntaxError, ValueError):
27
+ raise ValueError(string)
28
+ if not isinstance(n, self._types) or n < 0. or not self._zero and n == 0 or \
29
+ self._max is not None and n > self._max:
30
+ raise ValueError(string)
31
+ return self._types[0](n)
32
+
33
+ def __repr__(self):
34
+ return "positive %s" % "|".join(map(lambda x: x.__name__, self._types))
35
+
36
+
37
+ def _features_args(parser):
38
+ parser.add_argument("--block-size", type=Positive(int, zero=False), default=BLOCK_SIZE,
39
+ help="size of the blocks, in bytes (default: %d)" % BLOCK_SIZE)
40
+ parser.add_argument("--minkowski-order", type=Positive(float, int, zero=False), default=MINKOWSKI_ORDER,
41
+ help="order (lambda) of Minkowski's distance (default: %d)" % MINKOWSKI_ORDER)
42
+ parser.add_argument("--ngram", type=int, choices=NGRAMS, default=1, help="n-gram size (default: 1)")
43
+
44
+
45
+ def _detect(args):
46
+ r = BinStat(args.model, args.threshold, args.logger)
47
+ return r.ratio(args.path) if args.ratio else r.detect(args.path)
48
+
49
+
50
+ def _features(args):
51
+ with open(args.path, "rb") as f:
52
+ md5 = hashlib.md5(f.read()).hexdigest()
53
+ kw = {k: getattr(args, k) for k in ["block_size", "minkowski_order", "ngram"]}
54
+ # same format as the C5.0 data file of the paper: the measures, the label, the block ID and the MD5 of the file
55
+ return "\n".join(",".join(["%f" % v for v in row] + [args.label, f"BLOCK{i}", md5])
56
+ for i, row in enumerate(file_features(args.path, **kw)))
57
+
58
+
59
+ def _train(args):
60
+ kw = {k: getattr(args, k) for k in ["balanced", "block_size", "boost", "max_blocks", "max_leaf_nodes",
61
+ "minkowski_order", "ngram", "seed"]}
62
+ BinStat.train(args.packed, args.not_packed, logger=args.logger, **kw).model.save(args.output)
63
+ args.logger.info(f"model saved to {args.output}")
64
+
65
+
66
+ def main():
67
+ """ Tool's main function """
68
+ descr = "BinStat {}\n\nAuthor : {}\nCopyright: {}\nLicense : {}\nReference: {}\nSource : {}\n" \
69
+ "\nThis tool computes statistics on the n-grams of each block of an executable and classifies the blocks" \
70
+ " with boosted decision trees to determine if the executable is packed or not.\n\n"
71
+ descr = descr.format(__version__, __author__, __copyright__, __license__, __reference__, __source__)
72
+ examples = "usage examples:\n- " + "\n- ".join([
73
+ "binstat detect program.exe",
74
+ "binstat detect program.exe --threshold 0.6 --ratio",
75
+ "binstat features program.exe --label yes",
76
+ "binstat train -p packed/ -n not-packed/ -o model.json --max-blocks 100",
77
+ ])
78
+ parser = ArgumentParser(description=descr, epilog=examples, formatter_class=RawTextHelpFormatter)
79
+ # extra arguments are accepted after each command
80
+ common = ArgumentParser(add_help=False)
81
+ extra = common.add_argument_group("extra arguments")
82
+ extra.add_argument("-b", "--benchmark", action="store_true",
83
+ help="enable benchmarking, output in seconds (default: False)")
84
+ extra.add_argument("-v", "--verbose", action="store_true", help="display debug information (default: False)")
85
+ sparsers = parser.add_subparsers(dest="command", required=True, help="command to be executed")
86
+ cmd = lambda name, help: sparsers.add_parser(name, help=help, description=help, parents=[common])
87
+ detect = cmd("detect", "detect whether an executable is packed")
88
+ detect.add_argument("path", type=valid_file, help="path to the executable file")
89
+ detect.add_argument("-m", "--model", help="path to a JSON model (default: the model shipped with BinStat)")
90
+ detect.add_argument("-r", "--ratio", action="store_true",
91
+ help="output the ratio of packed blocks instead of the decision")
92
+ detect.add_argument("-t", "--threshold", type=Positive(float, int, maximum=1), default=THRESHOLD,
93
+ help="minimum ratio of packed blocks for the executable to be packed (default: %.1f)" %
94
+ THRESHOLD)
95
+ features = cmd("features", "compute the measures of each block (in C5.0 data format)")
96
+ features.add_argument("path", type=valid_file, help="path to the executable file")
97
+ _features_args(features)
98
+ features.add_argument("--label", choices=["yes", "no", "?"], default="?",
99
+ help="label of the blocks, 'yes' meaning packed (default: ?, i.e. unknown)")
100
+ train = cmd("train", "train a model on packed and not packed executables")
101
+ train.add_argument("-n", "--not-packed", action="extend", nargs="+", required=True,
102
+ help="not packed executables or folders holding them")
103
+ train.add_argument("-o", "--output", required=True, help="path to the output JSON model")
104
+ train.add_argument("-p", "--packed", action="extend", nargs="+", required=True,
105
+ help="packed executables or folders holding them")
106
+ _features_args(train)
107
+ train.add_argument("--balanced", action="store_true",
108
+ help="weight the blocks so that packed and not packed blocks weigh the same (default: False)")
109
+ train.add_argument("--boost", type=Positive(int, zero=False), default=30,
110
+ help="number of boosting trials (default: 30)")
111
+ train.add_argument("--max-blocks", type=Positive(int, zero=False),
112
+ help="maximum number of blocks taken at random from each executable (default: all)")
113
+ train.add_argument("--max-leaf-nodes", type=Positive(int, zero=False),
114
+ help="maximum number of leaves of each tree (default: unlimited)")
115
+ train.add_argument("--seed", type=int, help="random seed, for reproducible models")
116
+ args = parser.parse_args()
117
+ logging.basicConfig()
118
+ args.logger = logging.getLogger("binstat")
119
+ args.logger.setLevel([logging.INFO, logging.DEBUG][args.verbose])
120
+ code = 0
121
+ # execute the tool
122
+ if args.benchmark:
123
+ t1 = perf_counter()
124
+ try:
125
+ r = {'detect': _detect, 'features': _features, 'train': _train}[args.command](args)
126
+ dt = str(perf_counter() - t1) if args.benchmark else ""
127
+ if r is not None:
128
+ print(str(r))
129
+ if dt != "":
130
+ print(dt)
131
+ except Exception as e:
132
+ args.logger.exception(e)
133
+ code = 1
134
+ return code
binstat/features.py ADDED
@@ -0,0 +1,151 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Statistical module of BinStat: the thirteen block measures of Park et al.
3
+
4
+ Each block of the input is described by the frequencies X_f of its n-gram values f (0 <= f < M, with M = 256**n). Except
5
+ Simpson's index and the entropy, the measures compare the frequencies of consecutive n-gram values, X_f and X_{f+1},
6
+ for 0 <= f < M - 1. As a block holds at most 1024 distinct n-grams, only the non-zero frequencies are computed, so that
7
+ bigrams to four-grams cost the same as unigrams.
8
+
9
+ The divergences (Kullback-Leibler, Jensen-Shannon and Itakura-Saito) use decimal logarithms: with these, the measures
10
+ match the sample line given in the paper for a UPX-packed block, while the entropy is in bits as stated.
11
+ """
12
+ import numpy as np
13
+
14
+
15
+ __all__ = ["BLOCK_SIZE", "FEATURES", "MINKOWSKI_ORDER", "NGRAMS", "block_features", "file_features"]
16
+
17
+
18
+ BLOCK_SIZE = 1024
19
+ FEATURES = ["simpson", "canberra", "minkowski", "manhattan", "chebyshev", "bray_curtis", "angular_separation",
20
+ "correlation", "entropy", "kullback_leibler", "jensen_shannon", "itakura_saito", "total_variation"]
21
+ MINKOWSKI_ORDER = 3
22
+ NGRAMS = (1, 2, 3, 4)
23
+
24
+
25
+ def _div(a, b):
26
+ """ Element-wise a / b, with 0 where b is 0. """
27
+ out = np.zeros_like(a, dtype="float64")
28
+ np.divide(a, b, out=out, where=b != 0)
29
+ return out
30
+
31
+
32
+ def _histograms(data, ngram, block_size):
33
+ """ Count the n-grams of each block.
34
+
35
+ N-grams do not cross block boundaries ; the last block may be shorter than block_size, and is ignored when it is
36
+ shorter than the n-gram.
37
+
38
+ :return: (number of n-grams per block, sorted keys block * M + value of the n-grams, their counts)
39
+ """
40
+ a = np.frombuffer(data, dtype="uint8")
41
+ n_blocks = (len(a) - ngram) // block_size + 1 if len(a) >= ngram else 0
42
+ if n_blocks == 0:
43
+ return np.zeros(0, dtype="int64"), np.zeros(0, dtype="int64"), np.zeros(0, dtype="int64")
44
+ # value of the n-gram starting at each position (big-endian, so that f and f+1 differ in their last byte)
45
+ start = np.arange(len(a) - ngram + 1, dtype="int64")
46
+ values = np.zeros(len(start), dtype="int64")
47
+ for j in range(ngram):
48
+ values = (values << 8) | a[j:len(a) - ngram + 1 + j]
49
+ # keep the n-grams lying entirely inside their block
50
+ keep = start % block_size <= block_size - ngram
51
+ block = start[keep] // block_size
52
+ counts_per_block = np.bincount(block, minlength=n_blocks)
53
+ if ngram == 1:
54
+ # fast path: a dense histogram of 256 values per block
55
+ dense = np.bincount(block * 256 + values[keep], minlength=n_blocks * 256)
56
+ keys = np.flatnonzero(dense)
57
+ return counts_per_block, keys, dense[keys]
58
+ keys, counts = np.unique(block * 256 ** ngram + values[keep], return_counts=True)
59
+ return counts_per_block, keys, counts
60
+
61
+
62
+ def _measures(n_per_block, keys, counts, ngram, minkowski_order=MINKOWSKI_ORDER):
63
+ """ Compute the thirteen measures of each block from its sparse n-gram histogram. """
64
+ m, nb = 256 ** ngram, len(n_per_block)
65
+ bsum = lambda block, w: np.bincount(block, weights=w, minlength=nb)
66
+ N = n_per_block.astype("float64")
67
+ blk, val = keys // m, keys % m
68
+ x = counts / N[blk]
69
+ # pairs (X_f, X_{f+1}) where at least one of both is non-zero ; all the other pairs are (0, 0)
70
+ f = np.union1d(keys[val <= m - 2], keys[val >= 1] - 1)
71
+ pb = f // m
72
+ i = np.searchsorted(keys, f)
73
+ i[i == len(keys)] = 0
74
+ xa = np.where(keys[i] == f, x[i], 0.)
75
+ j = np.searchsorted(keys, f + 1)
76
+ j[j == len(keys)] = 0
77
+ xb = np.where(keys[j] == f + 1, x[j], 0.)
78
+ d = np.abs(xa - xb)
79
+ both = (xa > 0) & (xb > 0)
80
+ r = np.where(both, xa / np.where(both, xb, 1.), 1.)
81
+ mix = (xa + xb) / 2
82
+ # correlation coefficient, with the mean frequency 1/M over the M values ; each (0, 0) pair contributes 1/M^2
83
+ # to each sum
84
+ mean, zero_pairs = 1. / m, (m - 1) - bsum(pb, None)
85
+ cov = bsum(pb, (xa - mean) * (xb - mean)) + zero_pairs * mean ** 2
86
+ va = bsum(pb, (xa - mean) ** 2) + zero_pairs * mean ** 2
87
+ vb = bsum(pb, (xb - mean) ** 2) + zero_pairs * mean ** 2
88
+ chebyshev = np.zeros(nb)
89
+ np.maximum.at(chebyshev, pb, d)
90
+ manhattan = bsum(pb, d)
91
+ return np.column_stack([
92
+ # 1) Simpson's index
93
+ _div(bsum(blk, counts * (counts - 1.)), N * (N - 1)),
94
+ # 2) Canberra's distance (pairs (0, 0) are skipped)
95
+ bsum(pb, _div(d, xa + xb)),
96
+ # 3) Minkowski's distance of order lambda
97
+ bsum(pb, d ** minkowski_order) ** (1. / minkowski_order),
98
+ # 4) Manhattan's distance
99
+ manhattan,
100
+ # 5) Chebyshev's distance
101
+ chebyshev,
102
+ # 6) Bray-Curtis' distance
103
+ _div(manhattan, bsum(pb, xa + xb)),
104
+ # 7) Angular separation
105
+ _div(bsum(pb, xa * xb), np.sqrt(bsum(pb, xa ** 2) * bsum(pb, xb ** 2))),
106
+ # 8) Correlation coefficient
107
+ _div(cov, np.sqrt(va * vb)),
108
+ # 9) Entropy (bits)
109
+ -bsum(blk, x * np.log2(x)),
110
+ # 10) Kullback-Leibler's divergence (pairs where X_f or X_{f+1} is zero are skipped)
111
+ bsum(pb, np.where(both, xa * np.log10(r), 0.)),
112
+ # 11) Jensen-Shannon's divergence
113
+ bsum(pb, (_xlogy(xa, mix) + _xlogy(xb, mix)) / 2),
114
+ # 12) Itakura-Saito's divergence (pairs where X_f or X_{f+1} is zero are skipped)
115
+ bsum(pb, np.where(both, r - np.log10(r) - 1, 0.)),
116
+ # 13) Total variation
117
+ manhattan / 2,
118
+ ])
119
+
120
+
121
+ def _xlogy(x, y):
122
+ """ x * log10(x / y), with 0 where x is 0. """
123
+ out = np.zeros_like(x)
124
+ nz = x > 0
125
+ out[nz] = x[nz] * np.log10(x[nz] / y[nz])
126
+ return out
127
+
128
+
129
+ def block_features(data, ngram=1, block_size=BLOCK_SIZE, minkowski_order=MINKOWSKI_ORDER):
130
+ """ Compute the thirteen measures of each block of the input bytes.
131
+
132
+ :param data: bytes to analyze
133
+ :param ngram: n-gram size, from 1 (unigrams) to 4 (four-grams)
134
+ :param block_size: size of the blocks, in bytes
135
+ :param minkowski_order: order (lambda) of Minkowski's distance
136
+ :return: array of shape (number of blocks, 13), with columns in the order of FEATURES
137
+ """
138
+ if ngram not in NGRAMS:
139
+ raise ValueError(f"n-gram size must be one of {', '.join(map(str, NGRAMS))}")
140
+ if block_size < ngram:
141
+ raise ValueError("block size must be at least the n-gram size")
142
+ n_per_block, keys, counts = _histograms(bytes(data), ngram, block_size)
143
+ if len(n_per_block) == 0:
144
+ return np.zeros((0, len(FEATURES)))
145
+ return _measures(n_per_block, keys, counts, ngram, minkowski_order)
146
+
147
+
148
+ def file_features(path, **kwargs):
149
+ """ Compute the thirteen measures of each block of a file (see block_features). """
150
+ with open(path, "rb") as f:
151
+ return block_features(f.read(), **kwargs)