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.
@@ -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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vivarium"
5
+
6
+ Vivarium::CLI.run!
data/image.png ADDED
Binary file
@@ -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