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.
@@ -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
@@ -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