lictor 0.4.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 +240 -0
- data/LICENSE +21 -0
- data/README.md +285 -0
- data/bin/lictor +609 -0
- data/lib/config/build_layouts.yml +67 -0
- data/lib/lictor/acquisition.rb +78 -0
- data/lib/lictor/config.rb +24 -0
- data/lib/lictor/executor.rb +119 -0
- data/lib/lictor/exploit_scaffold.rb +61 -0
- data/lib/lictor/handoff.rb +62 -0
- data/lib/lictor/harvest.rb +58 -0
- data/lib/lictor/pack_draft.rb +84 -0
- data/lib/lictor/payload_builder.rb +187 -0
- data/lib/lictor/priors.rb +30 -0
- data/lib/lictor/profiler.rb +38 -0
- data/lib/lictor/recon.rb +61 -0
- data/lib/lictor/report.rb +123 -0
- data/lib/lictor/run.rb +174 -0
- data/lib/lictor/scanner.rb +31 -0
- data/lib/lictor/suggestion.rb +40 -0
- data/lib/lictor/targets.rb +38 -0
- data/lib/lictor/technique_packs.rb +25 -0
- data/lib/lictor/templates/scaffold_exploit.rb.erb +47 -0
- data/lib/lictor/trail.rb +77 -0
- data/lib/lictor/tube.rb +76 -0
- data/lib/lictor/version.rb +5 -0
- data/lib/lictor.rb +26 -0
- metadata +101 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "augure"
|
|
4
|
+
|
|
5
|
+
module Lictor
|
|
6
|
+
# Prior adaptation: the closed loop from the articles. Every recorded
|
|
7
|
+
# outcome (success/failure) is fed back into the augure bandit priors,
|
|
8
|
+
# so the next decision carries the engagement's own evidence. Same
|
|
9
|
+
# seed, different facts, honest adaptation - and every step trailed.
|
|
10
|
+
module Priors
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def from_trail(trail, base: nil)
|
|
14
|
+
base ||= Augure::Pipeline.default_priors
|
|
15
|
+
priors = base.transform_values(&:dup)
|
|
16
|
+
trail.events.each do |event|
|
|
17
|
+
next unless event["type"] == "outcome"
|
|
18
|
+
|
|
19
|
+
tech = event["technique"]
|
|
20
|
+
priors[tech] ||= [1.0, 1.0]
|
|
21
|
+
priors[tech][event["success"] ? 0 : 1] += 1
|
|
22
|
+
end
|
|
23
|
+
priors
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def adapted?(trail)
|
|
27
|
+
trail.events.any? { |event| event["type"] == "outcome" }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Lictor
|
|
7
|
+
# Bridge to the augure-profiler gem: binary in, facts out, through the
|
|
8
|
+
# serialization boundary (the augure-profile subprocess). A facts file
|
|
9
|
+
# passes through untouched - lictor accepts both.
|
|
10
|
+
class ProfilerError < StandardError; end
|
|
11
|
+
|
|
12
|
+
module Profiler
|
|
13
|
+
ELF_MAGIC = "\x7fELF"
|
|
14
|
+
DEFAULT_CMD = "augure-profile"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def elf?(path)
|
|
19
|
+
File.open(path, "rb") { |f| f.read(4) == ELF_MAGIC }
|
|
20
|
+
rescue
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(binary, cmd: DEFAULT_CMD, timeout: 300)
|
|
25
|
+
stdout, stderr, status = Timeout.timeout(timeout) { Open3.capture3(cmd, binary) }
|
|
26
|
+
unless status.success?
|
|
27
|
+
hint = stderr.lines.first&.strip || "exit #{status.exitstatus}"
|
|
28
|
+
raise ProfilerError,
|
|
29
|
+
"profiling #{binary} failed: #{hint} (gem install augure-profiler?)"
|
|
30
|
+
end
|
|
31
|
+
stdout
|
|
32
|
+
rescue Errno::ENOENT
|
|
33
|
+
raise ProfilerError, "augure-profile not found (gem install augure-profiler)"
|
|
34
|
+
rescue Timeout::Error
|
|
35
|
+
raise ProfilerError, "profiling #{binary} timed out after #{timeout}s (hung gdb/metasm? check the binary)"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/lictor/recon.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nmap/xml"
|
|
4
|
+
|
|
5
|
+
module Lictor
|
|
6
|
+
# Recon bridge: nmap XML in, target inventory out. ruby-nmap (1.0.3,
|
|
7
|
+
# postmodern) parses the XML; Lictor emits service candidates in the
|
|
8
|
+
# exact format the operator keeps - explicit host:port entries, wildcards
|
|
9
|
+
# structurally impossible. Recon never runs nmap itself: the operator
|
|
10
|
+
# scans, Lictor reads.
|
|
11
|
+
module Recon
|
|
12
|
+
Target = Struct.new(:host, :port, :service, :product, :version, keyword_init: false)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def from_nmap_xml(path)
|
|
17
|
+
Nmap::XML.open(path).hosts.flat_map do |host|
|
|
18
|
+
next [] unless host.status.state == :up
|
|
19
|
+
|
|
20
|
+
host.ports.select { |port| port.state == :open }.map do |port|
|
|
21
|
+
svc = port.service
|
|
22
|
+
{
|
|
23
|
+
host: host.address,
|
|
24
|
+
port: port.number,
|
|
25
|
+
service: svc&.name,
|
|
26
|
+
product: svc&.product,
|
|
27
|
+
version: svc&.version
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def service_candidates(path)
|
|
34
|
+
from_nmap_xml(path).map { |t| "#{t[:host]}:#{t[:port]}" }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Network facts for the decision layer: one fact block per open
|
|
38
|
+
# service. The binary-exploitation vocabulary grows with the service
|
|
39
|
+
# predicates (service/software_version/remote); technique packs match
|
|
40
|
+
# them exactly like binary facts.
|
|
41
|
+
def facts(path)
|
|
42
|
+
lines = []
|
|
43
|
+
from_nmap_xml(path).each do |t|
|
|
44
|
+
lines << "remote(\"true\").\n"
|
|
45
|
+
lines << "service(\"#{sanitize(t[:service])}\").\n" if t[:service]
|
|
46
|
+
if t[:product]
|
|
47
|
+
version = [t[:product], t[:version]].compact.join(" ")
|
|
48
|
+
lines << "software_version(\"#{sanitize(version)}\").\n" if t[:version]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
lines.uniq.join
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The banner data comes from the TARGET: it must fit the fact-atom
|
|
55
|
+
# grammar (no quotes, backslashes, parens, newlines) or a hostile
|
|
56
|
+
# service forges facts and steers the decision.
|
|
57
|
+
def sanitize(value)
|
|
58
|
+
value.to_s.gsub(/[\\"()\n\r]/, "_")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lictor
|
|
4
|
+
# The engagement report: the auditable deliverable. Every run - the
|
|
5
|
+
# decision, the rule provenance behind it, the handoff recipe, the
|
|
6
|
+
# outcome - rendered as markdown a client can read. Predictions stay
|
|
7
|
+
# labeled as predictions.
|
|
8
|
+
module Report
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def from_trail(trail)
|
|
12
|
+
lines = []
|
|
13
|
+
lines << "# Engagement report"
|
|
14
|
+
lines << ""
|
|
15
|
+
lines << "generated: #{Time.now.utc.iso8601}"
|
|
16
|
+
lines << ""
|
|
17
|
+
lines << "_All numbers below are model predictions with rule provenance, not field results._"
|
|
18
|
+
lines << ""
|
|
19
|
+
|
|
20
|
+
trail.runs.each do |run|
|
|
21
|
+
lines << "## Run `#{run[:run]}`"
|
|
22
|
+
lines << ""
|
|
23
|
+
lines << "- scan: #{run[:scanned]} (nmap)" if run[:scanned]
|
|
24
|
+
lines << "- acquire: #{run[:acquired]}" if run[:acquired]
|
|
25
|
+
if (built = run[:built])
|
|
26
|
+
lines << "- build: #{built[:technique]}, offset #{built[:offset]} (#{built[:ret]}), #{built[:size]} bytes -> #{built[:out]}"
|
|
27
|
+
end
|
|
28
|
+
lines << "- scaffold: #{run[:scaffolded]}" if run[:scaffolded]
|
|
29
|
+
lines << "- harvest: #{run[:harvested]}" if run[:harvested]
|
|
30
|
+
lines << "- verify: the remote #{run[:verified] ? "faulted" : "survived"}" unless run[:verified].nil?
|
|
31
|
+
lines << "- suggest: #{run[:suggested]} added" if run[:suggested]
|
|
32
|
+
lines << "- leak: #{run[:leaked]}" if run[:leaked]
|
|
33
|
+
if (measured = run[:measured])
|
|
34
|
+
lines << "- measure: the sink #{measured[:sink]} at offset #{measured[:offset]} (#{measured[:source]})"
|
|
35
|
+
end
|
|
36
|
+
lines << "- drafted: the pack #{run[:drafted]} (staging)" if run[:drafted]
|
|
37
|
+
lines << "- fuzz: #{run[:fuzzed]} (#{run[:crashes]} crashes)" if run[:fuzzed]
|
|
38
|
+
lines << "- mode: #{run[:mode]}" if run[:mode]
|
|
39
|
+
lines << "- facts: #{run[:facts_file]}" if run[:facts_file]
|
|
40
|
+
lines << "- applicable: #{Array(run[:applicable]).join(", ")}" if run[:applicable]
|
|
41
|
+
lines << "- selected: **#{run[:selected]}**" if run[:selected]
|
|
42
|
+
if (explain = run[:explain])
|
|
43
|
+
provs = explain[:provenance] || explain["provenance"] || {}
|
|
44
|
+
entries = provs[run[:selected]] || provs[run[:selected].to_s] || []
|
|
45
|
+
entries.each do |p|
|
|
46
|
+
evidence = (p[:evidence] || p["evidence"]).map { |e| e.join("=") }.join(", ")
|
|
47
|
+
lines << " - #{run[:selected]} <- #{p[:rule] || p["rule"]} (#{evidence})"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
Array(run[:outcomes]).each do |o|
|
|
51
|
+
verdict = o[:success] ? "success" : "failure"
|
|
52
|
+
lines << "- outcome: **#{verdict}** (#{o[:technique]})"
|
|
53
|
+
end
|
|
54
|
+
lines << ""
|
|
55
|
+
end
|
|
56
|
+
lines.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The defender view: the trail read from the BLUE side - the raw
|
|
60
|
+
# action timeline (what the operator did) plus, per attempted
|
|
61
|
+
# technique, the pack's detection: hints (the observables the
|
|
62
|
+
# defenses should have seen). The same JSONL feeds both sides.
|
|
63
|
+
def defender(trail, packs: [])
|
|
64
|
+
hints = Array(packs).flat_map do |p|
|
|
65
|
+
Array(p.detection).map { |d| [p.technique, d] }
|
|
66
|
+
end.group_by { |tech, _| tech }
|
|
67
|
+
|
|
68
|
+
lines = []
|
|
69
|
+
lines << "# Defender report (the trail read backwards)"
|
|
70
|
+
lines << ""
|
|
71
|
+
lines << "generated: #{Time.now.utc.iso8601}"
|
|
72
|
+
lines << ""
|
|
73
|
+
lines << "_Built from the same append-only trail the operator's report reads._"
|
|
74
|
+
lines << ""
|
|
75
|
+
|
|
76
|
+
lines << "## The action timeline"
|
|
77
|
+
lines << ""
|
|
78
|
+
trail.events.each do |event|
|
|
79
|
+
type = event["type"]
|
|
80
|
+
what = case type
|
|
81
|
+
when "scan" then "scanned #{event["target"]} (nmap)"
|
|
82
|
+
when "harvest" then "harvest the banner of #{event["target"]} - addresses read: #{event["addresses"].keys.join(", ")}"
|
|
83
|
+
when "verify" then "verified the remote faults like the local model: #{event["target"]}"
|
|
84
|
+
when "outcome" then "attempted #{event["technique"]} against #{event["target"]} (#{event["success"] ? "success" : "failed"})"
|
|
85
|
+
when "leak" then "captured leaks: #{event["addresses"].keys.join(", ")}"
|
|
86
|
+
when "fuzz" then "fuzzed #{event["binary"]}"
|
|
87
|
+
when "build" then "built the payload for #{event["technique"]} (offset #{event["offset"]})"
|
|
88
|
+
end
|
|
89
|
+
next unless what
|
|
90
|
+
|
|
91
|
+
lines << "- #{event["timestamp"]}: #{what}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
attempted = trail.events.select { |e| e["type"] == "outcome" }.map { |e| e["technique"] }.uniq
|
|
95
|
+
if attempted.empty?
|
|
96
|
+
return lines.join("
|
|
97
|
+
")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
lines << ""
|
|
101
|
+
lines << "## The detection hints for the attempted techniques"
|
|
102
|
+
lines << ""
|
|
103
|
+
attempted.each do |tech|
|
|
104
|
+
entries = hints[tech]
|
|
105
|
+
lines << "### #{tech}"
|
|
106
|
+
if entries
|
|
107
|
+
entries.each do |(_t, d)|
|
|
108
|
+
lines << "- #{d["channel"]}: #{d["signature"]} - #{d["note"]}"
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
lines << "- no detection hints (teach the pack: the detection: section)"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
lines.join("
|
|
115
|
+
")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def to_file(path, trail)
|
|
119
|
+
File.write(path, from_trail(trail))
|
|
120
|
+
path
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/lictor/run.rb
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "handoff"
|
|
6
|
+
require_relative "priors"
|
|
7
|
+
require_relative "trail"
|
|
8
|
+
|
|
9
|
+
module Lictor
|
|
10
|
+
# The closed loop, driven: decide (augure) -> execute the plan's next
|
|
11
|
+
# step (executor) -> outcome -> priors -> re-decide,
|
|
12
|
+
# until a shell, or the budget runs out. Every step is trailed. The
|
|
13
|
+
# handoff executor makes the whole loop safe by construction: it
|
|
14
|
+
# "executes" by printing.
|
|
15
|
+
class RunError < StandardError; end
|
|
16
|
+
|
|
17
|
+
module Run
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def run(target:, facts:, trail:, executor: nil, max_attempts: 3,
|
|
21
|
+
seed: nil, priors: nil, packs: nil, payload: nil, binary: nil, repo_dir: nil,
|
|
22
|
+
flag_regex: nil, manifest_path: nil, resume: nil, technique: nil)
|
|
23
|
+
executor ||= ManualExecutor.new
|
|
24
|
+
attempts = []
|
|
25
|
+
leaks = {}
|
|
26
|
+
scaffolded_techniques = []
|
|
27
|
+
|
|
28
|
+
# The run manifest (ADR 0003): one stable id per logical run, the
|
|
29
|
+
# state (facts with the merged leaks, the attempt history) written
|
|
30
|
+
# per attempt. --resume <id> continues THAT run - an interruption
|
|
31
|
+
# is a pause, not an orphan.
|
|
32
|
+
if resume
|
|
33
|
+
unless manifest_path && File.file?(manifest_path)
|
|
34
|
+
raise RunError,
|
|
35
|
+
"resume needs a manifest (run with manifest_path first)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
manifest = JSON.parse(File.read(manifest_path))
|
|
39
|
+
raise RunError, "the manifest holds run #{manifest["id"]}, not #{resume}" if manifest["id"] != resume
|
|
40
|
+
|
|
41
|
+
run_id = manifest["id"]
|
|
42
|
+
facts = manifest["facts"]
|
|
43
|
+
start = manifest["attempts"].size
|
|
44
|
+
else
|
|
45
|
+
run_id = SecureRandom.uuid
|
|
46
|
+
start = 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# per-attempt persistence: an interruption is a pause, not an
|
|
50
|
+
# orphan - the state (facts with the merged leaks, the attempt
|
|
51
|
+
# history) lands on disk after every event
|
|
52
|
+
write_manifest = lambda do |status|
|
|
53
|
+
next unless manifest_path
|
|
54
|
+
|
|
55
|
+
File.write(manifest_path,
|
|
56
|
+
JSON.generate(id: run_id, target: target, status: status,
|
|
57
|
+
attempts: attempts, facts: facts.to_s))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The static read feeds the decision: the sink the frame analysis
|
|
61
|
+
# found (strcpy/gets...) becomes the vuln_hint the engine derives
|
|
62
|
+
# from, the winning symbol's existence becomes the target fact.
|
|
63
|
+
# Measurement -> decision, in one trail.
|
|
64
|
+
if binary
|
|
65
|
+
begin
|
|
66
|
+
require "augure-profiler" # lazy: the enrichment loads metasm
|
|
67
|
+
static = AugureProfiler::StaticOffset.offset(binary)
|
|
68
|
+
# the static read is TRAILED: the measure -> decision chain is
|
|
69
|
+
# auditable, and a swallowed error class (a NameError hiding
|
|
70
|
+
# under a bare rescue) cannot hide - the event's absence is
|
|
71
|
+
# the test's red flag
|
|
72
|
+
trail.record(run: run_id, type: "measure", target: target,
|
|
73
|
+
sink: static[:sink], offset: static[:offset], source: "static-frame")
|
|
74
|
+
enriched = +facts.to_s
|
|
75
|
+
enriched << "vuln_hint(\"unsafe_func:#{static[:sink]}\").\n" if static[:sink]
|
|
76
|
+
enriched << "target_function(\"true\").\n" if facts.include?("win_symbol(") && static[:sink]
|
|
77
|
+
facts = enriched if enriched != facts
|
|
78
|
+
rescue Metasm::DecodeError, Metasm::ParseError, Metasm::InvalidExeFormat,
|
|
79
|
+
Errno::ENOENT, Errno::EACCES
|
|
80
|
+
# the static read is an enrichment, never a gate - the
|
|
81
|
+
# KNOWN failure modes only: a programming error (NameError,
|
|
82
|
+
# NoMethodError) must crash loudly, not vanish here
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
(start...max_attempts).each do
|
|
87
|
+
# technique: the pipeline's decided technique pinned across the
|
|
88
|
+
# attempts - the run's own bandit draw could pick a technique
|
|
89
|
+
# whose layout differs from the payload the build just produced
|
|
90
|
+
# (measured: the pilot's build built shellcode, the run's draw
|
|
91
|
+
# picked ret2libc, no exploit exists for it -> handoff x4)
|
|
92
|
+
decision = Handoff.plan(facts: facts, seed: seed, priors: Priors.from_trail(trail), packs: packs,
|
|
93
|
+
technique: technique)
|
|
94
|
+
technique = decision[:selected]
|
|
95
|
+
break if technique.nil?
|
|
96
|
+
|
|
97
|
+
result = executor.execute(target: target, technique: technique,
|
|
98
|
+
recipe: decision[:recipe], payload: payload, flag_regex: flag_regex)
|
|
99
|
+
# The leak capture: the service's own output hex addresses
|
|
100
|
+
# become leaked_address facts for the NEXT attempt (the
|
|
101
|
+
# two-steps: leak, then rebuild with the observed values). The
|
|
102
|
+
# facts the build layouts resolve as leak:NAME.
|
|
103
|
+
if result[:output].is_a?(String) && !result[:output].empty?
|
|
104
|
+
found = Harvest.parse_banner(result[:output]).reject { |name, _| leaks.key?(name) }
|
|
105
|
+
unless found.empty?
|
|
106
|
+
leaks.merge!(found)
|
|
107
|
+
facts = Augure::Facts.parse(facts.to_s + found.map { |name, addr|
|
|
108
|
+
"leaked_address(\"#{name}\", #{addr})."
|
|
109
|
+
}.join("\n") + "\n")
|
|
110
|
+
trail.record(run: run_id, type: "leak", target: target,
|
|
111
|
+
addresses: found.transform_values { |v| format("0x%x", v) })
|
|
112
|
+
write_manifest.call("running")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The repos have no exploit for the decided technique and the
|
|
117
|
+
# binary is in hand: scaffold it (measured constants), trail it,
|
|
118
|
+
# and the NEXT attempt discovers it. The operator still reviews
|
|
119
|
+
# the file - the loop does not invent craft, it drafts it.
|
|
120
|
+
scaffolded_now = false
|
|
121
|
+
if !result[:executed] && binary && repo_dir && !scaffolded_techniques.include?(technique)
|
|
122
|
+
require "augure-profiler" # lazy: the scaffold path loads metasm
|
|
123
|
+
build = PayloadBuilder.build(binary: binary, facts: facts,
|
|
124
|
+
technique: technique, out: File.join(repo_dir, "payload.bin"))
|
|
125
|
+
dest = ExploitScaffold.scaffold(technique: technique, constants: build,
|
|
126
|
+
repo_dir: repo_dir, author: "lictor run auto-scaffold")
|
|
127
|
+
trail.record(run: run_id, type: "scaffold", technique: technique,
|
|
128
|
+
offset: build[:offset], offset_source: build[:offset_source],
|
|
129
|
+
ret: build[:ret_source], file: dest)
|
|
130
|
+
scaffolded_now = true
|
|
131
|
+
scaffolded_techniques << technique
|
|
132
|
+
write_manifest.call("running")
|
|
133
|
+
end
|
|
134
|
+
attempt = {
|
|
135
|
+
technique: technique, executed: result[:executed],
|
|
136
|
+
success: result[:success], exploit: result[:exploit], handoff: result[:handoff],
|
|
137
|
+
flag: result[:flag],
|
|
138
|
+
# the runner's output: the oracle's search region AND the
|
|
139
|
+
# debugging record - without it a failed attempt is a black
|
|
140
|
+
# box (the flag_regex eats the only copy of the output)
|
|
141
|
+
output: result[:output]&.byteslice(0, 2000)
|
|
142
|
+
}
|
|
143
|
+
attempts << attempt
|
|
144
|
+
# The outcome feeds the priors ONLY when something actually ran:
|
|
145
|
+
# a handoff attempt (the safe default) never attempted anything,
|
|
146
|
+
# and recording it as a failure systematically downgrades the
|
|
147
|
+
# decided technique for every future decision.
|
|
148
|
+
if result[:executed]
|
|
149
|
+
trail.record(run: run_id, type: "outcome", technique: technique,
|
|
150
|
+
success: result[:success] == true, executed: true,
|
|
151
|
+
target: target)
|
|
152
|
+
end
|
|
153
|
+
break if result[:success] == true
|
|
154
|
+
|
|
155
|
+
# Failed execution: avoid repeating the same technique with the
|
|
156
|
+
# same evidence - the failed outcome feeds the priors next round.
|
|
157
|
+
# A handoff also stops: the recipe is identical, nothing was
|
|
158
|
+
# attempted. But a fresh auto-scaffold CHANGED the repos: the
|
|
159
|
+
# next attempt discovers it, so the loop continues.
|
|
160
|
+
break unless result[:executed] || scaffolded_now
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
status = if attempts.any? && attempts.last[:success] == true
|
|
164
|
+
"success"
|
|
165
|
+
elsif attempts.any? && attempts.none? { |a| a[:executed] }
|
|
166
|
+
"handoff"
|
|
167
|
+
else
|
|
168
|
+
"budget-exhausted"
|
|
169
|
+
end
|
|
170
|
+
write_manifest.call(status)
|
|
171
|
+
{status: status, steps: attempts, leaks: leaks, run_id: run_id}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "recon"
|
|
5
|
+
|
|
6
|
+
module Lictor
|
|
7
|
+
# Active scanning: nmap as a subprocess against a target,
|
|
8
|
+
# operator-scoped target. The scan is an ACTION - trailed like everything
|
|
9
|
+
# else, and it is trailed. Recon (XML parsing) stays passive.
|
|
10
|
+
class ScanError < StandardError; end
|
|
11
|
+
|
|
12
|
+
module Scanner
|
|
13
|
+
NMAP_ARGS = ["-sV", "-Pn"].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(target, out:, nmap_cmd: "nmap", extra_args: [])
|
|
18
|
+
require "timeout"
|
|
19
|
+
_, stderr, status = Timeout.timeout(600) do
|
|
20
|
+
Open3.capture3(nmap_cmd, *NMAP_ARGS, *extra_args, "-oX", out, target)
|
|
21
|
+
end
|
|
22
|
+
unless status.success?
|
|
23
|
+
reason = stderr.lines.first&.strip || "exit #{status.exitstatus}"
|
|
24
|
+
raise ScanError, "nmap failed on #{target}: #{reason} (is nmap installed?)"
|
|
25
|
+
end
|
|
26
|
+
Recon.from_nmap_xml(out)
|
|
27
|
+
rescue Errno::ENOENT
|
|
28
|
+
raise ScanError, "nmap binary not found: #{nmap_cmd} (is nmap installed?)"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "augure"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Lictor
|
|
7
|
+
# The article's inversion: the agent enriches the fact base before the
|
|
8
|
+
# reasoning, it never suggests verdicts. Suggestions pass the same
|
|
9
|
+
# closed schema as any facts file (no applicable() exists to forge),
|
|
10
|
+
# augure re-decides on the merged base, and the trail keeps who
|
|
11
|
+
# proposed what and what actually changed - auditable hypotheses.
|
|
12
|
+
module Suggestion
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# base_facts / suggested: fact text. Returns the delta payload:
|
|
16
|
+
# {facts:, added:, applicable_before:, applicable_after:,
|
|
17
|
+
# selected_before:, selected_after:} - and records a "suggest"
|
|
18
|
+
# trail event. seed keeps the before/after comparison deterministic.
|
|
19
|
+
def apply(base_facts:, suggested:, trail:, target:, seed: nil, packs: nil)
|
|
20
|
+
base = Augure::Facts.parse(base_facts)
|
|
21
|
+
suggested_facts = Augure::Facts.parse(suggested)
|
|
22
|
+
added = suggested_facts.to_a.reject { |rel, tuple| base[rel]&.include?(tuple) }
|
|
23
|
+
|
|
24
|
+
before = Handoff.plan(facts: base_facts, seed: seed, packs: packs)
|
|
25
|
+
merged = base.merge(suggested_facts)
|
|
26
|
+
after = Handoff.plan(facts: merged, seed: seed, packs: packs)
|
|
27
|
+
|
|
28
|
+
trail.record(run: SecureRandom.uuid, type: "suggest", target: target,
|
|
29
|
+
suggested: suggested_facts.to_a, added: added,
|
|
30
|
+
applicable_before: before[:applicable], applicable_after: after[:applicable],
|
|
31
|
+
selected_before: before[:selected], selected_after: after[:selected])
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
facts: merged, added: added,
|
|
35
|
+
applicable_before: before[:applicable], applicable_after: after[:applicable],
|
|
36
|
+
selected_before: before[:selected], selected_after: after[:selected]
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Lictor
|
|
7
|
+
# The engagement registry (ADR 0003): a named target owns a directory
|
|
8
|
+
# - facts, payloads, crash inputs, its own trail - and a line in
|
|
9
|
+
# targets.yml. Files a human can cat, no database. --target NAME
|
|
10
|
+
# scopes every command's state into the dir; the CWD-global files die.
|
|
11
|
+
module Targets
|
|
12
|
+
REGISTRY = "targets.yml"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def add(name, host_port, from: nil, bin: nil)
|
|
17
|
+
raise ArgumentError, "target name must match [a-z0-9_-]+" unless name.match?(/\A[a-z0-9_-]+\z/)
|
|
18
|
+
|
|
19
|
+
dir = File.join("targets", name)
|
|
20
|
+
FileUtils.mkdir_p(dir)
|
|
21
|
+
registry = load
|
|
22
|
+
registry[name] = {"host" => host_port}
|
|
23
|
+
registry[name]["from"] = from if from
|
|
24
|
+
registry[name]["bin"] = bin if bin
|
|
25
|
+
File.write(REGISTRY, registry.to_yaml)
|
|
26
|
+
{dir: dir, registry: registry[name]}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load
|
|
30
|
+
File.file?(REGISTRY) ? YAML.safe_load_file(REGISTRY, permitted_classes: [], aliases: false) || {} : {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resolve(name)
|
|
34
|
+
entry = load.fetch(name) { raise ArgumentError, "unknown target #{name.inspect} - lictor target add <name> <host:port>" }
|
|
35
|
+
{dir: File.join("targets", name), host: entry["host"], from: entry["from"], bin: entry["bin"]}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "augure"
|
|
4
|
+
require "augure/pack_loader"
|
|
5
|
+
|
|
6
|
+
module Lictor
|
|
7
|
+
# Loads the operator's taught techniques (data-only packs) for use in
|
|
8
|
+
# a decision. The armor runs at load time: the schema vocabulary, the
|
|
9
|
+
# corpus guard - a pack that would move a frozen verdict is refused
|
|
10
|
+
# here, before any decision sees it. No directory (or an empty one)
|
|
11
|
+
# means built-in techniques only.
|
|
12
|
+
module TechniquePacks
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def load(dir, guard: true)
|
|
16
|
+
return [] if dir.nil? || !File.directory?(dir)
|
|
17
|
+
|
|
18
|
+
packs = Augure::PackLoader.load_dir(dir)
|
|
19
|
+
Augure::PackLoader.corpus_guard(packs) if guard
|
|
20
|
+
packs
|
|
21
|
+
rescue Augure::PackError => e
|
|
22
|
+
raise Augure::PackError, "technique packs in #{dir} refused: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env -S ronin-exploits run -f
|
|
2
|
+
#
|
|
3
|
+
# Scaffolded by lictor for the <%= technique %> decision - REVIEW
|
|
4
|
+
# before committing: the constants below are measurements, the
|
|
5
|
+
# craft is yours.
|
|
6
|
+
|
|
7
|
+
require "ronin/exploits/exploit"
|
|
8
|
+
require "ronin/exploits/mixins/remote_tcp"
|
|
9
|
+
|
|
10
|
+
module Ronin
|
|
11
|
+
module Exploits
|
|
12
|
+
class <%= klass_name %> < Exploit
|
|
13
|
+
include Mixins::RemoteTCP
|
|
14
|
+
|
|
15
|
+
register "<%= register_name %>"
|
|
16
|
+
quality :poc
|
|
17
|
+
author "<%= author %>"
|
|
18
|
+
summary "<%= technique %> against the target lictor measured"
|
|
19
|
+
description <<~DESC
|
|
20
|
+
The offset was MEASURED (<%= offset_source %>): <%= offset %>.
|
|
21
|
+
The return address comes from the facts: <%= ret_source %>.
|
|
22
|
+
The payload is the built file, sent as-is.
|
|
23
|
+
DESC
|
|
24
|
+
|
|
25
|
+
param :payload_file, String, default: "<%= payload_path %>",
|
|
26
|
+
desc: "the payload lictor built"
|
|
27
|
+
|
|
28
|
+
def build
|
|
29
|
+
nil # the payload ships pre-built
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def launch
|
|
33
|
+
@socket = tcp_connect
|
|
34
|
+
print_info "sending the lictor-built payload (<%= size %> bytes)"
|
|
35
|
+
@socket.write(File.binread(params[:payload_file]))
|
|
36
|
+
reply = @socket.read(256) || ""
|
|
37
|
+
if reply.include?("FLAG:")
|
|
38
|
+
print_info reply[/FLAG: [^\n]+/]
|
|
39
|
+
else
|
|
40
|
+
raise "no flag in the reply - the measurements do not fit this target anymore"
|
|
41
|
+
end
|
|
42
|
+
ensure
|
|
43
|
+
@socket&.close
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|