augure 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +136 -0
- data/LICENSE +21 -0
- data/README.md +217 -0
- data/exe/augure +181 -0
- data/exe/augure-benchmark +67 -0
- data/home/runner/work/augure/augure/docs/agent-snippet.md +31 -0
- data/home/runner/work/augure/augure/docs/auditability.md +160 -0
- data/home/runner/work/augure/augure/docs/build-format.md +119 -0
- data/home/runner/work/augure/augure/docs/explanation-architecture.md +74 -0
- data/home/runner/work/augure/augure/docs/how-to-swap-solver.md +73 -0
- data/home/runner/work/augure/augure/docs/how-to-write-rules.md +71 -0
- data/home/runner/work/augure/augure/docs/pack-format.md +137 -0
- data/home/runner/work/augure/augure/docs/reference.md +293 -0
- data/home/runner/work/augure/augure/docs/tutorial.md +108 -0
- data/home/runner/work/augure/augure/examples/ret2win.facts +11 -0
- data/home/runner/work/augure/augure/examples/split.facts +12 -0
- data/home/runner/work/augure/augure/exe/augure +181 -0
- data/home/runner/work/augure/augure/exe/augure-benchmark +67 -0
- data/home/runner/work/augure/augure/lib/augure/bandit.rb +118 -0
- data/home/runner/work/augure/augure/lib/augure/coverage.rb +56 -0
- data/home/runner/work/augure/augure/lib/augure/ctf_corpus.json +773 -0
- data/home/runner/work/augure/augure/lib/augure/engine.rb +128 -0
- data/home/runner/work/augure/augure/lib/augure/facts.rb +194 -0
- data/home/runner/work/augure/augure/lib/augure/kb_corpus.json +345 -0
- data/home/runner/work/augure/augure/lib/augure/knowledge_base.rb +109 -0
- data/home/runner/work/augure/augure/lib/augure/mcts.rb +178 -0
- data/home/runner/work/augure/augure/lib/augure/pack.rb +244 -0
- data/home/runner/work/augure/augure/lib/augure/pack_loader.rb +93 -0
- data/home/runner/work/augure/augure/lib/augure/parity.rb +69 -0
- data/home/runner/work/augure/augure/lib/augure/pipeline.rb +107 -0
- data/home/runner/work/augure/augure/lib/augure/priors.rb +32 -0
- data/home/runner/work/augure/augure/lib/augure/rules.rb +300 -0
- data/home/runner/work/augure/augure/lib/augure/smt_process.rb +48 -0
- data/home/runner/work/augure/augure/lib/augure/verifier.rb +40 -0
- data/home/runner/work/augure/augure/lib/augure/version.rb +5 -0
- data/home/runner/work/augure/augure/lib/augure.rb +25 -0
- data/home/runner/work/augure/augure/packs/banner_leak.yml +46 -0
- data/home/runner/work/augure/augure/packs/brop.yml +46 -0
- data/home/runner/work/augure/augure/packs/fastbin_hook.yml +45 -0
- data/home/runner/work/augure/augure/packs/got_partial_overwrite.yml +45 -0
- data/home/runner/work/augure/augure/packs/got_partial_overwrite_deref.yml +39 -0
- data/home/runner/work/augure/augure/packs/house_botcake.yml +38 -0
- data/home/runner/work/augure/augure/packs/house_force.yml +37 -0
- data/home/runner/work/augure/augure/packs/house_of_apple2.yml +45 -0
- data/home/runner/work/augure/augure/packs/house_orange.yml +38 -0
- data/home/runner/work/augure/augure/packs/jop.yml +39 -0
- data/home/runner/work/augure/augure/packs/large_bin_attack.yml +39 -0
- data/home/runner/work/augure/augure/packs/one_gadget.yml +38 -0
- data/home/runner/work/augure/augure/packs/orw.yml +55 -0
- data/home/runner/work/augure/augure/packs/ret2csu_v2.yml +55 -0
- data/home/runner/work/augure/augure/packs/ret2dlresolve_x86.yml +39 -0
- data/home/runner/work/augure/augure/packs/ret2partial_overwrite.yml +38 -0
- data/home/runner/work/augure/augure/packs/ret2vdso.yml +39 -0
- data/home/runner/work/augure/augure/packs/setcontext_srop.yml +46 -0
- data/home/runner/work/augure/augure/packs/uaf_tcache.yml +45 -0
- data/home/runner/work/augure/augure/packs/unsorted_bin_attack.yml +38 -0
- metadata +103 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Augure
|
|
6
|
+
# Retrieval-augmented knowledge base over a seed corpus of documented
|
|
7
|
+
# exploitation patterns (ROP Emporium / Protostar / pwnable.tw write-ups).
|
|
8
|
+
# Pure-Ruby TF-IDF; a consumer can swap in an embedding endpoint, the
|
|
9
|
+
# interface stays query -> ranked entries.
|
|
10
|
+
class KnowledgeBase
|
|
11
|
+
CORPUS_PATH = File.join(__dir__, "kb_corpus.json")
|
|
12
|
+
PSEUDO_COUNTS = 10
|
|
13
|
+
MIN_SCORE = 0.05
|
|
14
|
+
|
|
15
|
+
Entry = Struct.new(:doc, :score)
|
|
16
|
+
|
|
17
|
+
def self.corpus
|
|
18
|
+
@corpus ||= JSON.parse(File.read(CORPUS_PATH)).freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The seed corpus extended with knowledge from loaded technique
|
|
22
|
+
# packs - entries carry the same shape, so retrieval, priors and
|
|
23
|
+
# explain see pack techniques like built-in ones.
|
|
24
|
+
def self.corpus_with(extra)
|
|
25
|
+
(corpus + extra.to_a).freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.priors
|
|
29
|
+
accum = Hash.new { |h, k| h[k] = [0.0, 0.0] }
|
|
30
|
+
corpus.each do |entry|
|
|
31
|
+
tech = entry["technique"]
|
|
32
|
+
rate = entry["success_rate"]
|
|
33
|
+
next if !tech || rate.nil?
|
|
34
|
+
|
|
35
|
+
accum[tech][0] += rate * PSEUDO_COUNTS
|
|
36
|
+
accum[tech][1] += (1 - rate) * PSEUDO_COUNTS
|
|
37
|
+
end
|
|
38
|
+
accum
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.transitions
|
|
42
|
+
corpus.each_with_object(Hash.new { |h, k| h[k] = [] }) do |entry, acc|
|
|
43
|
+
unlocks = entry["unlocks"]
|
|
44
|
+
next if !entry["technique"] || !unlocks.is_a?(Array)
|
|
45
|
+
|
|
46
|
+
unlocks.each { |u| acc[entry["technique"]] << u unless acc[entry["technique"]].include?(u) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :size
|
|
51
|
+
|
|
52
|
+
def initialize(extra: [])
|
|
53
|
+
@corpus = extra.empty? ? self.class.corpus : self.class.corpus_with(extra)
|
|
54
|
+
@size = @corpus.size
|
|
55
|
+
build_index
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def query(text, technique: nil, n_results: 3)
|
|
59
|
+
candidates = @corpus.each_with_index.select { |e, _| technique.nil? || e["technique"] == technique }
|
|
60
|
+
candidates = @corpus.each_with_index.to_a if candidates.empty?
|
|
61
|
+
return [] if candidates.empty?
|
|
62
|
+
|
|
63
|
+
q = encode(text)
|
|
64
|
+
scored = candidates.filter_map do |entry, i|
|
|
65
|
+
score = dot(@vectors[i], q)
|
|
66
|
+
Entry.new(entry, score) if score >= MIN_SCORE
|
|
67
|
+
end
|
|
68
|
+
scored.sort_by { |e| -e.score }.first(n_results)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def build_index
|
|
74
|
+
texts = @corpus.map { |e| e["description"] }
|
|
75
|
+
tokens = texts.map { |t| tokenize(t) }
|
|
76
|
+
@vocab = {}
|
|
77
|
+
tokens.each { |tk| tk.each { |w| @vocab[w] ||= @vocab.size } }
|
|
78
|
+
@df = Array.new(@vocab.size, 0)
|
|
79
|
+
tokens.each { |tk| tk.uniq.each { |w| @df[@vocab[w]] += 1 } }
|
|
80
|
+
@idf = @df.map { |df| Math.log((texts.size + 1).to_f / (df + 1)) }
|
|
81
|
+
@vectors = texts.each_with_index.map { |t, i| encode(t, tf_source: tokens[i]) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def tokenize(text)
|
|
85
|
+
text.downcase.scan(/[a-z0-9_]+/)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# TF (length-normalized) * IDF, L2-normalized. Sparse Hash vector.
|
|
89
|
+
def encode(text, tf_source: nil)
|
|
90
|
+
tokens = tf_source || tokenize(text)
|
|
91
|
+
counts = Hash.new(0.0)
|
|
92
|
+
tokens.each { |w| counts[@vocab[w]] += 1 if @vocab.key?(w) }
|
|
93
|
+
total = counts.values.sum
|
|
94
|
+
return {} if total.zero?
|
|
95
|
+
|
|
96
|
+
vec = {}
|
|
97
|
+
counts.each do |term_idx, tf|
|
|
98
|
+
val = (tf / total) * @idf[term_idx]
|
|
99
|
+
vec[term_idx] = val
|
|
100
|
+
end
|
|
101
|
+
norm = Math.sqrt(vec.values.sum { |v| v * v }) + 1e-9
|
|
102
|
+
vec.transform_values { |v| v / norm }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dot(a, b)
|
|
106
|
+
b.sum { |i, v| a.fetch(i, 0.0) * v }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Augure
|
|
4
|
+
# Monte Carlo Tree Search over the technique-transition graph (article 3:
|
|
5
|
+
# AG -> bandit -> MCTS). Nodes are capability sets, edges are verified
|
|
6
|
+
# techniques, rollouts score the discounted product of success rates. A
|
|
7
|
+
# flat bandit cannot value a leak by what it unlocks; this can.
|
|
8
|
+
class Mcts
|
|
9
|
+
# technique => [requires, provides, terminal, base_success]
|
|
10
|
+
STAGE_MODEL = {
|
|
11
|
+
"fmtstr_leak" => [[], %w[libc_base], false, 0.75],
|
|
12
|
+
"fmtstr_canary" => [[], %w[canary], false, 0.70],
|
|
13
|
+
"ret2plt_leak" => [[], %w[libc_base], false, 0.68],
|
|
14
|
+
"ret2libc" => [%w[libc_base], %w[shell], true, 0.85],
|
|
15
|
+
"rop" => [%w[libc_base], %w[shell], true, 0.75],
|
|
16
|
+
# ROP without a libc leak on a PIE target is unreliable (addresses are
|
|
17
|
+
# guessed). Low success reflects the real-world penalty.
|
|
18
|
+
"rop_nocontext" => [[], %w[shell], true, 0.25],
|
|
19
|
+
"ret2plt" => [[], %w[shell], true, 0.75],
|
|
20
|
+
"shellcode" => [[], %w[shell], true, 0.90],
|
|
21
|
+
"stack_pivot" => [[], %w[pivot], false, 0.60],
|
|
22
|
+
# Pivot-gated variants: the real chain only fits after a pivot,
|
|
23
|
+
# modeling the stack-space constraint.
|
|
24
|
+
"ret2plt_leak_big" => [%w[pivot], %w[libc_base], false, 0.68],
|
|
25
|
+
"ret2libc_big" => [%w[pivot libc_base], %w[shell], true, 0.85],
|
|
26
|
+
# Extended vocabulary: techniques documented by public suites.
|
|
27
|
+
"ret2csu" => [[], %w[libc_base], false, 0.70],
|
|
28
|
+
"dlresolve" => [[], %w[shell], true, 0.55],
|
|
29
|
+
"got_overwrite" => [[], %w[shell], true, 0.65],
|
|
30
|
+
# PinTheft (RDS zerocopy double-free privesc, V12 Security): a
|
|
31
|
+
# DOCUMENTED external chain collapsed to its load-bearing spine.
|
|
32
|
+
"io_uring_register" => [[], %w[iouring], false, 0.90],
|
|
33
|
+
"rds_pin_steal" => [%w[iouring], %w[pin_underflow], false, 0.70],
|
|
34
|
+
"page_free" => [%w[pin_underflow], %w[freed_page], false, 0.75],
|
|
35
|
+
"pagecache_reclaim" => [%w[freed_page], %w[pagecache_ctrl], false, 0.65],
|
|
36
|
+
"io_uring_write" => [%w[pagecache_ctrl iouring], %w[suid_overwrite], false, 0.70],
|
|
37
|
+
"suid_exec" => [%w[suid_overwrite], %w[shell], true, 0.90]
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
DISCOUNT = 0.97
|
|
41
|
+
MAX_DEPTH = 6
|
|
42
|
+
UCB_C = 1.41
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
def caps
|
|
46
|
+
Set.new.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def available(caps, allowed, model: STAGE_MODEL)
|
|
50
|
+
model.filter_map do |tech, (req, _, _, _)|
|
|
51
|
+
tech if allowed.include?(tech) && req.all? { |r| caps.include?(r) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def transition(caps, tech, model: STAGE_MODEL)
|
|
56
|
+
caps + model.fetch(tech)[1]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# terminal = any capability a terminal:true stage PROVIDES (the model
|
|
60
|
+
# owns the semantics - the orw pack terminates on flag_read, not
|
|
61
|
+
# on a shell that never comes). Built-in terminals all provide
|
|
62
|
+
# "shell", so built-in behaviour is unchanged.
|
|
63
|
+
def terminal?(caps, model: STAGE_MODEL)
|
|
64
|
+
model.each_value.any? do |(_, provides, term, _)|
|
|
65
|
+
term && provides.any? { |c| caps.include?(c) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stage(tech, model: STAGE_MODEL)
|
|
70
|
+
model.fetch(tech)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Built-in stage model + technique-pack transitions (packs only add).
|
|
74
|
+
def merged_model(packs)
|
|
75
|
+
merged = STAGE_MODEL.dup
|
|
76
|
+
Array(packs).each do |pack|
|
|
77
|
+
m = pack.mcts
|
|
78
|
+
merged[pack.technique] = [m["requires"], m["provides"], m["terminal"], m["success"]]
|
|
79
|
+
end
|
|
80
|
+
merged.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Random rollout: discounted product of success rates down the path.
|
|
84
|
+
# Reaching shell is necessary but not sufficient: the terminal
|
|
85
|
+
# technique's own success scales the reward.
|
|
86
|
+
def rollout(caps, allowed, depth: 0, rng: Random.new, max_depth: MAX_DEPTH, model: STAGE_MODEL)
|
|
87
|
+
return 1.0 if terminal?(caps, model: model)
|
|
88
|
+
return 0.0 if depth >= max_depth
|
|
89
|
+
|
|
90
|
+
avail = available(caps, allowed, model: model)
|
|
91
|
+
return 0.0 if avail.empty?
|
|
92
|
+
|
|
93
|
+
tech = avail[rand_index(rng, avail.size)]
|
|
94
|
+
success = stage(tech, model: model)[3]
|
|
95
|
+
new_caps = transition(caps, tech, model: model)
|
|
96
|
+
success * (DISCOUNT**depth) * rollout(new_caps, allowed, depth: depth + 1, rng: rng, model: model)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# MCTS from an empty capability set: best first move + most-visited path.
|
|
100
|
+
def plan(allowed, iterations: 2000, seed: nil, model: STAGE_MODEL)
|
|
101
|
+
rng = seed ? Random.new(seed) : Random.new
|
|
102
|
+
root = Node.new(caps: caps)
|
|
103
|
+
root.untried = available(root.caps, allowed, model: model)
|
|
104
|
+
|
|
105
|
+
iterations.times do
|
|
106
|
+
node = root
|
|
107
|
+
# 1. Selection
|
|
108
|
+
node = node.children.max_by { |n| n.ucb1(node.visits) } while node.untried.empty? && !node.children.empty?
|
|
109
|
+
# 2. Expansion. A child whose capability set equals its
|
|
110
|
+
# parent's adds nothing (the degenerate-tree case: the walk
|
|
111
|
+
# grows ~iterations deep, selection+backprop O(iterations^2)).
|
|
112
|
+
# Such nodes are not expanded - the parent absorbs them.
|
|
113
|
+
unless node.untried.empty?
|
|
114
|
+
tech = node.untried.pop
|
|
115
|
+
new_caps = transition(node.caps, tech, model: model)
|
|
116
|
+
next if new_caps == node.caps && !terminal?(new_caps, model: model)
|
|
117
|
+
|
|
118
|
+
child = Node.new(caps: new_caps, technique_used: tech, parent: node)
|
|
119
|
+
child.untried = terminal?(new_caps) ? [] : available(new_caps, allowed, model: model)
|
|
120
|
+
node.children << child
|
|
121
|
+
node = child
|
|
122
|
+
end
|
|
123
|
+
# 3. Simulation. An already-terminal expansion node scores its own
|
|
124
|
+
# technique's success, not a flat 1.0.
|
|
125
|
+
reward = if terminal?(node.caps, model: model) && node.technique_used
|
|
126
|
+
stage(node.technique_used, model: model)[3]
|
|
127
|
+
else
|
|
128
|
+
rollout(node.caps, allowed, rng: rng, model: model)
|
|
129
|
+
end
|
|
130
|
+
# 4. Backpropagation
|
|
131
|
+
until node.nil?
|
|
132
|
+
node.visits += 1
|
|
133
|
+
node.value += reward
|
|
134
|
+
node = node.parent
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
path = []
|
|
139
|
+
node = root
|
|
140
|
+
until node.children.empty?
|
|
141
|
+
node = node.children.max_by(&:visits)
|
|
142
|
+
path << node.technique_used
|
|
143
|
+
break if terminal?(node.caps, model: model)
|
|
144
|
+
end
|
|
145
|
+
best_first = root.children.max_by(&:visits)&.technique_used
|
|
146
|
+
[best_first, path]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def rand_index(rng, n)
|
|
152
|
+
(rng.rand * n).to_i.clamp(0, n - 1)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
class Node
|
|
157
|
+
attr_reader :caps, :children
|
|
158
|
+
attr_accessor :technique_used, :parent, :visits, :value, :untried
|
|
159
|
+
|
|
160
|
+
def initialize(caps:, technique_used: nil, parent: nil)
|
|
161
|
+
@caps = caps.freeze
|
|
162
|
+
@technique_used = technique_used
|
|
163
|
+
@parent = parent
|
|
164
|
+
@children = []
|
|
165
|
+
@visits = 0
|
|
166
|
+
@value = 0.0
|
|
167
|
+
@untried = []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def ucb1(parent_visits)
|
|
171
|
+
return Float::INFINITY if visits.zero?
|
|
172
|
+
|
|
173
|
+
(value / visits) +
|
|
174
|
+
UCB_C * Math.sqrt(Math.log(parent_visits) / visits)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Augure
|
|
6
|
+
# A technique pack: ONE new technique, complete, as DATA - rules, KB
|
|
7
|
+
# entries, MCTS transitions and priors. Written by an operator or an
|
|
8
|
+
# agent; validated here before it can influence anything.
|
|
9
|
+
#
|
|
10
|
+
# The armor (in load order):
|
|
11
|
+
# 1. constrained condition vocabulary (fact/not_fact/match/cmp)
|
|
12
|
+
# 2. conditions reference EXISTING predicates only (facts schema +
|
|
13
|
+
# built-in derived heads) - a pack cannot invent a fact source
|
|
14
|
+
# 3. stratification: negation on input facts only
|
|
15
|
+
# 4. new heads only: a pack never overrides a built-in technique
|
|
16
|
+
# 5. provenance is mandatory (author + source)
|
|
17
|
+
# 6. the corpus guard runs in PackLoader (belt and braces on 4)
|
|
18
|
+
class Pack
|
|
19
|
+
CONDITION_VOCABULARY = %w[fact not_fact match cmp].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :technique, :author, :source, :rules, :kb, :mcts, :priors, :build, :detection, :mitre
|
|
22
|
+
|
|
23
|
+
def self.load_file(path)
|
|
24
|
+
data = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
25
|
+
raise PackError, "#{path}: invalid YAML" unless data.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
load_hash(data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.load_hash(data)
|
|
31
|
+
new(data)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(data)
|
|
35
|
+
@data = data
|
|
36
|
+
validate_shape
|
|
37
|
+
build
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def reject(message)
|
|
43
|
+
raise PackError, "#{technique_name || "pack"}: #{message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def technique_name
|
|
47
|
+
@data["technique"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_shape
|
|
51
|
+
@technique = @data["technique"]
|
|
52
|
+
unless @technique.is_a?(String) && @technique.match?(/\A[a-z0-9_]+\z/)
|
|
53
|
+
reject "technique name must match [a-z0-9_]+"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@author = @data["author"]
|
|
57
|
+
reject "author is mandatory (provenance is not optional)" unless @author.is_a?(String) && !@author.empty?
|
|
58
|
+
@source = @data["source"]
|
|
59
|
+
unless @source.is_a?(String) && !@source.empty?
|
|
60
|
+
reject "source is mandatory (write-up URL, ticket, anything verifiable)"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
validate_rules(@data["rules"])
|
|
64
|
+
validate_kb(@data["kb"])
|
|
65
|
+
validate_mcts(@data["mcts"])
|
|
66
|
+
validate_priors(@data["priors"])
|
|
67
|
+
validate_build(@data["build"])
|
|
68
|
+
validate_detection(@data["detection"])
|
|
69
|
+
validate_mitre(@data["mitre"])
|
|
70
|
+
build_rules
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def known_predicates
|
|
74
|
+
@known_predicates ||= Facts::SCHEMA.keys + Rules.all.reject { |r| r.head[0] == Rules::APPLICABLE }.map { |r| r.head[0] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def input_predicates
|
|
78
|
+
Facts::SCHEMA
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def built_in_techniques
|
|
82
|
+
@built_in_techniques ||= Rules.applicable_rules.map { |r| r.head[1] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_rules(rules)
|
|
86
|
+
reject "rules are mandatory" unless rules.is_a?(Array) && !rules.empty?
|
|
87
|
+
|
|
88
|
+
rules.each_with_index do |rule, i|
|
|
89
|
+
reject "rule #{i}: id mandatory" unless rule["id"].is_a?(String)
|
|
90
|
+
head = rule["head"]
|
|
91
|
+
unless head.is_a?(Array) && head[0] == Rules::APPLICABLE && head[1] == @technique
|
|
92
|
+
reject "rule #{i}: head must be [applicable, <technique>]"
|
|
93
|
+
end
|
|
94
|
+
if built_in_techniques.include?(head[1])
|
|
95
|
+
reject "head overrides a built-in technique (#{head[1]}) - new techniques only"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
conditions = rule["conditions"]
|
|
99
|
+
reject "rule #{i}: conditions mandatory" unless conditions.is_a?(Array) && !conditions.empty?
|
|
100
|
+
conditions.each_with_index do |cond, j|
|
|
101
|
+
validate_condition(cond, "rule #{i} condition #{j}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_condition(cond, label)
|
|
107
|
+
unless cond.is_a?(Array) && CONDITION_VOCABULARY.include?(cond[0])
|
|
108
|
+
reject "#{label}: #{cond.inspect} is outside the condition vocabulary #{CONDITION_VOCABULARY.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
kind = cond[0]
|
|
112
|
+
rel = cond[1]
|
|
113
|
+
if kind == "not_fact"
|
|
114
|
+
unless input_predicates.key?(rel)
|
|
115
|
+
reject "#{label}: not_fact on #{rel.inspect} breaks stratification (negation on input facts only)"
|
|
116
|
+
end
|
|
117
|
+
elsif kind == "cmp"
|
|
118
|
+
reject "#{label}: cmp needs [cmp, rel, op, n]" unless cond.size == 4
|
|
119
|
+
reject "#{label}: unknown predicate #{rel.inspect}" unless input_predicates.key?(rel)
|
|
120
|
+
unless Engine::OP.key?(cond[2].to_sym)
|
|
121
|
+
reject "#{label}: cmp operator #{cond[2].inspect} unknown (#{Engine::OP.keys.join(", ")})"
|
|
122
|
+
end
|
|
123
|
+
unless cond[3].is_a?(Integer)
|
|
124
|
+
reject "#{label}: cmp operand #{cond[3].inspect} must be an integer (the engine compares integer fact values)"
|
|
125
|
+
end
|
|
126
|
+
elsif kind == "match"
|
|
127
|
+
reject "#{label}: match pattern #{cond[3].inspect} must be a string" unless cond[3].is_a?(String)
|
|
128
|
+
begin
|
|
129
|
+
Regexp.new(cond[3])
|
|
130
|
+
rescue RegexpError => e
|
|
131
|
+
reject "#{label}: match pattern #{cond[3].inspect} is not a valid regex (#{e.message})"
|
|
132
|
+
end
|
|
133
|
+
else
|
|
134
|
+
reject "#{label}: unknown predicate #{rel.inspect}" unless known_predicates.include?(rel)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_kb(entries)
|
|
139
|
+
reject "kb entries mandatory (the knowledge IS the technique)" unless entries.is_a?(Array) && !entries.empty?
|
|
140
|
+
entries.each_with_index do |e, i|
|
|
141
|
+
reject "kb #{i}: id mandatory" unless e["id"].is_a?(String)
|
|
142
|
+
rate = e["success_rate"]
|
|
143
|
+
reject "kb #{i}: success_rate must be a number in [0, 1]" unless rate.is_a?(Numeric) && rate.between?(0, 1)
|
|
144
|
+
reject "kb #{i}: description mandatory" unless e["description"].is_a?(String) && !e["description"].empty?
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_mcts(mcts)
|
|
149
|
+
reject "mcts transitions mandatory" unless mcts.is_a?(Hash)
|
|
150
|
+
%w[requires provides].each do |k|
|
|
151
|
+
reject "mcts.#{k} must be an array" unless mcts[k].is_a?(Array)
|
|
152
|
+
end
|
|
153
|
+
reject "mcts.terminal must be true or false" unless [true, false].include?(mcts["terminal"])
|
|
154
|
+
reject "mcts.success must be a number in [0, 1]" unless mcts["success"].is_a?(Numeric) && mcts["success"].between?(
|
|
155
|
+
0, 1
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_priors(priors)
|
|
160
|
+
return if priors.is_a?(Array) && priors.size == 2 && priors.all?(Numeric)
|
|
161
|
+
|
|
162
|
+
reject "priors must be [alpha, beta] (two numbers)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# armor check 8: the optional build: section (the pack teaching the
|
|
166
|
+
# BUILDER its assembly order) must name its own technique and use
|
|
167
|
+
# the slot vocabulary only. The consumer (lictor's PayloadBuilder)
|
|
168
|
+
# interprets the slots; here we gate the vocabulary.
|
|
169
|
+
BUILD_SLOTS = %w[padding qword dword shellcode chain].freeze
|
|
170
|
+
|
|
171
|
+
# armor check 9: the optional detection: section - the DEFENDER
|
|
172
|
+
# side of the technique (the corpus read backwards). Constrained
|
|
173
|
+
# channel vocabulary; every entry carries a signature and a note -
|
|
174
|
+
# a vague hint is not detection content.
|
|
175
|
+
DETECTION_CHANNELS = %w[wire syslog syscall crash file].freeze
|
|
176
|
+
|
|
177
|
+
def validate_detection(detection)
|
|
178
|
+
return if detection.nil?
|
|
179
|
+
|
|
180
|
+
unless detection.is_a?(Array) && !detection.empty?
|
|
181
|
+
reject "detection: must be a non-empty array"
|
|
182
|
+
end
|
|
183
|
+
detection.each_with_index do |e, i|
|
|
184
|
+
unless e.is_a?(Hash) && DETECTION_CHANNELS.include?(e["channel"])
|
|
185
|
+
reject "detection #{i}: channel must be from #{DETECTION_CHANNELS.inspect}"
|
|
186
|
+
end
|
|
187
|
+
%w[signature note].each do |field|
|
|
188
|
+
unless e[field].is_a?(String) && !e[field].empty?
|
|
189
|
+
reject "detection #{i}: #{field} is mandatory (a vague hint is not detection content)"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
@detection = detection
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# armor check 10: the optional mitre: mapping - ATT&CK technique
|
|
197
|
+
# codes (T####[.###]); the export layer (rules --mitre) consumes it.
|
|
198
|
+
MITRE_CODE = /\AT\d{4}(?:\.\d{3})?\z/
|
|
199
|
+
|
|
200
|
+
def validate_mitre(mitre)
|
|
201
|
+
return if mitre.nil?
|
|
202
|
+
|
|
203
|
+
unless mitre.is_a?(Array) && !mitre.empty? && mitre.all? { |c| c.is_a?(String) && c.match?(MITRE_CODE) }
|
|
204
|
+
reject "mitre: must be an array of ATT&CK technique codes (T1068, T1068.001)"
|
|
205
|
+
end
|
|
206
|
+
@mitre = mitre
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_build(build)
|
|
210
|
+
return if build.nil?
|
|
211
|
+
|
|
212
|
+
unless build.is_a?(Hash) && build["technique"] == @technique
|
|
213
|
+
reject "build: must name its own technique (#{build.is_a?(Hash) ? build["technique"].inspect : "missing"})"
|
|
214
|
+
end
|
|
215
|
+
layout = build["layout"]
|
|
216
|
+
unless layout.is_a?(Array) && !layout.empty? &&
|
|
217
|
+
layout.all? { |slot| slot.is_a?(Array) && BUILD_SLOTS.include?(slot[0]) }
|
|
218
|
+
reject "build: layout must be a non-empty array of slots from #{BUILD_SLOTS.inspect}"
|
|
219
|
+
end
|
|
220
|
+
@build = build
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build_rules
|
|
224
|
+
@rules = @data["rules"].map do |rule|
|
|
225
|
+
conditions = rule["conditions"].map do |cond|
|
|
226
|
+
normalized = cond.map do |v|
|
|
227
|
+
if v == true then "true"
|
|
228
|
+
elsif v == false then "false"
|
|
229
|
+
else v
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
[normalized[0].to_sym, *normalized[1..]]
|
|
233
|
+
end
|
|
234
|
+
Rules::Rule.new(
|
|
235
|
+
id: rule["id"].to_sym, head: rule["head"], conditions: conditions,
|
|
236
|
+
source: "#{@data["source"]} (pack #{@technique} by #{@author})", origin: @technique
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
@kb = @data["kb"]
|
|
240
|
+
@mcts = @data["mcts"]
|
|
241
|
+
@priors = @data["priors"].map(&:to_f)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Augure
|
|
6
|
+
# Loads and screens technique packs. The corpus guard is the armor's
|
|
7
|
+
# last wall: with the packs loaded, every frozen corpus verdict must
|
|
8
|
+
# stay identical - a pack that moves an existing decision is refused
|
|
9
|
+
# at load time, not discovered in production.
|
|
10
|
+
module PackLoader
|
|
11
|
+
CORPUS_CACHE = {}
|
|
12
|
+
BASE_DECISIONS = {}
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def load_file(path)
|
|
17
|
+
Pack.load_file(path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load_dir(dir)
|
|
21
|
+
Dir.glob(File.join(dir, "*.{yml,yaml}")).sort.map { |p| Pack.load_file(p) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Belt and braces: new-heads-only already makes flips structurally
|
|
25
|
+
# impossible; this proves it on every load.
|
|
26
|
+
def corpus_guard(packs, corpus_path: nil)
|
|
27
|
+
corpus_path ||= File.join(__dir__, "ctf_corpus.json")
|
|
28
|
+
corpus = CORPUS_CACHE[corpus_path] ||= JSON.parse(File.read(corpus_path))
|
|
29
|
+
# The "before" decisions depend only on the built-in rules and the
|
|
30
|
+
# base priors - identical across every guard run. Cached.
|
|
31
|
+
base_priors = Pipeline.default_priors
|
|
32
|
+
corpus["suites"].values.flatten.each do |entry|
|
|
33
|
+
before = BASE_DECISIONS[[corpus_path, entry["name"]]] ||=
|
|
34
|
+
Pipeline.analyze(facts: entry["facts"], priors: base_priors, plan: false)
|
|
35
|
+
after = Pipeline.analyze(facts: entry["facts"], priors: priors_with(packs, base_priors),
|
|
36
|
+
packs: packs, plan: false)
|
|
37
|
+
# A documented pack verdict is not a mover: the frozen entry may
|
|
38
|
+
# say the built-in table has no answer for this target AND the
|
|
39
|
+
# pack layer does (entry["pack_verdict"]) - the guard then
|
|
40
|
+
# checks the pack answer ARRIVES, not that nothing changed. The
|
|
41
|
+
# check holds only when that pack is among the loaded ones.
|
|
42
|
+
if entry["pack_verdict"] && packs.any? { |p| p.technique == entry["pack_verdict"] }
|
|
43
|
+
unless after[:ranking].first == entry["pack_verdict"]
|
|
44
|
+
raise PackError,
|
|
45
|
+
"corpus guard: pack(s) #{packs.map(&:technique).join(", ")} fail the documented " \
|
|
46
|
+
"pack verdict on #{entry["suite"]}/#{entry["name"]} " \
|
|
47
|
+
"(expected #{entry["pack_verdict"]}, got #{after[:ranking].first}) - refused"
|
|
48
|
+
end
|
|
49
|
+
next
|
|
50
|
+
end
|
|
51
|
+
# An empty frozen verdict has NOTHING to move: additive packs
|
|
52
|
+
# extending it are the pack layer working (the documented case
|
|
53
|
+
# was policed above by pack_verdict when its pack is loaded).
|
|
54
|
+
next if before[:applicable].empty?
|
|
55
|
+
|
|
56
|
+
# Additive packs may extend applicable/ranking with NEW techniques
|
|
57
|
+
# below the documented top - what they must never do: (1) move an
|
|
58
|
+
# EXISTING technique's order, (2) take the top-1 of a DOCUMENTED
|
|
59
|
+
# corpus target (that verdict belongs to the frozen truth; claiming
|
|
60
|
+
# better on it is a dev-time corpus update, not a runtime pack).
|
|
61
|
+
# selected is a seeded Thompson draw - stochastic by design, so the
|
|
62
|
+
# guard compares the deterministic prior-mean ranking only.
|
|
63
|
+
existing_order = after[:ranking].select { |t| before[:applicable].include?(t) }
|
|
64
|
+
next if existing_order == before[:ranking] && after[:ranking].first == before[:ranking].first
|
|
65
|
+
|
|
66
|
+
raise PackError,
|
|
67
|
+
"corpus guard: pack(s) #{packs.map(&:technique).join(", ")} move the verdict " \
|
|
68
|
+
"on #{entry["suite"]}/#{entry["name"]} " \
|
|
69
|
+
"(#{before[:ranking].first} -> #{after[:ranking].first}) - refused"
|
|
70
|
+
end
|
|
71
|
+
packs
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Pack priors fill the gaps: a taught technique starts on its
|
|
75
|
+
# authored prior, but a base table entry (outcome-adapted or
|
|
76
|
+
# consumer-provided) always wins - the loop stays able to learn.
|
|
77
|
+
def priors_with(packs, base)
|
|
78
|
+
return base if packs.nil? || packs.empty?
|
|
79
|
+
|
|
80
|
+
merged = base.transform_values(&:dup)
|
|
81
|
+
packs.each do |pack|
|
|
82
|
+
merged[pack.technique] = pack.priors.dup unless merged.key?(pack.technique)
|
|
83
|
+
end
|
|
84
|
+
merged
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def mcts_model(packs)
|
|
88
|
+
return Mcts::STAGE_MODEL if packs.nil? || packs.empty?
|
|
89
|
+
|
|
90
|
+
Mcts.merged_model(packs.map(&:mcts))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Augure
|
|
4
|
+
# Static-parity contract: what the frozen corpus claims about a
|
|
5
|
+
# documented target vs what the profiler observed on the REAL binary.
|
|
6
|
+
# The corpus stays the write-up ground truth; parity asserts only what
|
|
7
|
+
# static ELF analysis can derive, and reports honestly what it cannot:
|
|
8
|
+
# gadget types outside the profiler's pattern set and source-level
|
|
9
|
+
# hints (a gets() the binary never imports) are SKIPPED, not failed.
|
|
10
|
+
class ParityResult
|
|
11
|
+
attr_reader :target, :missing, :skipped, :checked
|
|
12
|
+
|
|
13
|
+
def initialize(target, missing, skipped, checked)
|
|
14
|
+
@target = target
|
|
15
|
+
@missing = missing
|
|
16
|
+
@skipped = skipped
|
|
17
|
+
@checked = checked
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ok? = missing.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Parity
|
|
24
|
+
EXACT_RELATIONS = %w[nx pie canary relro].freeze
|
|
25
|
+
SUBSET_RELATIONS = %w[plt target_function].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# observed: a Facts object or a facts string (main binary + shipped
|
|
30
|
+
# libraries merged by the caller). entry: a corpus hash with "name"
|
|
31
|
+
# and "facts". supported_gadget_types: the byte-pattern vocabulary of
|
|
32
|
+
# the profiler at hand (passed in, so this core class stays
|
|
33
|
+
# profiler-agnostic).
|
|
34
|
+
def check(observed, entry, supported_gadget_types:)
|
|
35
|
+
observed = Facts.parse(observed) if observed.is_a?(String)
|
|
36
|
+
claimed = Facts.parse(entry.fetch("facts"))
|
|
37
|
+
missing = []
|
|
38
|
+
skipped = []
|
|
39
|
+
checked = 0
|
|
40
|
+
|
|
41
|
+
claimed.each do |rel, tuple|
|
|
42
|
+
case rel
|
|
43
|
+
when *EXACT_RELATIONS, *SUBSET_RELATIONS
|
|
44
|
+
checked += 1
|
|
45
|
+
missing << [rel, tuple] unless observed[rel]&.include?(tuple)
|
|
46
|
+
when "gadget"
|
|
47
|
+
if supported_gadget_types.include?(tuple[0])
|
|
48
|
+
checked += 1
|
|
49
|
+
missing << [rel, tuple] unless observed[rel]&.any? { |t| t[0] == tuple[0] }
|
|
50
|
+
else
|
|
51
|
+
skipped << [rel, tuple]
|
|
52
|
+
end
|
|
53
|
+
when "vuln_hint"
|
|
54
|
+
sym = tuple[0].delete_prefix("unsafe_func:")
|
|
55
|
+
if observed["plt"]&.any? { |t| t == [sym] }
|
|
56
|
+
checked += 1
|
|
57
|
+
missing << [rel, tuple] unless observed[rel]&.include?(tuple)
|
|
58
|
+
else
|
|
59
|
+
skipped << [rel, tuple]
|
|
60
|
+
end
|
|
61
|
+
else
|
|
62
|
+
skipped << [rel, tuple]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
ParityResult.new(entry["name"], missing, skipped, checked)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|