kunoichi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/bin/kunoichi ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'kunoichi'
4
+
5
+ # Hash for commandline flags that override configuration
6
+ @override_config = Hash.new
7
+
8
+ # Default configuration placement
9
+ @conf_files = [ 'config.yml', '/etc/kunoichi/config.yml' ]
10
+
11
+ begin
12
+ opts = GetoptLong.new(
13
+ [ '--config-file', '-c', GetoptLong::REQUIRED_ARGUMENT ],
14
+ [ '--help', '-h', GetoptLong::NO_ARGUMENT ],
15
+ [ '--dry_run', '-n', GetoptLong::NO_ARGUMENT ],
16
+ [ '--import', '-i', GetoptLong::REQUIRED_ARGUMENT ],
17
+ [ '--generate-config', '-g', GetoptLong::OPTIONAL_ARGUMENT ],
18
+ [ '--debug', '-d', GetoptLong::NO_ARGUMENT ],
19
+ [ '--pid-file', '-p', GetoptLong::REQUIRED_ARGUMENT ]
20
+ )
21
+ rescue => e
22
+ puts e.message
23
+ exit
24
+ end
25
+
26
+ begin
27
+ opts.each do |opt, arg|
28
+ case opt
29
+ when '--help'
30
+ help
31
+ when '--config-file'
32
+ @conf_files = [ arg ]
33
+ when '--dry-run'
34
+ @override_config['no_kill'] = true
35
+ @override_config['no_kill_ppid'] = true
36
+ when '--debug'
37
+ @override_config['daemon'] = false
38
+ @override_config['syslog'] = false
39
+ @override_config['logfile'] = false
40
+ @override_config['debug'] = true
41
+ when '--pid-file'
42
+ @override_config['pidfile'] = arg
43
+ when '--import'
44
+ puts import_conf arg
45
+ exit
46
+ when '--generate-config'
47
+ if arg.empty?
48
+ generate_conf 'config.yml'
49
+ else
50
+ generate_conf arg
51
+ end
52
+ exit
53
+ end
54
+ end
55
+ rescue
56
+ exit
57
+ end
58
+
59
+ # Attempt to include each configuration file
60
+ @conf_files.each do |file|
61
+ next unless File.readable? file
62
+ include_conf file
63
+ break
64
+ end
65
+
66
+ # If we're done attempting ad have no @config it means none could be loaded
67
+ unless @config.is_a? Hash
68
+ puts 'Could not find any configuration files. Tried to load: ' + @conf_files.to_s
69
+ exit
70
+ end
71
+
72
+ # Handle commandline overrides in configuration
73
+ [ 'debug', 'no_kill', 'no_kill_ppid', 'daemon', 'syslog', 'logfile', 'pidfile' ].each do |flag|
74
+ unless @override_config[flag].nil?
75
+ @config[flag] = @override_config[flag]
76
+ end
77
+ end
78
+
79
+ # Start kunoichi
80
+ begin
81
+ Kunoichi::Daemon.new @config
82
+ rescue => e
83
+ puts 'Cannot start kunoichi: ' + e.message
84
+ puts e.backtrace if @config['debug']
85
+ end
@@ -0,0 +1,9 @@
1
+
2
+ # Syntax sugar
3
+
4
+ class Object
5
+ # Provide is_bool? to all objects
6
+ def is_bool?
7
+ [ true, false ].include? self
8
+ end
9
+ end
data/lib/kunoichi.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'set'
2
+ require 'etc'
3
+ require 'yaml'
4
+ require 'logger'
5
+ require 'syslog-logger'
6
+ require 'getoptlong'
7
+
8
+ module Kunoichi
9
+ DEFAULTS = {
10
+ 'group' => 0,
11
+ 'daemon' => true,
12
+ 'syslog' => true,
13
+ 'interval' => 1,
14
+ 'logfile' => '/var/log/kunoichi.log',
15
+ 'pidfile' => '/var/run/kunoichi.pid',
16
+ 'whitelist' => '/etc/kunoichi/whitelist.txt',
17
+ 'external_command' => '/bin/true',
18
+ 'no_kill' => true,
19
+ 'no_kill_ppid' => true,
20
+ 'ignore_root_procs' => true,
21
+ 'log_whitelist' => true,
22
+ 'require_init_wlist' => false,
23
+ 'proc_scan_offset' => 0,
24
+ }
25
+
26
+ CONFIGURATION_HEADER = '## Kunoichi configuration file.
27
+ # Same options as ninja.
28
+ # Check the documentation to know what each option means.
29
+ # Documentation can be found on the project home page on github,
30
+ # or in README.md bundled with Kunoichi.
31
+ '
32
+
33
+ end
34
+
35
+ require 'extensions/object'
36
+ require 'kunoichi/proctable'
37
+ require 'kunoichi/kunoichi'
38
+ require 'kunoichi/cli'
@@ -0,0 +1,99 @@
1
+ # Show help
2
+ def help
3
+ puts "Usage: #{$0} [options]"
4
+ puts "Options:"
5
+ puts "\t-h|--help\t\tThis help"
6
+ puts "\t-d|--debug\t\tDon't run in daemon mode"
7
+ puts "\t-n|--dry-run\t\tPerform no actions"
8
+ puts "\t-c|--config-file\tConfiguration file path.\n\t\t\t\t(defaults: config.rb, /etc/kunoichi/config.rb)"
9
+ puts "\t-i|--import\t\tImport configuration from ninja and writes to stdout."
10
+ puts "\t-g|--generate-config\tGenerate sample configuration file"
11
+ puts "\t-p|--pid-file\t\tPid file path"
12
+ exit
13
+ end
14
+
15
+ # Try to include yaml configuration
16
+ def include_conf(file)
17
+ begin
18
+ raise 'empty file' unless @config = YAML.load_file(file)
19
+ rescue => e
20
+ puts "Cannot load configuration: #{e.message}"
21
+ exit
22
+ end
23
+ end
24
+
25
+ # Parse a ninja configuration file and return kunoichi yaml configuration
26
+ def import_conf(file)
27
+
28
+ # Try to open the file
29
+ begin
30
+ ninja = File.read(file)
31
+ rescue => e
32
+ puts 'Cannot open ninja configuration file: ' + e.message
33
+ exit
34
+ end
35
+
36
+ # Initialize parsed old configuration and new configuration hashes
37
+ oldconf = {}
38
+ newconf = {}
39
+
40
+ # Transform ninja configuration, comments excluded, in a ruby hash
41
+ ninja.lines.grep(/^[^#\n].* = /).each do |x|
42
+ key=x.split('=')[0].strip!
43
+ value=x.split('=')[1].strip!
44
+ oldconf[key] = value
45
+ end
46
+
47
+ # For each of the values that kunoichi understands
48
+ Kunoichi::DEFAULTS.keys.each do |x|
49
+ # If it was not declared, return the default value
50
+ if oldconf[x].nil? then
51
+ newconf[x] = Kunoichi::DEFAULTS[x]
52
+ else
53
+ # If there was the setting, turn yes/no/(null) into booleans
54
+ case oldconf[x]
55
+ when 'yes'
56
+ newconf[x] = true
57
+ when 'no', '(null)'
58
+ newconf[x] = false
59
+ else
60
+ # Since all values are strings, turn to integer those that require it
61
+ if [ 'group', 'interval', 'proc_scan_offset' ].include? x
62
+ newconf[x] = oldconf[x].to_i
63
+ else
64
+ # Save the old value
65
+ newconf[x] = oldconf[x]
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ # Return the newly filled hash in yaml format, preceded by some useful comments.
72
+ return Kunoichi::CONFIGURATION_HEADER + newconf.to_yaml
73
+ end
74
+
75
+ # Generate a configuration file, starting from defaults
76
+ def generate_conf(file)
77
+ # Take care not to overwrite the configuration by mistake
78
+ if File.exist? file
79
+ puts "File #{file} already existing."
80
+ return
81
+ end
82
+ begin
83
+ conf = File.open(file,'w+')
84
+ rescue => e
85
+ puts "Error creating file: #{e.message}"
86
+ return
87
+ end
88
+ begin
89
+ # Write some useful comments, and the defaults dump in yaml
90
+ conf.write Kunoichi::CONFIGURATION_HEADER +
91
+ Kunoichi::DEFAULTS.to_yaml
92
+ rescue => e
93
+ puts "Error writing to file: #{e.message}"
94
+ return
95
+ end
96
+ puts "Sample configuration written to #{file}."
97
+ conf.close
98
+ exit
99
+ end
@@ -0,0 +1,300 @@
1
+ module Kunoichi
2
+ # The main class, where all the dirty work happens
3
+ class Daemon
4
+
5
+ # Basic validator for configuration
6
+ def validate_conf(config)
7
+
8
+ # Base check, might be redundant
9
+ raise 'invalid configuration passed: not an hash' unless config.is_a? Hash
10
+
11
+ # Check for group validity
12
+ raise 'invalid group: not a number' unless config['group'].is_a? Fixnum
13
+ # Make sure the group exists on /etc/group
14
+ begin
15
+ Etc::getgrgid config['group']
16
+ rescue => e
17
+ raise "invalid group: #{e.message}"
18
+ end
19
+
20
+ # Check for daemon validity
21
+ raise 'invalid daemon setting: please set to true or false' unless config['daemon'].is_bool?
22
+
23
+ # Check interval
24
+ raise 'invalid interval: please set to a number' unless config['interval'].is_a? Fixnum or config['interval'].is_a? Float
25
+ # Useful warning in case no interval was declared
26
+ puts 'WARNING: an interval of 0 can severely impact your machine performance. Use with caution.' if config['interval'] == 0
27
+
28
+ # Check syslog
29
+ raise 'invalid value for syslog: please set to true or false' unless config['syslog'].is_bool?
30
+
31
+ # Check logfile
32
+ raise 'set either syslog or logfile if daemon mode is enabled' unless config['syslog'] or config['logfile'].is_a? String or !config['daemon']
33
+ raise 'invalid logfile setting: not writable' unless !config['logfile'] or File.writable? config['logfile'] or File.writable? File.dirname(config['logfile'])
34
+
35
+ # Check whitelist
36
+ raise 'invalid whitelist: set to a file path, or false to disable' unless config['whitelist'] == false or config['whitelist'].is_a? String
37
+ raise "invalid whitelist: cannot open #{config['whitelist']}" unless config['whitelist'] == false or File.readable?(config['whitelist'])
38
+
39
+ # Check external_command
40
+ raise 'invalid external_command: set to a file path, or false to disable' unless config['external_command'].is_a? String or config['external_command'] == false
41
+ raise 'invalid external_command: not executable' unless !config['external_command'] or File.executable?(config['external_command'].split[0])
42
+
43
+ # Check no_kill
44
+ raise 'invalid no_kill: set to true or false' unless config['no_kill'].is_bool?
45
+
46
+ # Check no_kill_ppid
47
+ raise 'invalid no_kill_ppid: set to true or false' unless config['no_kill_ppid'].is_bool?
48
+
49
+ # Check ignore_root_procs
50
+ raise 'invalid ignore_root_procs: set to true or false' unless config['ignore_root_procs'].is_bool?
51
+
52
+ # Check log_whitelist
53
+ raise 'invalid log_whitelist: set to true or false' unless config['log_whitelist'].is_bool?
54
+
55
+ # Check require_init_wlist
56
+ raise 'invalid require_init_wlist: set to true or false' unless config['require_init_wlist'].is_bool?
57
+
58
+ # Check proc_scan_offset
59
+ raise 'invalid proc_scan_offset: set to a number' unless config['proc_scan_offset'].is_a? Fixnum
60
+ raise "invalid proc_scan_offset: set lower than #{@max_pids}" unless config['proc_scan_offset'] < @max_pids
61
+
62
+ # Check pidfile
63
+ raise 'invalid pidfile setting: set to a string' unless config['pidfile'].is_a? String
64
+ raise 'invalid pidfile setting: empty' if config['pidfile'].empty?
65
+ raise 'invalid pidfile setting: not writable' unless File.writable? config['pidfile'] or File.writable? File.dirname(config['pidfile'])
66
+
67
+ # Return the configuration if no errors encountered
68
+ config
69
+ end
70
+
71
+ # Exit cleanly by logging our departure and deleting the pid file if we received a signal
72
+ def clean_exit(sig)
73
+ @log.info "Got SIG#{sig}, exiting."
74
+ File.unlink(@config['pidfile']) if @config['daemon'] and @config['pidfile']
75
+ exit
76
+ end
77
+
78
+ # Fetch the current running processes and put them into a ruby set
79
+ # TODO: make this multi platform
80
+ def get_process_list
81
+ # Initialize the set
82
+ entries = Set.new
83
+ # Look up all pids
84
+ Dir.glob('/proc/[0-9]*') { |x|
85
+ # Turn pids to numbers
86
+ pid = File.basename(x).to_i
87
+ # Formal check for unexpected files on /proc starting with a digit
88
+ next unless pid.to_s =~ /^[0-9]+$/
89
+ # Skip processes above the configured offset
90
+ if pid > @config['proc_scan_offset']
91
+ entries.add pid
92
+ end
93
+ }
94
+
95
+ return entries
96
+ end
97
+
98
+ # Load the whitelist
99
+ def load_whitelist(file)
100
+ # Open the file and read all words from lines that don't start with a #
101
+ begin
102
+ @whitelist = File.read(file).lines.grep(/^[^#]/).join.split
103
+ rescue => e
104
+ puts "Cannot load whitelist: #{e.message}"
105
+ exit
106
+ end
107
+ # Warn the user of the whitelist was loaded but found empty
108
+ if @whitelist.empty?
109
+ puts 'WARNING: Empty whitelist loaded.'
110
+ end
111
+ end
112
+
113
+ # Run the external command
114
+ def run_command(process,parent)
115
+
116
+ # Log our action
117
+ @log.info "Running external command: #{@config['external_command']}."
118
+
119
+ # Prepare the environment with some useful informations
120
+ ENV['EVIL_PID'] = process.pid.to_s
121
+ ENV['EVIL_CMD'] = process.cmdline
122
+ ENV['EVIL_NAME'] = process.name
123
+ ENV['EVIL_BIN'] = process.binary
124
+
125
+ ENV['EVIL_PPID'] = parent.pid.to_s
126
+ ENV['EVIL_PCMD'] = parent.cmdline
127
+ ENV['EVIL_PNAME'] = parent.name
128
+ ENV['EVIL_PBIN'] = parent.binary
129
+
130
+ # Launch the command
131
+ system(@config['external_command'])
132
+ end
133
+
134
+ # Daemon startup procedure
135
+ def initialize(config)
136
+ # Check max pids first
137
+ if RUBY_PLATFORM.downcase =~ /linux/
138
+ begin
139
+ @max_pids = File.read('/proc/sys/kernel/pid_max').to_i
140
+ rescue
141
+ raise 'Cannot read /proc/sys/kernel/pid_max (is /proc mounted?)'
142
+ end
143
+ else
144
+ # Default on newer FreeBSD. Solaris should have a cap of 30000.
145
+ # Makes only sense once get_process_list becomes multi-platform, but nice to have.
146
+ @max_pids = 99999
147
+ end
148
+
149
+ # Validate configuration and store if valid
150
+ @config = validate_conf config
151
+
152
+ # Save the full path to the pid file in @config
153
+ @config['pidfile'] = File.expand_path(@config['pidfile'])
154
+
155
+ # Get initial process list at startup
156
+ @initial_procs = get_process_list
157
+ raise 'No processes found (is /proc mounted?)' if @initial_procs.empty?
158
+
159
+ # Load the whitelist
160
+ load_whitelist @config['whitelist'] if @config['whitelist']
161
+
162
+ # Initialize @log as configured
163
+ if @config['syslog']
164
+ @log = Logger::Syslog.new('kunoichi', Syslog::LOG_DAEMON)
165
+ else
166
+ if @config['daemon']
167
+ @log = Logger.new(@config['logfile'])
168
+ else
169
+ @log = Logger.new(STDOUT)
170
+ end
171
+ end
172
+
173
+ # Enable debug info if running in debug mode, keep it quiet otherwise
174
+ if @config['debug']
175
+ @log.level = Logger::DEBUG
176
+ else
177
+ @log.level = Logger::INFO
178
+ end
179
+
180
+ @log.info 'Kunoichi starting up'
181
+
182
+ # Daemonize if we're configured for it
183
+ if @config['daemon']
184
+ Process.daemon
185
+ # Only write the pid file if we're running as daemon
186
+ if @config['pidfile']
187
+ begin
188
+ File.open(@config['pidfile'], 'w+') { |x| x.write Process.pid.to_s }
189
+ rescue => e
190
+ @log.error e.message
191
+ end
192
+ end
193
+ end
194
+
195
+ # Catch SIGINT (^C) and SIGTERM (default kill)
196
+ [ 'INT', 'TERM' ].each do |signal|
197
+ Signal.trap signal do clean_exit signal end
198
+ end
199
+
200
+ # Finally, enter the main loop
201
+ loop do
202
+ main_loop
203
+ sleep @config['interval']
204
+ end
205
+ end
206
+
207
+ # Main loop
208
+ def main_loop
209
+ @log.debug 'Checking processes:'
210
+ # Get running processes
211
+ procs = get_process_list
212
+ # Extract the new processes since last loop cycle
213
+ new_procs = procs - @initial_procs
214
+
215
+ # Skip the run if there are new processes
216
+ (@log.debug "\tNo new processes found."; return) if new_procs.empty?
217
+
218
+ @log.debug "\tNew processes:"
219
+
220
+ # For each new process:
221
+ new_procs.each do |pid|
222
+ # Extract information
223
+ begin
224
+ process = ProcEntry.new(pid)
225
+ rescue
226
+ # Skip if the process died before we could extract the info
227
+ @log.debug "\t\t#{pid} - Disappeared before analysis"
228
+ next
229
+ end
230
+ @log.debug "\t\t#{pid} - #{process.cmdline}"
231
+
232
+ # If the process runs as root
233
+ if process.uid == 0
234
+
235
+ # Attempt to identify the parent
236
+ begin
237
+ parent = ProcEntry.new(process.parent)
238
+ rescue => e
239
+ @log.warn "\t\tParent process of #{process.name} disappeared (#{e.message})."
240
+ next
241
+ end
242
+
243
+ # Skip if the parent has the whitelisted gid
244
+ next if parent.gid == @config['group']
245
+
246
+ # If the parent is root
247
+ if parent.uid == 0
248
+ # ..and we've chosen to ignore root spawned processes
249
+ if @config['ignore_root_procs'] or parent.gid == @config['group']
250
+ # if the parent is init
251
+ if parent.pid == 1
252
+ # spare the process unless configured otherwise
253
+ unless @config['require_init_wlist']
254
+ next
255
+ end
256
+ # if the parent is not init everything is normal, just skip this one
257
+ else
258
+ next
259
+ end
260
+ end
261
+ else
262
+ # Also skip if the parent is not root, but is in the magic gid
263
+ next if parent.gid == @config['group']
264
+ end
265
+
266
+ # Log our finding
267
+ @log.info "\t\tFound offending process: #{pid} - #{process.cmdline}"
268
+
269
+ # Skip if the executable is whitelisted
270
+ if @config['whitelist'] and @whitelist.include? process.binary
271
+ @log.info "\t\t\tAllowed(#{process.binary})." if @config['log_whitelist']
272
+ next
273
+ end
274
+
275
+ # If we got this far it means the process deserves our attention
276
+
277
+ # Terminate the process unless configured otherwise
278
+ unless @config['no_kill']
279
+ @log.info "\t\t\tTerminating."
280
+ Process.kill(:KILL,pid)
281
+ else
282
+ @log.info "\t\t\tNot killing."
283
+ end
284
+
285
+ # Also kill the parent unless configured otherwise (or the parent is init)
286
+ unless process.parent == 1 or @config['no_kill_ppid']
287
+ @log.info "\t\t\tKilling parent too."
288
+ Process.kill(:KILL,process.parent)
289
+ end
290
+
291
+ # Run the external command in a subprocess
292
+ fork { run_command(process,parent) } if @config['external_command']
293
+ end
294
+ end
295
+
296
+ # Save the current process list for the next run
297
+ @initial_procs = procs
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,23 @@
1
+ # Accessory class to access informations about a process
2
+ class ProcEntry
3
+
4
+ attr_accessor :pid, :cmdline, :name, :binary, :uid, :gid, :parent
5
+
6
+ # Fetch all the values when the object is created
7
+ def initialize(pid)
8
+ raise 'invalid pid: not a number' unless pid.is_a? Fixnum
9
+ pids = pid.to_s
10
+ raise 'invalid pid: not running' unless File.directory? "/proc/#{pids}"
11
+ begin
12
+ @pid = pid
13
+ @cmdline = File.read("/proc/#{pids}/cmdline").gsub("\0", ' ')
14
+ @name = @cmdline.split[0]
15
+ @uid = File.stat("/proc/#{pids}/").uid
16
+ @gid = File.stat("/proc/#{pids}/").gid
17
+ @binary = File.readlink("/proc/#{pids}/exe")
18
+ @parent = File.readlines("/proc/#{pids}/status").grep(/^PPid:/).first.split[1].to_i
19
+ rescue => e
20
+ raise "can't get process info: #{e.message}"
21
+ end
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kunoichi
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Alessandro Grassi
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-01-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: logger
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.2.8
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 1.2.8
30
+ - !ruby/object:Gem::Dependency
31
+ name: syslog-logger
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 1.6.8
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 1.6.8
46
+ description: A Ruby rewrite of Tom Rune Flo's Ninja, a privilege escalation monitor.
47
+ email:
48
+ - alessandro@aggro.it
49
+ executables:
50
+ - kunoichi
51
+ extensions: []
52
+ extra_rdoc_files: []
53
+ files:
54
+ - lib/extensions/object.rb
55
+ - lib/kunoichi.rb
56
+ - lib/kunoichi/cli.rb
57
+ - lib/kunoichi/kunoichi.rb
58
+ - lib/kunoichi/proctable.rb
59
+ - bin/kunoichi
60
+ homepage: https://github.com/xstasi/kunoichi
61
+ licenses: []
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ none: false
68
+ requirements:
69
+ - - ! '>='
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubyforge_project:
80
+ rubygems_version: 1.8.24
81
+ signing_key:
82
+ specification_version: 3
83
+ summary: Process privilege escalation monitor
84
+ test_files: []