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.
data/bin/lictor ADDED
@@ -0,0 +1,609 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Bootstrap the bundler context from the checkout: the CLI runs from any
5
+ # working directory (when not already inside `bundle exec`).
6
+ unless defined?(Bundler)
7
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
8
+ require "bundler/setup"
9
+ end
10
+
11
+ require "optparse"
12
+ require "tmpdir"
13
+ require "fileutils"
14
+ require "json"
15
+ require_relative "../lib/lictor"
16
+
17
+ # Config first (lictor.yml in CWD), flags override it.
18
+ options = Lictor::Config.load
19
+ parser = OptionParser.new do |opts|
20
+ opts.banner = <<~USAGE
21
+
22
+ Usage: lictor <command> [options]
23
+
24
+ Commands:
25
+ scan <host> run nmap against a target (trailed)
26
+ acquire <target> fetch the binary, profile it into target.facts
27
+ fuzz <binary> fuzz + triage a local binary (discovery layer)
28
+ verify <host:port> probe the remote with the crash input
29
+ harvest <host:port> parse the banner's hex leaks into facts
30
+ suggest <facts> "..." simulate a fact merge, print the delta (-o writes it)
31
+ plan <binary-or-facts> profile if ELF, decide, print the recipe
32
+ build <binary> measure the offset, emit the payload
33
+ run <target> the closed loop: decide, execute, outcome
34
+ outcome <run-id> record the result of a run
35
+ exploit scaffold <t> <bin> draft a ronin exploit from measured constants
36
+ draft <facts> auto-draft a technique pack into staging/
37
+ rule validate|install|list teach augure a technique (data-only packs)
38
+ runs / report the trail, rendered (--defender: the blue view)
39
+ target add|list named targets: a dir + the registry (ADR)
40
+ doctor / init verify the environment, scaffold the setup
41
+
42
+ Setup: lictor.yml in the working directory
43
+ (trail: runs.jsonl / nmap: / seed: / profile_cmd:). Flags override
44
+ the config. You are the operator: what this tool does, you did.
45
+ The trail is the record.
46
+ USAGE
47
+ opts.on("-s", "--seed N", Integer, "Seed for reproducible decisions") { |v| options[:seed] = v }
48
+ opts.on("-t", "--trail PATH", "JSONL trail (default: lictor-trail.jsonl)") { |v| options[:trail] = v }
49
+ opts.on("--nmap PATH", "Path to the nmap binary (default: nmap in PATH)") { |v| options[:nmap] = v }
50
+ opts.on("--profile-cmd PATH", "Path to augure-profile (default: augure-profile in PATH)") { |v| options[:profile_cmd] = v }
51
+ opts.on("--success", "Record a successful outcome (outcome only)") { options[:success] = true }
52
+ opts.on("--failure", "Record a failed outcome (outcome only)") { options[:success] = false }
53
+ opts.on("--technique NAME", "The technique the outcome applies to") { |v| options[:technique] = v }
54
+ opts.on("--packs DIR", "Technique packs directory (default: packs/)") { |v| options[:packs] = v }
55
+ opts.on("--max-inputs N", Integer, "Fuzz budget: mutated inputs to try (default 50)") { |v| options[:max_inputs] = v }
56
+ opts.on("--mutator NAME", "ruby (default) or radamsa if installed") { |v| options[:mutator] = v }
57
+ opts.on("--executor NAME", "manual (default) or ronin") { |v| options[:executor] = v }
58
+ opts.on("--technique NAME", "Override the technique the build implements (default: augure's pick)") { |v| options[:technique] = v }
59
+ opts.on("--payload FILE", "Send a pre-built payload (from `lictor build`) with run") { |v| options[:payload] = v }
60
+ opts.on("--shellcode-path PATH", "The flag path embedded in the shellcode stubs (build)") { |v| options[:shellcode_path] = v }
61
+ opts.on("--flag-regex RE", "Success oracle: the flag pattern in the runner's output (the exit code lies)") { |v| options[:flag_regex] = v }
62
+ opts.on("--resume RUN-ID", "Continue an interrupted run (the manifest carries the id)") { |v| options[:resume] = v }
63
+ opts.on("--payload-file FILE", "Custom shellcode/payload bytes for build") { |v| options[:payload_file] = v }
64
+ opts.on("--out FILE", "Output path for build (default payload.bin)") { |v| options[:out] = v }
65
+ opts.on("--repo-dir DIR", "Exploit scaffold destination directory (default: .)") { |v| options[:repo_dir] = v }
66
+ opts.on("--author NAME", "The scaffolded exploit's author line") { |v| options[:author] = v }
67
+ opts.on("--max-attempts N", Integer, "Run budget: attempts before giving up (default 3)") { |v| options[:max_attempts] = v }
68
+ opts.on("--facts PATH", "Facts file for run/plan (the profiler output)") { |v| options[:facts] = v }
69
+ opts.on("--target NAME", "Scope the state into targets/NAME (facts, payloads, trail)") { |v| options[:target] = v }
70
+ opts.on("--from URL", "Acquire the binary from an http(s) URL") { |v| options[:from] = v }
71
+ opts.on("--bin PATH", "Local binary: the acquire source, or the mirror for run auto-scaffold") { |v| options[:bin] = v }
72
+ opts.on("--input PATH", "Crash input for verify (the fuzzer's discovery)") { |v| options[:input] = v }
73
+ opts.on("--triage-backend NAME", "gdb (default), rizin or lldb") { |v| options[:triage_backend] = v }
74
+ opts.on("-o", "--output PATH", "Report output path, or the merged facts for suggest") { |v| options[:output] = v }
75
+ opts.on("-j", "--json", "Machine payload instead of human output") { options[:json] = true }
76
+ opts.on("--defender", "report: the blue-side view (the timeline + the packs' detection hints)") { options[:defender] = true }
77
+ opts.on("-h", "--help", "Show this help") { puts opts; exit 0 }
78
+ end
79
+ parser.parse!
80
+
81
+ HELP = {
82
+ "recon" => "recon <nmap.xml>\n Print service candidates from an nmap XML scan (passive).",
83
+ "scan" => "scan <host>\n Run nmap against a target (trailed).",
84
+ "plan" => "plan <binary-or-facts>\n Profile if ELF (augure-profile), decide, print the handoff recipe.",
85
+ "outcome" => "outcome <run-id> --success|--failure --technique NAME\n Record the result; the next plan adapts the priors.",
86
+ "runs" => "runs\n List the trailed runs.",
87
+ "report" => "report [--json] [--defender] [-o PATH]\n The operator's report, or (--defender) the blue-side view.",
88
+ "rule" => "rule validate|install|list <pack.yml>\n Teach augure a technique: data-only pack, validated + corpus-guarded.\n install is interactive and trailed.",
89
+ "init" => "init\n Scaffold lictor.yml and an example facts file.",
90
+ "doctor" => "doctor\n Verify the environment; print the exact fix per check.",
91
+ "target" => "target add <name> <host:port> [--from URL | --bin PATH] | target list\n A named target owns a dir: facts, payloads, its own trail. --target scopes the commands.",
92
+ "acquire" => "acquire <target> [--from URL | --bin PATH]\n Fetch (or copy) the binary, check it is an ELF, profile it into target.facts.",
93
+ "verify" => "verify <host:port> [--input PATH]\n Probe the remote with the crash input; a fault anchors verified(\"remote\").",
94
+ "fuzz" => "fuzz <binary> [--max-inputs N] [--mutator ruby|radamsa] [--triage-backend gdb]\n Discover crashes, triage the best one, trail both.",
95
+ "harvest" => "harvest <host:port> [--facts PATH]\n Parse the service banner's hex leaks into leaked_address facts.",
96
+ "suggest" => "suggest <facts-file> \"fact(\"atom\").\n...\" [-o PATH]\n Simulate the fact merge, print the decision delta; -o writes the merged base.",
97
+ "run" => "run <target> --facts PATH [--executor manual|ronin] [--payload FILE]\n The closed loop: decide, execute (or hand off), outcome, re-decide.",
98
+ "build" => "build <binary> [--facts PATH] [-o payload.bin]\n Measure the offset, resolve the addresses from the facts, emit the payload.",
99
+ "exploit" => "exploit scaffold <technique> <binary>\n Draft a ronin exploit from the measured constants into your repo.",
100
+ "draft" => "draft <facts> [--bin PATH]\n Auto-draft a technique pack from the facts into staging/."
101
+ }.freeze
102
+
103
+ if ARGV[0] == "help"
104
+ verb = ARGV[1]
105
+ abort("lictor: no such command '#{verb}' (see lictor --help)") unless HELP[verb]
106
+ puts HELP[verb]
107
+ exit 0
108
+ end
109
+
110
+ options[:technique] = nil unless options.key?(:technique)
111
+ options[:success] = nil unless options.key?(:success)
112
+ command = ARGV.shift or abort(parser.to_s)
113
+
114
+ when_state = if options[:target]
115
+ entry = Lictor::Targets.resolve(options[:target])
116
+ entry[:dir]
117
+ else
118
+ "."
119
+ end
120
+ options[:trail] ||= File.join(when_state, "trail.jsonl")
121
+ if options[:target]
122
+ options[:facts] ||= File.join(when_state, "target.facts")
123
+ options[:out] ||= File.join(when_state, "payload.bin")
124
+ end
125
+ trail = Lictor::Trail.new(options[:trail])
126
+
127
+ def scaffold_files
128
+ {
129
+ "lictor.yml" => <<~YML,
130
+ # lictor setup - flags override these values
131
+ trail: lictor-trail.jsonl
132
+ YML
133
+ }
134
+ end
135
+
136
+ case command
137
+ when "init"
138
+ scaffold_files.each do |name, content|
139
+ path = File.join(Dir.pwd, name)
140
+ if File.exist?(path)
141
+ abort("lictor: #{name} already exists - keep your setup, run `lictor doctor`")
142
+ end
143
+ end
144
+ scaffold_files.each do |name, content|
145
+ dir = File.dirname(File.join(Dir.pwd, name))
146
+ FileUtils.mkdir_p(dir)
147
+ File.write(File.join(Dir.pwd, name), content)
148
+ end
149
+ # The one example that proves the pipeline: straight from the corpus.
150
+ # Same resolution as the Gemfile: dev sibling or CI in-tree checkout.
151
+ FileUtils.mkdir_p("examples")
152
+ corpus_candidates = [
153
+ File.expand_path("../../augure/lib/augure/ctf_corpus.json", __dir__),
154
+ File.expand_path("../augure/lib/augure/ctf_corpus.json", __dir__)
155
+ ]
156
+ augure_corpus = corpus_candidates.find { |p| File.file?(p) }
157
+ if augure_corpus
158
+ entry = JSON.parse(File.read(augure_corpus))["suites"]["ropemporium"].find { |e| e["name"] == "ret2win" }
159
+ File.write("examples/ret2win.facts", entry["facts"]) if entry
160
+ end
161
+ scaffolded = ["lictor.yml"]
162
+ scaffolded << "examples/ret2win.facts" if File.file?("examples/ret2win.facts")
163
+ puts "scaffolded: #{scaffolded.join(", ")}"
164
+ puts
165
+ puts "next:"
166
+ puts " 1. lictor doctor # verify the environment"
167
+ puts " 2. lictor plan examples/ret2win.facts # your first decision"
168
+ when "target"
169
+ sub = ARGV.shift or abort(parser.to_s)
170
+ case sub
171
+ when "add"
172
+ name = ARGV.shift or abort(parser.to_s)
173
+ host = ARGV.shift or abort(parser.to_s)
174
+ info = Lictor::Targets.add(name, host, from: options[:from], bin: options[:bin])
175
+ puts "target: #{name} (#{info[:registry]["host"]})"
176
+ puts "dir: #{info[:dir]}/ (facts, payloads, trail - scoped)"
177
+ puts "registry: #{Lictor::Targets::REGISTRY}"
178
+ puts "next: lictor acquire #{name} --target #{name} --from <url>"
179
+ when "list"
180
+ Lictor::Targets.load.each { |name, entry| puts "#{name}\t#{entry["host"]}" }
181
+ else
182
+ abort("lictor: target add|list")
183
+ end
184
+ when "doctor"
185
+ checks = []
186
+ checks << ["ruby >= 3.4", RUBY_VERSION >= "3.4" ? nil : "upgrade ruby (mise install ruby@3.4)"]
187
+ nmap = options[:nmap] || "nmap"
188
+ nmap_found = system(nmap, "--version", out: File::NULL, err: File::NULL)
189
+ checks << ["nmap binary", nmap_found ? nil : "install nmap (apt install nmap) or set nmap: in lictor.yml"]
190
+ profiler = options[:profile_cmd] || Lictor::Profiler::DEFAULT_CMD
191
+ # Same candidate resolution as init's corpus: dev sibling or CI in-tree.
192
+ profiler_candidates = [
193
+ File.expand_path("../../augure/profiler/exe/augure-profile", __dir__),
194
+ File.expand_path("../augure/profiler/exe/augure-profile", __dir__)
195
+ ]
196
+ # Availability only: missing gems at runtime are ProfilerError's job.
197
+ profiler_found = [profiler, *profiler_candidates].find do |c|
198
+ system("sh", "-c", "command -v '#{c}' >/dev/null 2>&1")
199
+ end
200
+ checks << ["augure-profile",
201
+ profiler_found ? nil : "gem install augure-profiler (or set profile_cmd: in lictor.yml)"]
202
+ config_path = "lictor.yml"
203
+ checks << ["lictor.yml", File.file?(config_path) ? nil : "run `lictor init`"]
204
+ # optional tooling: warned, never failed - plan/report run without them
205
+ @optional = []
206
+ gdb_found = system("sh", "-c", "command -v gdb >/dev/null 2>&1")
207
+ @optional << ["gdb binary", gdb_found ? nil : "install gdb (apt install gdb) - the offset measurement, fuzz and triage need it"]
208
+ ronin_found = system("sh", "-c", "gem list -i ronin-exploits >/dev/null 2>&1")
209
+ @optional << ["ronin-exploits (run executor)", ronin_found ? nil : "gem install ronin-exploits - only the ronin executor needs it"]
210
+ docker_found = system("sh", "-c", "command -v docker >/dev/null 2>&1")
211
+ @optional << ["docker (the lab)", docker_found ? nil : "install docker to run the lab bench - optional outside the lab"]
212
+
213
+ trail_path = options[:trail] || Lictor::Config.load[:trail] || "lictor-trail.jsonl"
214
+ writable = begin
215
+ File.open(trail_path, "a") { |f| f.write("") }
216
+ true
217
+ rescue StandardError
218
+ false
219
+ end
220
+ checks << ["trail writable: #{trail_path}", writable ? nil : "check directory permissions"]
221
+
222
+ failed = false
223
+ checks.each do |name, problem|
224
+ if problem
225
+ failed = true
226
+ puts " FAIL #{name} - fix: #{problem}"
227
+ else
228
+ puts " ok #{name}"
229
+ end
230
+ end
231
+ @optional.each do |name, problem|
232
+ if problem
233
+ puts " warn #{name} - note: #{problem}"
234
+ else
235
+ puts " ok #{name}"
236
+ end
237
+ end
238
+ if failed
239
+ n = checks.count { |_, p| p }
240
+ abort("lictor: doctor found #{n} issue(s) - fixes above (fresh setup? run lictor init)")
241
+ end
242
+ when "recon"
243
+ xml = ARGV.shift or abort(parser.to_s)
244
+ puts Lictor::Recon.service_candidates(xml)
245
+ when "rule"
246
+ sub = ARGV.shift or abort(parser.to_s)
247
+ packs_dir = options[:packs] || Lictor::Config.load[:packs] || "packs"
248
+ case sub
249
+ when "list"
250
+ Dir.glob(File.join(packs_dir, "*.{yml,yaml}")).sort.each do |p|
251
+ pack = Augure::PackLoader.load_file(p)
252
+ puts "#{pack.technique} by #{pack.author} (#{p})"
253
+ end
254
+ when "validate", "install"
255
+ path = ARGV.shift or abort("lictor: rule #{sub} <pack.yml>")
256
+ begin
257
+ pack = Augure::PackLoader.load_file(path)
258
+ Augure::PackLoader.corpus_guard([pack])
259
+ rescue Augure::PackError => e
260
+ abort("lictor: pack refused - #{e.message}")
261
+ end
262
+ puts "pack: #{pack.technique}"
263
+ puts "author: #{pack.author}"
264
+ puts "source: #{pack.source}"
265
+ puts "corpus: all 34 documented verdicts intact"
266
+ if sub == "validate"
267
+ puts "verdict: valid (dry-run; `lictor rule install #{path}` to teach augure)"
268
+ else
269
+ # Interactive confirmation: friction with information. Type the
270
+ # technique name - a flag can be typed by reflex, this cannot.
271
+ print "type the technique name to confirm: "
272
+ confirm = $stdin.gets.to_s.strip
273
+ abort("lictor: confirmation mismatch - nothing installed") unless confirm == pack.technique
274
+ FileUtils.mkdir_p(packs_dir)
275
+ installed = File.join(packs_dir, File.basename(path))
276
+ FileUtils.cp(path, installed)
277
+ trail.record(run: SecureRandom.uuid, type: "rule_install", technique: pack.technique,
278
+ author: pack.author, source: pack.source, pack_file: installed)
279
+ puts "installed: #{installed} (trailed)"
280
+ puts "next plan run will consider #{pack.technique}"
281
+ end
282
+ else
283
+ abort("lictor: rule validate|install|list")
284
+ end
285
+ when "verify"
286
+ # The differential: the fuzzer's crash input goes to the REMOTE
287
+ # service. If the remote faults like the local model, the model is
288
+ # remote-verified (the facts carry the confidence level).
289
+ target = ARGV.shift or abort(parser.to_s)
290
+ abort("lictor: verify needs --input <crash-input-file> (lictor fuzz discovers it)") unless options[:input]
291
+ # Remote crash detection: deliver the input, then read until the
292
+ # connection dies. A crashed child dies IN vuln_copy: the normal path
293
+ # prints "hello" first, the crash path does not. A reset = death too.
294
+ # The socket work is the Tube's (ADR lictor 0001).
295
+ host, port = target.split(":", 2)
296
+ begin
297
+ tube = Lictor::Tube.tcp(host, port, timeout: 5)
298
+ tube.gets("\n") # the greeting, if any
299
+ tube.write(File.binread(options[:input]))
300
+ reply = tube.read_bytes(limit: 4096)
301
+ reset_seen = tube.reset?
302
+ tube.close
303
+ rescue Lictor::TubeError => e
304
+ reset_seen = false
305
+ reply = ""
306
+ puts " (the connection itself failed: #{e.message})"
307
+ end
308
+ crashed = (reset_seen || reply.empty?) && !reply.include?("hello")
309
+ trail.record(run: SecureRandom.uuid, type: "verify", target: target,
310
+ input: options[:input], remote_crashed: crashed)
311
+ if crashed
312
+ puts "remote-verified: the remote service faulted on the same input -"
313
+ puts " the local model (offsets, gadgets) is representative. The facts"
314
+ puts " gained verified(\"remote\")."
315
+ facts_path = options[:facts] || "target.facts"
316
+ File.open(facts_path, "a") { |f| f.puts 'verified("remote").' }
317
+ else
318
+ puts "not verified: the remote survived - the local model may not apply"
319
+ puts " (version drift? a different build?). Do not trust the offsets."
320
+ end
321
+ when "fuzz"
322
+ # Fuzzing is an ACTION on a local binary: TRAILED, and its discoveries
323
+ # feed the plan.
324
+ binary = ARGV.shift or abort(parser.to_s)
325
+ begin
326
+ require "augure-profiler"
327
+ binary = File.expand_path(binary)
328
+ rescue LoadError
329
+ abort("lictor: fuzzing needs the augure-profiler gem (gem install augure-profiler)")
330
+ end
331
+ crashes = AugureProfiler::Fuzzer.run(binary, inputs: options[:max_inputs] || 50,
332
+ mutator: (options[:mutator] || :ruby).to_sym)
333
+ trail.record(run: SecureRandom.uuid, type: "fuzz", binary: binary,
334
+ inputs: options[:max_inputs] || 50, crashes: crashes.size)
335
+ if crashes.empty?
336
+ puts "fuzzed: #{binary} - no crash (nothing to triage)"
337
+ else
338
+ puts "fuzzed: #{binary} - #{crashes.size} crash(es)"
339
+ # The LONGEST crash input is the most robust across environments
340
+ # (it smashes past any offset): the shortest may be a marginal,
341
+ # layout-dependent crash - exactly what `verify` is there to catch.
342
+ robust = crashes.max_by { |c| c[:input].bytesize }
343
+ crash_file = File.join(Dir.pwd, "crash-input.bin")
344
+ File.binwrite(crash_file, robust[:input])
345
+ puts "saved: #{crash_file} (the longest crash input - robust for verify)"
346
+ triage = AugureProfiler::Triage.run(binary, robust[:input])
347
+ trail.record(run: SecureRandom.uuid, type: "triage", binary: binary,
348
+ signal: triage[:signal], control: triage[:control].to_s)
349
+ puts "triage: #{triage[:signal]} control=#{triage[:control]}"
350
+ facts_text = AugureProfiler::Triage.facts(triage)
351
+ puts facts_text
352
+ puts "next: lictor plan <binary> (facts above are decidable)"
353
+ end
354
+ when "run"
355
+ # The closed loop, opt-in: decide -> execute -> outcome -> re-decide.
356
+ target = ARGV.shift or abort(parser.to_s)
357
+ abort("lictor: run needs --facts <facts-file> (scan/profile first)") unless options[:facts]
358
+ executor_kind = (options[:executor] || :manual).to_sym
359
+
360
+ result = Lictor::Run.run(target: target, facts: File.read(options[:facts]), trail: trail,
361
+ executor: Lictor::Executor.resolve(executor_kind),
362
+ max_attempts: options[:max_attempts] || 3,
363
+ packs: Lictor::TechniquePacks.load(options[:packs] || Lictor::Config.load[:packs] || "packs"),
364
+ payload: options[:payload],
365
+ seed: options[:seed],
366
+ binary: options[:bin],
367
+ repo_dir: options[:repo_dir],
368
+ flag_regex: options[:flag_regex],
369
+ manifest_path: File.join(when_state, "run.json"),
370
+ resume: options[:resume],
371
+ technique: options[:technique])
372
+ puts "run: #{result[:run_id]} (interrupted? resume: lictor run ... --resume #{result[:run_id]})"
373
+ puts "status: #{result[:status]}"
374
+ result[:steps].each do |s|
375
+ executed = s[:executed] ? "executed" : "handoff"
376
+ ok = s[:success] ? "success" : (s[:success].nil? ? "-" : "failed")
377
+ puts " #{s[:technique]} [#{executed}/#{ok}]"
378
+ end
379
+ # The flag in the output is the proof the loop actually closed - the
380
+ # exit code lies, the flag does not.
381
+ flag = result[:steps].filter_map { |s| s[:flag] }.first
382
+ puts "flag: #{flag}" if flag
383
+ when "acquire"
384
+ # The nmap -> binary bridge: local copy or http download, ELF-checked,
385
+ # profiled, trailed.
386
+ target = ARGV.shift or abort(parser.to_s)
387
+ # the registry carries the source: --from/--bin override, the
388
+ # target's recorded `from`/`bin` is the fallback (the engagement
389
+ # flow: one `target add` and the acquire needs no flags)
390
+ source = options[:from] || (options[:bin] && "local:#{options[:bin]}")
391
+ source ||= begin
392
+ entry = Lictor::Targets.resolve(target)
393
+ (entry[:from] || (entry[:bin] && "local:#{entry[:bin]}"))
394
+ rescue ArgumentError
395
+ nil
396
+ end
397
+ source or abort("lictor: acquire needs --from <url> or --bin <path> (or a target add --from/--bin)")
398
+ result = Lictor::Acquisition.acquire(target, source: source, trail: trail,
399
+ out_dir: when_state,
400
+ profiler_cmd: options[:profile_cmd])
401
+ puts "acquired: #{result[:binary_path]}"
402
+ puts "facts: #{result[:facts_path]} (#{result[:facts].lines.size} facts)"
403
+ puts "next: lictor plan target.facts"
404
+ when "scan"
405
+ # A scan is an ACTION, trailed.
406
+ target = ARGV.shift or abort(parser.to_s)
407
+ out_xml = File.join(Dir.mktmpdir("lictor_scan"), "scan.xml")
408
+ begin
409
+ targets = Lictor::Scanner.run(target, out: out_xml, nmap_cmd: options[:nmap] || "nmap")
410
+ rescue Lictor::ScanError => e
411
+ abort("lictor: #{e.message}")
412
+ end
413
+ trail.record(run: SecureRandom.uuid, type: "scan", target: target,
414
+ nmap_args: [options[:nmap] || "nmap", *Lictor::Scanner::NMAP_ARGS])
415
+ puts "scanned: #{target}"
416
+ targets.each do |t|
417
+ puts "#{t[:host]}:#{t[:port]} #{t[:service]}#{t[:product] ? " (#{t[:product]} #{t[:version]})" : ''}"
418
+ end
419
+ when "suggest"
420
+ facts_path = ARGV.shift or abort(parser.to_s)
421
+ suggested = ARGV.shift or abort("lictor: suggest <facts-file> \"fact(\\\"atom\\\").\\n...\" - the suggestion text")
422
+ begin
423
+ result = Lictor::Suggestion.apply(base_facts: File.read(facts_path), suggested: suggested,
424
+ trail: trail, target: facts_path, seed: options[:seed],
425
+ packs: Lictor::TechniquePacks.load(options[:packs] || Lictor::Config.load[:packs] || "packs"))
426
+ rescue Augure::MalformedFact, Augure::UnknownPredicate => e
427
+ abort("lictor: #{e.message}")
428
+ end
429
+ puts "added: #{result[:added].inspect}"
430
+ puts "applicable: #{result[:applicable_before].join(', ')} -> #{result[:applicable_after].join(', ')}"
431
+ puts "selected: #{result[:selected_before].inspect} -> #{result[:selected_after].inspect}"
432
+ if options[:output]
433
+ File.write(options[:output], result[:facts].to_s)
434
+ puts "written: #{options[:output]} (the merged base - the enrichment is not a dry run)"
435
+ end
436
+ when "harvest"
437
+ target = ARGV.shift or abort(parser.to_s)
438
+ begin
439
+ result = Lictor::Harvest.harvest(target, trail: trail,
440
+ timeout: options[:timeout] || 5, read_bytes: 512)
441
+ puts "leaked: #{result[:addresses].map { |k, v| "#{k}=#{format('0x%x', v)}" }.join(', ')}"
442
+ if options[:facts]
443
+ File.open(options[:facts], "a") { |f| f.write(result[:facts]) }
444
+ puts "appended: #{options[:facts]} (leaked_address facts, observed-live)"
445
+ else
446
+ puts "facts: merge them with lictor suggest or pass --facts PATH to append"
447
+ end
448
+ rescue Lictor::Harvest::HarvestError => e
449
+ abort("lictor: #{e.message}")
450
+ end
451
+ when "exploit"
452
+ sub = ARGV.shift
453
+ abort("lictor: exploit <scaffold> ... - only scaffold is implemented") unless sub == "scaffold"
454
+ technique = ARGV.shift or abort(parser.to_s)
455
+ abort("lictor: the technique must match [a-z0-9_]+ (it becomes a filename and lands in generated code)") unless technique =~ /\A[a-z0-9_]+\z/
456
+ binary = ARGV.shift or abort(parser.to_s)
457
+ begin
458
+ facts_path = options[:facts] ||
459
+ abort("lictor: exploit scaffold needs --facts <facts-file>")
460
+ result = Lictor::PayloadBuilder.build(binary: binary, facts: File.read(facts_path),
461
+ technique: technique, out: options[:out] || "payload.bin",
462
+ payload_file: options[:payload_file])
463
+ repo_dir = File.expand_path(options[:repo_dir] || ".")
464
+ FileUtils.mkdir_p(repo_dir)
465
+ dest = Lictor::ExploitScaffold.scaffold(technique: technique, constants: result,
466
+ repo_dir: repo_dir, author: options[:author] || "lictor scaffold")
467
+ trail.record(run: SecureRandom.uuid, type: "scaffold", technique: technique,
468
+ offset: result[:offset], offset_source: result[:offset_source],
469
+ ret: result[:ret_source], file: dest)
470
+ puts "scaffolded: #{dest}"
471
+ puts "review it, commit it, then ronin-exploits finds it by name"
472
+ rescue Lictor::PayloadBuilder::BuildError, Lictor::ExploitScaffold::ScaffoldError => e
473
+ abort("lictor: #{e.message}")
474
+ end
475
+ when "draft"
476
+ facts_path = ARGV.shift or abort(parser.to_s)
477
+ binary = ARGV.shift
478
+ begin
479
+ require "augure-profiler" # lazy: metasm loads on the draft path only
480
+ measurements = {}
481
+ if binary && File.file?(binary)
482
+ static = AugureProfiler::StaticOffset.offset(binary) rescue nil
483
+ measurements[:sink] = static&.dig(:sink)
484
+ measurements[:offset] = static&.dig(:offset)
485
+ measurements[:offset_source] = static&.dig(:offset) ? "static-frame" : nil
486
+ end
487
+ measurements[:run_id] = options[:run_id] || "manual"
488
+ staging = options[:repo_dir] || "staging"
489
+ result = Lictor::PackDraft.draft(facts: File.read(facts_path),
490
+ measurements: measurements, staging_dir: File.expand_path(staging))
491
+ abort("lictor: the built-in techniques already cover these facts - no draft needed") if result.nil?
492
+ trail.record(run: SecureRandom.uuid, type: "pack_draft", technique: result[:technique],
493
+ status: result[:status], file: result[:path])
494
+ puts "draft: #{result[:path]} (#{result[:technique]})"
495
+ puts "armor: #{result[:status]}"
496
+ puts "staging: review it, then lictor rule install once it is yours"
497
+ rescue Lictor::PackDraft::DraftError => e
498
+ abort("lictor: #{e.message}")
499
+ end
500
+ when "build"
501
+ input = ARGV.shift or abort(parser.to_s)
502
+ facts_path = options[:facts]
503
+ unless facts_path
504
+ abort("lictor: build needs --facts <facts-file> (scan/profile first)") unless File.file?("target.facts")
505
+ facts_path = "target.facts"
506
+ end
507
+ begin
508
+ packs = Lictor::TechniquePacks.load(options[:packs] || Lictor::Config.load[:packs] || "packs")
509
+ decision = Lictor::Handoff.plan(facts: File.read(facts_path), seed: options[:seed],
510
+ priors: Lictor::Priors.from_trail(trail), packs: packs)
511
+ technique = options[:technique] || decision[:selected] or
512
+ abort("lictor: build needs a selected technique - plan decided nothing")
513
+ result = Lictor::PayloadBuilder.build(binary: input, facts: File.read(facts_path),
514
+ shellcode_path: options[:shellcode_path],
515
+ technique: technique, out: options[:out] || "payload.bin",
516
+ payload_file: options[:payload_file],
517
+ packs: packs)
518
+ trail.record(run: SecureRandom.uuid, type: "build", technique: technique,
519
+ offset: result[:offset], ret: result[:ret_source], arch: result[:arch].to_s,
520
+ size: result[:size], out: result[:path])
521
+ puts "technique: #{technique}"
522
+ puts "offset: #{result[:offset]} (measured: cyclic pattern under the debugger)"
523
+ puts "ret: #{result[:ret_source]}"
524
+ puts "payload: #{result[:path]} (#{result[:size]} bytes, arch #{result[:arch]})"
525
+ rescue Lictor::PayloadBuilder::BuildError => e
526
+ abort("lictor: #{e.message}")
527
+ rescue Augure::MalformedFact, Augure::UnknownPredicate => e
528
+ abort("lictor: #{e.message}")
529
+ end
530
+ when "plan"
531
+ # --target: the facts default to the target's dir (the ARGV input
532
+ # stays for the direct path)
533
+ input = ARGV.shift || options[:facts] or abort(parser.to_s)
534
+ mode = "handoff (prints a recipe; executes nothing)"
535
+
536
+ begin
537
+ if Lictor::Profiler.elf?(input)
538
+ facts = Lictor::Profiler.run(input, cmd: options[:profile_cmd] || Lictor::Profiler::DEFAULT_CMD)
539
+ trail.record(run: SecureRandom.uuid, type: "profile", binary: input)
540
+ else
541
+ facts = File.read(input)
542
+ end
543
+ priors = Lictor::Priors.from_trail(trail)
544
+ packs = Lictor::TechniquePacks.load(options[:packs] || Lictor::Config.load[:packs] || "packs")
545
+ plan = Lictor::Handoff.plan(facts: facts, seed: options[:seed], priors: priors, packs: packs)
546
+ rescue Lictor::ProfilerError => e
547
+ abort("lictor: #{e.message}")
548
+ rescue Augure::MalformedFact, Augure::UnknownPredicate => e
549
+ abort("lictor: #{e.message}")
550
+ end
551
+ trail.record(run: SecureRandom.uuid, type: "plan", mode: mode, facts_file: input,
552
+ profiled: Lictor::Profiler.elf?(input) ? input : nil,
553
+ selected: plan[:selected], applicable: plan[:applicable],
554
+ explain: plan[:explain],
555
+ priors_adapted: Lictor::Priors.adapted?(trail))
556
+
557
+ if options[:json]
558
+ puts JSON.generate(plan)
559
+ else
560
+ puts "mode: #{mode}"
561
+ puts "applicable: #{plan[:applicable].join(', ')}"
562
+ puts "ranking: #{plan[:ranking].join(' > ')}"
563
+ puts "selected: #{plan[:selected]}"
564
+ if plan[:recipe]
565
+ puts "recipe: #{plan[:recipe][:console]}"
566
+ end
567
+ plan[:explain][:provenance].each do |tech, provs|
568
+ provs.each do |p|
569
+ puts " #{tech} <- #{p[:rule]} (#{p[:evidence].map { |e| e.join('=') }.join(', ')})"
570
+ end
571
+ end
572
+ end
573
+ when "outcome"
574
+ run_id = ARGV.shift or abort(parser.to_s)
575
+ if options[:success].nil? || options[:technique].nil?
576
+ abort("lictor: outcome needs --success or --failure, and --technique NAME")
577
+ end
578
+ trail.record(run: run_id, type: "outcome", technique: options[:technique],
579
+ success: options[:success])
580
+ puts "recorded: #{run_id} #{options[:technique]} #{options[:success] ? 'success' : 'failure'}"
581
+ puts "next plan run adapts the priors (the closed loop)"
582
+ when "runs"
583
+ runs = trail.runs
584
+ abort("lictor: empty trail") if runs.empty?
585
+ runs.each do |r|
586
+ puts "#{r[:run]} #{r[:mode]} selected=#{r[:selected]} " \
587
+ "outcomes=#{r[:outcomes].map { |o| "#{o[:technique]}:#{o[:success] ? 'ok' : 'ko'}" }.join(',')}"
588
+ end
589
+ when "report"
590
+ if options[:defender]
591
+ packs = Lictor::TechniquePacks.load(options[:packs] || Lictor::Config.load[:packs] || "packs")
592
+ content = Lictor::Report.defender(trail, packs: packs)
593
+ if options[:output]
594
+ File.write(options[:output], content)
595
+ puts "wrote #{options[:output]}"
596
+ else
597
+ puts content
598
+ end
599
+ elsif options[:json]
600
+ puts JSON.generate(runs: trail.runs)
601
+ elsif options[:output]
602
+ Lictor::Report.to_file(options[:output], trail)
603
+ puts "wrote #{options[:output]}"
604
+ else
605
+ puts Lictor::Report.from_trail(trail)
606
+ end
607
+ else
608
+ abort(parser.to_s)
609
+ end
@@ -0,0 +1,67 @@
1
+ # The build layouts: how a decided technique becomes bytes. Data, not
2
+ # code - a pack can add or override a layout for its own techniques
3
+ # (pack `build:` sections follow the same slot vocabulary and pass the
4
+ # same armor).
5
+ #
6
+ # Slots:
7
+ # [padding, measured_offset] - the measured offset, as filler
8
+ # [qword, win] - the win_symbol address, 8 bytes
9
+ # [qword, gadget:TYPE] - the address of that gadget type
10
+ # [dword, ...] - the same, 4 bytes (x86)
11
+ # [shellcode, sh|bash] - the assembled stub
12
+ # [shellcode, file] - the operator-provided payload file
13
+ # [chain, REF, REF, ...] - a ROP chain: one arch-width word
14
+ # per REF, resolved in order
15
+ #
16
+ # chain references:
17
+ # gadget:TYPE - the address of that gadget type (facts)
18
+ # plt_addr:NAME - the PLT stub address (profiler facts)
19
+ # got_addr:NAME - the GOT entry address (profiler facts)
20
+ # win - the win_symbol address
21
+ # leak:NAME - a leaked_address(name) fact (harvest/banner)
22
+ # const:N - the literal N (a syscall number, an offset)
23
+ #
24
+ # resolved at build time from the facts + the measurement; anything the
25
+ # facts cannot supply is a loud BuildError, never a guessed byte.
26
+
27
+ ret2func:
28
+ arch: any
29
+ layout:
30
+ - [padding, measured_offset]
31
+ - [qword, win]
32
+
33
+ ret2win:
34
+ arch: any
35
+ layout:
36
+ - [padding, measured_offset]
37
+ - [qword, win]
38
+
39
+ shellcode:
40
+ arch: any
41
+ layout:
42
+ - [padding, measured_offset, sled]
43
+ - [qword, leak:buf]
44
+ - [shellcode, sh]
45
+
46
+ banner_leak:
47
+ arch: any
48
+ layout:
49
+ - [padding, measured_offset]
50
+ - [qword, leak:win]
51
+ ret2plt:
52
+ arch: any
53
+ layout:
54
+ - [padding, measured_offset]
55
+ - [chain, "gadget:pop_rdi_ret", "got_addr:puts", "plt_addr:puts"]
56
+
57
+ ret2libc:
58
+ arch: any
59
+ layout:
60
+ - [padding, measured_offset]
61
+ - [chain, "gadget:ret_align", "gadget:pop_rdi_ret", "leak:binsh", "leak:system"]
62
+ orw:
63
+ arch: any
64
+ layout:
65
+ - [padding, measured_offset, sled]
66
+ - [qword, leak:input]
67
+ - [shellcode, orw, flag.txt]