vivarium 0.1.2 → 0.3.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 +4 -4
- data/CONTEXT.md +535 -0
- data/README.md +56 -7
- data/examples/execve_demo.rb +49 -0
- data/examples/file_operation_demo.rb +68 -0
- data/examples/privilege_event_demo.rb +38 -0
- data/examples/raise_demo.rb +42 -0
- data/examples/signal_kill_demo.rb +38 -0
- data/examples/sudo_attempt_demo.rb +18 -0
- data/exe/vivarium +6 -0
- data/image.png +0 -0
- data/lib/vivarium/cli.rb +40 -0
- data/lib/vivarium/correlator.rb +137 -0
- data/lib/vivarium/tree_renderer.rb +543 -0
- data/lib/vivarium/version.rb +1 -1
- data/lib/vivarium.rb +985 -157
- data/logo-simple.png +0 -0
- data/sig/vivarium.rbs +17 -0
- metadata +46 -5
- data/lib/vivarium/logger.rb +0 -68
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "vivarium"
|
|
6
|
+
|
|
7
|
+
# Usage:
|
|
8
|
+
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
9
|
+
# 2) Run this script: bundle exec ruby examples/execve_demo.rb
|
|
10
|
+
|
|
11
|
+
TMP_PREFIX = "vivarium-exec-demo"
|
|
12
|
+
|
|
13
|
+
def try_step(title)
|
|
14
|
+
puts "[exec-demo] #{title}"
|
|
15
|
+
yield
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
puts "[exec-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
|
|
21
|
+
output_path = File.join(dir, "execve-demo.out")
|
|
22
|
+
|
|
23
|
+
Vivarium.observe do
|
|
24
|
+
try_step("system echo with multiple args") do
|
|
25
|
+
system("/bin/echo", "hello", "from", "vivarium", out: File::NULL)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
try_step("spawn env with explicit argv") do
|
|
29
|
+
pid = Process.spawn(
|
|
30
|
+
"/usr/bin/env",
|
|
31
|
+
"env",
|
|
32
|
+
"printf",
|
|
33
|
+
"execve-demo\n",
|
|
34
|
+
out: output_path,
|
|
35
|
+
err: File::NULL
|
|
36
|
+
)
|
|
37
|
+
Process.wait(pid)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
try_step("spawn sleep with flag") do
|
|
41
|
+
pid = Process.spawn("/bin/sleep", "0")
|
|
42
|
+
Process.wait(pid)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts "[exec-demo] output file: #{output_path}" if File.exist?(output_path)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
puts "[exec-demo] done"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "vivarium"
|
|
7
|
+
|
|
8
|
+
# Usage:
|
|
9
|
+
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
10
|
+
# 2) Run this script: bundle exec ruby examples/file_operation_demo.rb
|
|
11
|
+
|
|
12
|
+
TMP_PREFIX = "vivarium-file-demo"
|
|
13
|
+
|
|
14
|
+
def try_step(title)
|
|
15
|
+
puts "[file-demo] #{title}"
|
|
16
|
+
yield
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
puts "[file-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Dir.mktmpdir(TMP_PREFIX, "/tmp") do |dir|
|
|
22
|
+
source_path = File.join(dir, "source.txt")
|
|
23
|
+
renamed_path = File.join(dir, "renamed.txt")
|
|
24
|
+
hardlink_path = File.join(dir, "hardlink.txt")
|
|
25
|
+
symlink_path = File.join(dir, "symlink.txt")
|
|
26
|
+
|
|
27
|
+
Vivarium.observe do
|
|
28
|
+
try_step("create source file") do
|
|
29
|
+
File.write(source_path, "vivarium sample\n")
|
|
30
|
+
File.read(source_path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
try_step("directory listing") do
|
|
34
|
+
Dir.children(dir)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
try_step("rename file") do
|
|
38
|
+
File.rename(source_path, renamed_path)
|
|
39
|
+
File.read(renamed_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
try_step("create hardlink") do
|
|
43
|
+
File.link(renamed_path, hardlink_path)
|
|
44
|
+
File.read(hardlink_path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
try_step("create symlink") do
|
|
48
|
+
File.symlink(renamed_path, symlink_path)
|
|
49
|
+
File.read(symlink_path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
try_step("chmod file") do
|
|
53
|
+
File.chmod(0o640, renamed_path)
|
|
54
|
+
File.stat(renamed_path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
try_step("list directory again") do
|
|
58
|
+
Dir.children(dir)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
FileUtils.rm_f(symlink_path)
|
|
63
|
+
FileUtils.rm_f(hardlink_path)
|
|
64
|
+
FileUtils.rm_f(renamed_path)
|
|
65
|
+
FileUtils.rm_f(source_path)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts "[file-demo] done"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "vivarium"
|
|
5
|
+
|
|
6
|
+
# Usage:
|
|
7
|
+
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
8
|
+
# 2) Run this script: bundle exec ruby examples/privilege_event_demo.rb
|
|
9
|
+
|
|
10
|
+
def try_step(title)
|
|
11
|
+
puts "[priv-demo] #{title}"
|
|
12
|
+
yield
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
puts "[priv-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Vivarium.observe do
|
|
18
|
+
try_step("attempt setuid(0)") do
|
|
19
|
+
Process::UID.change_privilege(0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
try_step("attempt setgid(0)") do
|
|
23
|
+
Process::GID.change_privilege(0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
try_step("attempt opening /etc/shadow") do
|
|
27
|
+
File.read("/etc/shadow")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
try_step("exec setuid-related binary") do
|
|
31
|
+
pid = Process.spawn("/usr/bin/sudo", "-n", "true", out: File::NULL, err: File::NULL)
|
|
32
|
+
Process.wait(pid)
|
|
33
|
+
rescue Errno::ENOENT
|
|
34
|
+
puts "[priv-demo] sudo not found; skipped"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
puts "[priv-demo] done"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "vivarium"
|
|
5
|
+
|
|
6
|
+
def try_step(title)
|
|
7
|
+
puts "[priv-demo] #{title}"
|
|
8
|
+
yield
|
|
9
|
+
rescue StandardError => e
|
|
10
|
+
puts "[priv-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Vivarium.observe do
|
|
14
|
+
try_step("raise in main") do
|
|
15
|
+
raise "error in main"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
try_step("raise in eval") do
|
|
19
|
+
eval("raise 'error in eval'")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
try_step("raise in nested eval") do
|
|
23
|
+
eval(<<~RUBY)
|
|
24
|
+
eval(<<~INNER_RUBY)
|
|
25
|
+
begin
|
|
26
|
+
eval(<<~INNER_INNER_RUBY)
|
|
27
|
+
puts "Hi"
|
|
28
|
+
raise "error in nested nested eval"
|
|
29
|
+
INNER_INNER_RUBY
|
|
30
|
+
rescue StandardError => _
|
|
31
|
+
puts "Rescued in nested eval"
|
|
32
|
+
end
|
|
33
|
+
File.open("/etc/hosts")
|
|
34
|
+
INNER_RUBY
|
|
35
|
+
RUBY
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
try_step("raise in method") do
|
|
40
|
+
File.open("notfound")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "vivarium"
|
|
5
|
+
|
|
6
|
+
# Usage:
|
|
7
|
+
# 1) In another shell (root): sudo bundle exec vivariumd
|
|
8
|
+
# 2) Run this script: bundle exec ruby examples/signal_kill_demo.rb
|
|
9
|
+
|
|
10
|
+
def try_step(title)
|
|
11
|
+
puts "[signal-demo] #{title}"
|
|
12
|
+
yield
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
puts "[signal-demo] #{title} failed: #{e.class}: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
child_pid = nil
|
|
18
|
+
|
|
19
|
+
Vivarium.observe do
|
|
20
|
+
try_step("fork child process") do
|
|
21
|
+
child_pid = fork do
|
|
22
|
+
trap("TERM") { exit!(0) }
|
|
23
|
+
loop { sleep 1 }
|
|
24
|
+
end
|
|
25
|
+
puts "[signal-demo] child pid=#{child_pid}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
try_step("send TERM signal to child") do
|
|
29
|
+
sleep 0.1
|
|
30
|
+
Process.kill("TERM", child_pid)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
try_step("wait child process") do
|
|
34
|
+
Process.wait(child_pid)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
puts "[signal-demo] done"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
def debug_output(msg)
|
|
4
|
+
$stderr.puts("[DEBUG] #{msg}") if ENV["VIVARIUM_DEBUG"]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
debug_output "=== sudo attempt demo ==="
|
|
8
|
+
|
|
9
|
+
debug_output "[1] Attempting: sudo id"
|
|
10
|
+
system("sudo", "-n", "id")
|
|
11
|
+
|
|
12
|
+
debug_output "[2] Attempting: sudo cat /etc/shadow"
|
|
13
|
+
system("sudo", "-n", "cat", "/etc/shadow")
|
|
14
|
+
|
|
15
|
+
debug_output "[3] Attempting: sudo cat /proc/1/environ"
|
|
16
|
+
system("sudo", "-n", "cat", "/proc/1/environ")
|
|
17
|
+
|
|
18
|
+
debug_output "=== done ==="
|
data/exe/vivarium
ADDED
data/image.png
ADDED
|
Binary file
|
data/lib/vivarium/cli.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Vivarium
|
|
6
|
+
module CLI
|
|
7
|
+
def self.run!(argv = ARGV)
|
|
8
|
+
options = { pin_dir: Vivarium.bpf_pin_dir, dest: $stdout }
|
|
9
|
+
parser = OptionParser.new do |opts|
|
|
10
|
+
opts.banner = "Usage: vivarium [options] <command> [args]"
|
|
11
|
+
opts.separator ""
|
|
12
|
+
opts.separator "Commands:"
|
|
13
|
+
opts.separator " load <script> Load and observe a Ruby script"
|
|
14
|
+
opts.separator ""
|
|
15
|
+
opts.separator "Options:"
|
|
16
|
+
opts.on("--pin-dir PATH", "Pinned map directory") { |v| options[:pin_dir] = v }
|
|
17
|
+
opts.on("-o", "--output PATH", "Log output file (default: stdout)") { |v| options[:dest] = File.open(v, "a") }
|
|
18
|
+
end
|
|
19
|
+
parser.order!(argv)
|
|
20
|
+
|
|
21
|
+
command = argv.shift
|
|
22
|
+
case command
|
|
23
|
+
when "load"
|
|
24
|
+
run_load!(argv, options)
|
|
25
|
+
else
|
|
26
|
+
abort parser.help
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.run_load!(argv, options)
|
|
31
|
+
script = argv.shift
|
|
32
|
+
abort "Usage: vivarium load <script>" unless script
|
|
33
|
+
abort "File not found: #{script}" unless File.exist?(script)
|
|
34
|
+
|
|
35
|
+
Vivarium.observe(pin_dir: options[:pin_dir], dest: options[:dest]) do
|
|
36
|
+
Kernel.load(File.expand_path(script))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbbcc"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Vivarium
|
|
7
|
+
class Correlator
|
|
8
|
+
RawEvent = Struct.new(
|
|
9
|
+
:ktime_ns, :pid, :tid, :event_name, :payload,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
EVENT_C_TYPE = <<~C
|
|
14
|
+
struct event_t {
|
|
15
|
+
u64 ktime_ns;
|
|
16
|
+
u32 pid;
|
|
17
|
+
u32 tid;
|
|
18
|
+
char event_name[16];
|
|
19
|
+
char payload[256];
|
|
20
|
+
};
|
|
21
|
+
C
|
|
22
|
+
|
|
23
|
+
POLL_TIMEOUT_MS = 200
|
|
24
|
+
|
|
25
|
+
def initialize(pin_dir:, observer_pid:, main_tid:, method_id_queue:, dest: $stdout)
|
|
26
|
+
@pin_dir = pin_dir
|
|
27
|
+
@observer_pid = observer_pid
|
|
28
|
+
@main_tid = main_tid
|
|
29
|
+
@method_id_queue = method_id_queue
|
|
30
|
+
@dest = dest
|
|
31
|
+
|
|
32
|
+
@events = []
|
|
33
|
+
@events_mutex = Mutex.new
|
|
34
|
+
@method_table = {}
|
|
35
|
+
@stop_flag = false
|
|
36
|
+
@started = false
|
|
37
|
+
|
|
38
|
+
@ringbuf = RbBCC::RingBuf.from_pin(
|
|
39
|
+
File.join(@pin_dir, "events"),
|
|
40
|
+
EVENT_C_TYPE,
|
|
41
|
+
Vivarium::EVENTS_RINGBUF_PAGES
|
|
42
|
+
)
|
|
43
|
+
@ringbuf.open_ring_buffer do |_ctx, data, size|
|
|
44
|
+
capture_event(data, size)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def start
|
|
49
|
+
return if @started
|
|
50
|
+
|
|
51
|
+
@session_start_iso = Time.now.utc.iso8601(3)
|
|
52
|
+
@session_start_ktime = Vivarium.monotonic_ktime_ns
|
|
53
|
+
@thread = Thread.new { run }
|
|
54
|
+
@started = true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stop
|
|
58
|
+
return unless @started
|
|
59
|
+
return if @stopped
|
|
60
|
+
|
|
61
|
+
@stop_flag = true
|
|
62
|
+
@thread&.join(POLL_TIMEOUT_MS * 4 / 1000.0 + 1)
|
|
63
|
+
@session_stop_iso = Time.now.utc.iso8601(3)
|
|
64
|
+
@session_stop_ktime = Vivarium.monotonic_ktime_ns
|
|
65
|
+
|
|
66
|
+
3.times { safe_poll(50) }
|
|
67
|
+
drain_method_id_queue
|
|
68
|
+
|
|
69
|
+
events_snapshot = @events_mutex.synchronize { @events.dup }
|
|
70
|
+
method_table_snapshot = @method_table.dup
|
|
71
|
+
@stopped = true
|
|
72
|
+
|
|
73
|
+
TreeRenderer.new(
|
|
74
|
+
events: events_snapshot,
|
|
75
|
+
method_table: method_table_snapshot,
|
|
76
|
+
observer_pid: @observer_pid,
|
|
77
|
+
main_tid: @main_tid,
|
|
78
|
+
session_start_iso: @session_start_iso,
|
|
79
|
+
session_start_ktime: @session_start_ktime,
|
|
80
|
+
session_stop_iso: @session_stop_iso,
|
|
81
|
+
session_stop_ktime: @session_stop_ktime,
|
|
82
|
+
dest: @dest
|
|
83
|
+
).render
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def run
|
|
89
|
+
until @stop_flag
|
|
90
|
+
safe_poll(POLL_TIMEOUT_MS)
|
|
91
|
+
drain_method_id_queue
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def safe_poll(timeout_ms)
|
|
96
|
+
@ringbuf.ring_buffer_poll(timeout_ms)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
warn "[vivarium correlator] poll error: #{e.class}: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def capture_event(data, size)
|
|
102
|
+
bytes = data[0, size].to_s.b
|
|
103
|
+
bytes = bytes.ljust(Vivarium::EVENT_STRUCT_SIZE, "\x00") if bytes.bytesize < Vivarium::EVENT_STRUCT_SIZE
|
|
104
|
+
|
|
105
|
+
ktime_ns = bytes[Vivarium::EVENT_TS_OFFSET, Vivarium::EVENT_TS_SIZE].unpack1("Q<")
|
|
106
|
+
pid = bytes[Vivarium::EVENT_PID_OFFSET, 4].unpack1("L<")
|
|
107
|
+
tid = bytes[Vivarium::EVENT_TID_OFFSET, 4].unpack1("L<")
|
|
108
|
+
event_name = Vivarium.c_string(bytes[Vivarium::EVENT_NAME_OFFSET, Vivarium::EVENT_NAME_SIZE])
|
|
109
|
+
payload = bytes[Vivarium::EVENT_PAYLOAD_OFFSET, Vivarium::EVENT_PAYLOAD_SIZE].to_s.b
|
|
110
|
+
|
|
111
|
+
@events_mutex.synchronize do
|
|
112
|
+
@events << RawEvent.new(
|
|
113
|
+
ktime_ns: ktime_ns,
|
|
114
|
+
pid: pid,
|
|
115
|
+
tid: tid,
|
|
116
|
+
event_name: event_name,
|
|
117
|
+
payload: payload
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
warn "[vivarium correlator] capture error: #{e.class}: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def drain_method_id_queue
|
|
125
|
+
loop do
|
|
126
|
+
msg = begin
|
|
127
|
+
@method_id_queue.pop(true)
|
|
128
|
+
rescue ThreadError
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
method_id, signature = msg
|
|
133
|
+
@method_table[method_id] = signature
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|