├── LICENSE ├── README.md └── SimpleBITSServer └── SimpleBITSServer.py /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, SafeBreach Labs 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimpleBITSServer (Background Intelligent Transfer Service) 2 | 3 | A simple python implementation of a BITS server. BITS protocol is used to transfer files asynchronously between a client and a server. 4 | The BITS protocol metadata communication resides mainly in BITS-defined HTTP headers, all start with prefix "BITS-". For that reason, this implemantation is based on python's built-in SimpleHTTPRequestHandler. 5 | 6 | The implementation corresponds to the [MSDN specification](https://msdn.microsoft.com/en-us/library/windows/desktop/aa362828(v=vs.85).aspx) for client and server packets. 7 | 8 | * It was originally developed as an ad-hoc utility for modelling a BITS upload job scenario. 9 | * The corresponding client is Windows' built-in PowerShell cmdlet. 10 | * Therefore, It is not intended to fully implement or abide to the official specification. 11 | Further work may be done in the future to expand the specification coverage. 12 | 13 | ## Background 14 | 15 | [Official protocol specification](https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MC-BUP/[MC-BUP].pdf) - Background Intelligent Transfer Service (BITS) 16 | 17 | [Example use at SafeBreach Labs](https://safebreach.com/Post/Building-a-Python-BITS-Server) 18 | 19 | ## Usage 20 | 21 | ### Server 22 | * Tested with Python 2.7: 23 | 24 | ``` 25 | python SimpleBITSServer.py [port] 26 | ``` 27 | 28 | ### Client 29 | Prerequisites: 30 | * Must run on a Windows OS to use the Microsoft Windows BITS Service. 31 | * Ports or protocol mimics might exist, please inform us if you do find 32 | * a BITS client, preferably PowerShell's Start-BitsTransfer. Alternatively: 33 | * Windows' built in utility - bitsadmin.exe (deprecated) 34 | * Any program implementing the required interfaces as described on [MSDN](https://msdn.microsoft.com/en-us/library/windows/desktop/aa362820(v=vs.85).aspx) 35 | 36 | 37 | The simple PowerShell usage this server was built to service: 38 | 39 | ``` 40 | > Import-Module BitsTransfer 41 | > Start-BitsTransfer -TransferType Upload -Source C:\temp\to_upload.txt -Destination http://127.0.0.1/to_upload.txt -DisplayName TEST 42 | ``` 43 | 44 | ## Authors 45 | 46 | **Dor Azouri** - *Initial work* 47 | 48 | See also the list of [contributors](https://github.com/SafeBreach-Labs/SimpleBITSServer/contributors) who participated in this project. 49 | 50 | ## License 51 | 52 | [WTFPL](http://www.wtfpl.net/) - do What the Fuck You Want To Public License 53 | -------------------------------------------------------------------------------- /SimpleBITSServer/SimpleBITSServer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | A simple BITS server in python based on SimpleHTTPRequestHandler 4 | 5 | * Supports both Download and Upload jobs (excluding Upload-Reply) 6 | * Example client usage using PowerShell: 7 | > Import-Module BitsTransfer 8 | > Start-BitsTransfer -TransferType Upload -Source C:\temp\to_upload.txt -Destination http://127.0.0.1/to_upload.txt -DisplayName TEST 9 | 10 | References: https://msdn.microsoft.com/en-us/library/windows/desktop/aa362828(v=vs.85).aspx 11 | https://winprotocoldoc.blob.core.windows.net/productionwindowsarchives/MC-BUP/[MC-BUP].pdf 12 | 13 | Author: Dor Azouri 14 | 15 | Date: 2017-03-29T12:14:45Z 16 | 17 | """ 18 | import os 19 | from BaseHTTPServer import HTTPServer 20 | from SimpleHTTPServer import SimpleHTTPRequestHandler 21 | 22 | 23 | # BITS Protocol header keys 24 | K_BITS_SESSION_ID = 'BITS-Session-Id' 25 | K_BITS_ERROR_CONTEXT = 'BITS-Error-Context' 26 | K_BITS_ERROR_CODE = 'BITS-Error-Code' 27 | K_BITS_PACKET_TYPE = 'BITS-Packet-Type' 28 | K_BITS_SUPPORTED_PROTOCOLS = 'BITS-Supported-Protocols' 29 | K_BITS_PROTOCOL = 'BITS-Protocol' 30 | 31 | # HTTP Protocol header keys 32 | K_ACCEPT_ENCODING = 'Accept-Encoding' 33 | K_CONTENT_NAME = 'Content-Name' 34 | K_CONTENT_LENGTH = 'Content-Length' 35 | K_CONTENT_RANGE = 'Content-Range' 36 | K_CONTENT_ENCODING = 'Content-Encoding' 37 | 38 | # BITS Protocol header values 39 | V_ACK = 'Ack' 40 | 41 | # BITS server errors 42 | class BITSServerHResult(object): 43 | # default context 44 | BG_ERROR_CONTEXT_REMOTE_FILE = hex(0x5) 45 | # official error codes 46 | BG_E_TOO_LARGE = hex(0x80200020) 47 | E_INVALIDARG = hex(0x80070057) 48 | E_ACCESSDENIED = hex(0x80070005) 49 | ZERO = hex(0x0) # protocol specification does not give a name for this HRESULT 50 | # custom error code 51 | ERROR_CODE_GENERIC = hex(0x1) 52 | 53 | 54 | class HTTPStatus(object): 55 | # Successful 2xx 56 | OK = 200 57 | CREATED = 201 58 | # Client Error 4xx 59 | BAD_REQUEST = 400 60 | FORBIDDEN = 403 61 | NOT_FOUND = 404 62 | CONFLICT = 409 63 | REQUESTED_RANGE_NOT_SATISFIABLE = 416 64 | # Server Error 5xx 65 | INTERNAL_SERVER_ERROR = 500 66 | NOT_IMPLEMENTED = 501 67 | 68 | 69 | class BITSServerException(Exception): 70 | pass 71 | 72 | class ClientProtocolNotSupported(BITSServerException): 73 | def __init__(self, supported_protocols): 74 | super(ClientProtocolNotSupported, self).__init__("Server supports neither of the requested protocol versions") 75 | self.requested_protocols = str(supported_protocols) 76 | 77 | 78 | class ServerInternalError(BITSServerException): 79 | def __init__(self, internal_exception): 80 | super(ServerInternalError, self).__init__("Internal server error encountered") 81 | self.internal_exception = internal_exception 82 | 83 | 84 | class InvalidFragment(BITSServerException): 85 | def __init__(self, last_range_end, new_range_start): 86 | super(ServerInternalError, self).__init__("Invalid fragment received on server") 87 | self.last_range_end = last_range_end 88 | self.new_range_start = new_range_start 89 | 90 | 91 | class FragmentTooLarge(BITSServerException): 92 | def __init__(self, fragment_size): 93 | super(FragmentTooLarge, self).__init__("Oversized fragment received on server") 94 | self.fragment_size = fragment_size 95 | 96 | 97 | class UploadAccessDenied(BITSServerException): 98 | def __init__(self): 99 | super(UploadAccessDenied, self).__init__("Write access to requested file upload is denied") 100 | 101 | 102 | class BITSUploadSession(object): 103 | 104 | # holds the file paths that has an active upload session 105 | files_in_use = [] 106 | 107 | def __init__(self, absolute_file_path, fragment_size_limit): 108 | self.fragment_size_limit = fragment_size_limit 109 | self.absolute_file_path = absolute_file_path 110 | self.fragments = [] 111 | self.expected_file_length = -1 112 | 113 | # case the file already exists 114 | if os.path.exists(self.absolute_file_path): 115 | # case the file is actually a directory 116 | if os.path.isdir(self.absolute_file_path): 117 | self._status_code = HTTPStatus.FORBIDDEN 118 | # case the file is being uploaded in another active session 119 | elif self.absolute_file_path in BITSUploadSession.files_in_use: 120 | self._status_code = HTTPStatus.CONFLICT 121 | # case file exists on server - we overwrite the file with the new upload 122 | else: 123 | BITSUploadSession.files_in_use.append(self.absolute_file_path) 124 | self.__open_file() 125 | # case file does not exist but its parent folder does exist - we create the file 126 | elif os.path.exists(os.path.dirname(self.absolute_file_path)): 127 | BITSUploadSession.files_in_use.append(self.absolute_file_path) 128 | self.__open_file() 129 | # case file does not exist nor its parent folder - we don't create the directory tree 130 | else: 131 | self._status_code = HTTPStatus.FORBIDDEN 132 | 133 | def __open_file(self): 134 | try: 135 | self.file = open(self.absolute_file_path, "wb") 136 | self._status_code = HTTPStatus.OK 137 | except Exception: 138 | self._status_code = HTTPStatus.FORBIDDEN 139 | 140 | def __get_final_data_from_fragments(self): 141 | """ 142 | Combines all accepted fragments' data into one string 143 | """ 144 | return "".join([frg['data'] for frg in self.fragments]) 145 | 146 | def get_last_status_code(self): 147 | return self._status_code 148 | 149 | def add_fragment(self, file_total_length, range_start, range_end, data): 150 | """ 151 | Applies new fragment received from client to the upload session. 152 | Returns a boolean: is the new fragment last in session 153 | """ 154 | # check if fragment size exceeds server limit 155 | if self.fragment_size_limit < range_end - range_start: 156 | raise FragmentTooLarge(range_end - range_start) 157 | 158 | # case new fragment is the first fragment in this session 159 | if self.expected_file_length == -1: 160 | self.expected_file_length = file_total_length 161 | 162 | last_range_end = self.fragments[-1]['range_end'] if self.fragments else -1 163 | if last_range_end + 1 < range_start: 164 | # case new fragment's range is not contiguous with the previous fragment 165 | # will cause the server to respond with status code 416 166 | raise InvalidFragment(last_range_end, range_start) 167 | elif last_range_end + 1 > range_start: 168 | # case new fragment partially overlaps last fragment 169 | # BITS protocol states that server should treat only the non-overlapping part 170 | range_start = last_range_end + 1 171 | 172 | self.fragments.append( 173 | {'range_start': range_start, 174 | 'range_end': range_end, 175 | 'data': data}) 176 | 177 | # case new fragment is the first fragment in this session, 178 | # we write the final uploaded data to file 179 | if range_end + 1 == self.expected_file_length: 180 | self.file.write(self.__get_final_data_from_fragments()) 181 | return True 182 | 183 | return False 184 | 185 | def close(self): 186 | self.file.flush() 187 | self.file.close() 188 | BITSUploadSession.files_in_use.remove(self.absolute_file_path) 189 | 190 | 191 | class SimpleBITSRequestHandler(SimpleHTTPRequestHandler): 192 | 193 | protocol_version = "HTTP/1.1" 194 | base_dir = os.getcwd() 195 | supported_protocols = ["{7df0354d-249b-430f-820d-3d2a9bef4931}"] # The only existing protocol version to date 196 | fragment_size_limit = 100*1024*1024 # bytes 197 | 198 | def __send_response(self, headers_dict={}, status_code=HTTPStatus.OK, data=""): 199 | """ 200 | Sends server response w/ headers and status code 201 | """ 202 | self.send_response(status_code) 203 | for k, v in headers_dict.iteritems(): 204 | self.send_header(k, v) 205 | self.end_headers() 206 | 207 | self.wfile.write(data) 208 | 209 | def __release_resources(self): 210 | """ 211 | Releases server resources for a session termination caused by either: 212 | Close-Session or Cancel-Session 213 | """ 214 | headers = { 215 | K_BITS_PACKET_TYPE: V_ACK, 216 | K_CONTENT_LENGTH: '0' 217 | } 218 | 219 | try: 220 | session_id = self.headers.get(K_BITS_SESSION_ID, None).lower() 221 | headers[K_BITS_SESSION_ID] = session_id 222 | self.log_message("Closing BITS-Session-Id: %s", session_id) 223 | 224 | self.sessions[session_id].close() 225 | self.sessions.pop(session_id, None) 226 | 227 | status_code = HTTPStatus.OK 228 | except AttributeError: 229 | self.__send_response(headers, status_code = HTTPStatus.BAD_REQUEST) 230 | return 231 | except Exception as e: 232 | raise ServerInternalError(e) 233 | 234 | self.__send_response(headers, status_code = status_code) 235 | 236 | def _handle_fragment(self): 237 | """ 238 | Handles a new Fragment packet from the client, adding it to the relevant upload session 239 | """ 240 | headers = { 241 | K_BITS_PACKET_TYPE: V_ACK, 242 | K_CONTENT_LENGTH: '0' 243 | } 244 | 245 | try: 246 | # obtain client headers 247 | session_id = self.headers.get(K_BITS_SESSION_ID, None).lower() 248 | content_length = int(self.headers.get(K_CONTENT_LENGTH, None)) 249 | content_name = self.headers.get(K_CONTENT_NAME, None) 250 | content_encoding = self.headers.get(K_CONTENT_ENCODING, None) 251 | content_range = self.headers.get(K_CONTENT_RANGE, None).split(" ")[-1] 252 | # set response headers's session id 253 | headers[K_BITS_SESSION_ID] = session_id 254 | # normalize fragment details 255 | crange, total_length = content_range.split("/") 256 | total_length = int(total_length) 257 | range_start, range_end = [int(num) for num in crange.split("-")] 258 | except AttributeError, IndexError: 259 | self.__send_response(status_code = HTTPStatus.BAD_REQUEST) 260 | return 261 | 262 | data = self.rfile.read(content_length) 263 | 264 | try: 265 | is_last_fragment = self.sessions[session_id].add_fragment( 266 | total_length, range_start, range_end, data) 267 | headers['BITS-Received-Content-Range'] = range_end + 1 268 | except InvalidFragment as e: 269 | headers[K_BITS_ERROR_CODE] = BITSServerHResult.ZERO 270 | headers[K_BITS_ERROR_CONTEXT] = BITSServerHResult.BG_ERROR_CONTEXT_REMOTE_FILE 271 | status_code = HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE 272 | self.log_message("ERROR processing new fragment (BITS-Session-Id: %s)." + \ 273 | "New fragment range (%d) is not contiguous with last received (%d). context:%s, code:%s, exception:%s", 274 | session_id, 275 | e.new_range_start, 276 | e.last_range_end, 277 | headers[K_BITS_ERROR_CONTEXT], 278 | headers[K_BITS_ERROR_CODE], 279 | repr(e)) 280 | except FragmentTooLarge as e: 281 | headers[K_BITS_ERROR_CODE] = BITSServerHResult.BG_E_TOO_LARGE 282 | headers[K_BITS_ERROR_CONTEXT] = BITSServerHResult.BG_ERROR_CONTEXT_REMOTE_FILE 283 | status_code = HTTPStatus.INTERNAL_SERVER_ERROR 284 | self.log_message("ERROR processing new fragment (BITS-Session-Id: %s)." + \ 285 | "New fragment size (%d) exceeds server limit (%d). context:%s, code:%s, exception:%s", 286 | session_id, 287 | e.fragment_size, 288 | SimpleBITSRequestHandler.fragment_size_limit, 289 | headers[K_BITS_ERROR_CONTEXT], 290 | headers[K_BITS_ERROR_CODE], 291 | repr(e)) 292 | except Exception as e: 293 | raise ServerInternalError(e) 294 | 295 | status_code = HTTPStatus.OK 296 | self.__send_response(headers, status_code = status_code) 297 | 298 | def _handle_ping(self): 299 | """ 300 | Handles Ping packet from client 301 | """ 302 | self.log_message("%s RECEIVED", "PING") 303 | headers = { 304 | K_BITS_PACKET_TYPE: V_ACK, 305 | K_BITS_ERROR_CODE : '1', 306 | K_BITS_ERROR_CONTEXT: '', 307 | K_CONTENT_LENGTH: '0' 308 | } 309 | self.__send_response(headers, status_code = HTTPStatus.OK) 310 | 311 | def __get_current_session_id(self): 312 | return str(hash((self.connection.getpeername()[0], self.path))) 313 | 314 | def _handle_cancel_session(self): 315 | self.log_message("%s RECEIVED", "CANCEL-SESSION") 316 | return self.__release_resources() 317 | 318 | def _handle_close_session(self): 319 | self.log_message("%s RECEIVED", "CLOSE-SESSION") 320 | return self.__release_resources() 321 | 322 | 323 | def _handle_create_session(self): 324 | """ 325 | Handles Create-Session packet from client. Creates the UploadSession. 326 | The unique ID that identifies a session in this server is a hash of the client's address and requested path. 327 | """ 328 | self.log_message("%s RECEIVED", "CREATE-SESSION") 329 | 330 | headers = { 331 | K_BITS_PACKET_TYPE: V_ACK, 332 | K_CONTENT_LENGTH: '0' 333 | } 334 | 335 | if not getattr(self, "sessions", False): 336 | self.sessions = dict() 337 | try: 338 | # check if server's protocol version is supported in client 339 | client_supported_protocols = \ 340 | self.headers.get(K_BITS_SUPPORTED_PROTOCOLS, None).lower().split(" ") 341 | protocols_intersection = set(client_supported_protocols).intersection( 342 | SimpleBITSRequestHandler.supported_protocols) 343 | 344 | # case mutual supported protocol is found 345 | if protocols_intersection: 346 | headers[K_BITS_PROTOCOL] = list(protocols_intersection)[0] 347 | requested_path = self.path[1:] if self.path.startswith("/") else self.path 348 | absolute_file_path = os.path.join(SimpleBITSRequestHandler.base_dir, requested_path) 349 | 350 | session_id = self.__get_current_session_id() 351 | self.log_message("Creating BITS-Session-Id: %s", session_id) 352 | if session_id not in self.sessions: 353 | self.sessions[session_id] = BITSUploadSession(absolute_file_path, SimpleBITSRequestHandler.fragment_size_limit) 354 | 355 | headers[K_BITS_SESSION_ID] = session_id 356 | status_code = self.sessions[session_id].get_last_status_code() 357 | if status_code == HTTPStatus.FORBIDDEN: 358 | raise UploadAccessDenied() 359 | # case no mutual supported protocol is found 360 | else: 361 | raise ClientProtocolNotSupported(client_supported_protocols) 362 | except AttributeError: 363 | self.__send_response(headers, status_code = HTTPStatus.BAD_REQUEST) 364 | return 365 | except ClientProtocolNotSupported as e: 366 | status_code = HTTPStatus.BAD_REQUEST 367 | headers[K_BITS_ERROR_CODE] = BITSServerHResult.E_INVALIDARG 368 | headers[K_BITS_ERROR_CONTEXT] = BITSServerHResult.BG_ERROR_CONTEXT_REMOTE_FILE 369 | self.log_message("ERROR creating new session - protocol mismatch (%s). context:%s, code:%s, exception:%s", 370 | e.requested_protocols, 371 | headers[K_BITS_ERROR_CONTEXT], 372 | headers[K_BITS_ERROR_CODE], 373 | repr(e)) 374 | except UploadAccessDenied as e: 375 | headers[K_BITS_ERROR_CODE] = BITSServerHResult.E_ACCESSDENIED 376 | headers[K_BITS_ERROR_CONTEXT] = BITSServerHResult.BG_ERROR_CONTEXT_REMOTE_FILE 377 | self.log_message("ERROR creating new session - Access Denied. context:%s, code:%s, exception:%s", 378 | headers[K_BITS_ERROR_CONTEXT], 379 | headers[K_BITS_ERROR_CODE], 380 | repr(e)) 381 | except Exception as e: 382 | raise ServerInternalError(e) 383 | 384 | 385 | if status_code == HTTPStatus.OK or status_code == HTTPStatus.CREATED: 386 | headers[K_ACCEPT_ENCODING] = 'identity' 387 | 388 | self.__send_response(headers, status_code = status_code) 389 | 390 | def do_BITS_POST(self): 391 | headers = {} 392 | bits_packet_type = self.headers.getheaders(K_BITS_PACKET_TYPE)[0].lower() 393 | try: 394 | do_function = getattr(self, "_handle_%s" % bits_packet_type.replace("-", "_")) 395 | try: 396 | do_function() 397 | return 398 | except ServerInternalError as e: 399 | status_code = HTTPStatus.INTERNAL_SERVER_ERROR 400 | headers[K_BITS_ERROR_CODE] = BITSServerHResult.ERROR_CODE_GENERIC 401 | except AttributeError as e: 402 | # case an Unknown BITS-Packet-Type value was received by the server 403 | status_code = HTTPStatus.BAD_REQUEST 404 | headers[K_BITS_ERROR_CODE] = BITSServerHResult.E_INVALIDARG 405 | 406 | headers[K_BITS_ERROR_CONTEXT] = BITSServerHResult.BG_ERROR_CONTEXT_REMOTE_FILE 407 | self.log_message("Internal BITS Server Error. context:%s, code:%s, exception:%s", 408 | headers[K_BITS_ERROR_CONTEXT], 409 | headers[K_BITS_ERROR_CODE], 410 | repr(e.internal_exception)) 411 | self.__send_response(headers, status_code = status_code) 412 | 413 | 414 | def run(server_class=HTTPServer, handler_class=SimpleBITSRequestHandler, port=80): 415 | server_address = ('', port) 416 | httpd = server_class(server_address, handler_class) 417 | print 'Starting BITS server...' 418 | httpd.serve_forever() 419 | 420 | 421 | if __name__ == "__main__": 422 | from sys import argv 423 | 424 | if len(argv) == 2: 425 | run(port=int(argv[1])) 426 | else: 427 | run() --------------------------------------------------------------------------------