def do_GET(self): path = urllib.parse.unquote(self.path) self.log(logging.INFO, "GET Header: {}".format(path)) if self.struggle_check(path): return # split the path by '/', ignoring empty string url_path = list(filter(None, path.split('/'))) # if url is empty or path is /vpn/, display fake login page if len(url_path) == 0 or (len(url_path) == 1 and url_path[0] == 'vpn'): return self.send_response(self.get_page('login.html')) # only proceed if a directory traversal was attempted if path.find('/../') != -1: # flatten path to ease parsing collapsed_path = server._url_collapse_path(path) url_path = list(filter(None, collapsed_path.split('/'))) # check if the directory traversal bug has been tried if len(url_path) >= 1 and url_path[0] == 'vpns': # collapse path to ignore extra / and .. for proper formatting collapsed_path = server._url_collapse_path(path) # 403 on /vpn/../vpns/ is used by some scanners to detect vulnerable hosts # Ex: https://github.com/cisagov/check-cve-2019-19781/blob/develop/src/check_cve/check.py if len(url_path) == 1 and url_path[0] == 'vpns': self.log(logging.WARN, "Detected type 1 CVE-2019-19781 scan attempt!") page_403 = self.get_page('403.html').replace( '{url}', collapsed_path) return self.send_response(page_403) if len(url_path) >= 2 and url_path[0] == 'vpns' and url_path[ 1] == 'portal': self.log(logging.CRITICAL, "Detected CVE-2019-19781 completion!") return self.send_response("") # some scanners try to fetch smb.conf to detect vulnerable hosts # Ex: https://github.com/trustedsec/cve-2019-19781/blob/master/cve-2019-19781_scanner.py elif collapsed_path == '/vpns/cfg/smb.conf': self.log(logging.WARN, "Detected type 2 CVE-2019-19781 scan attempt!") return self.send_response(self.get_page('smb.conf')) # we got a request that sort of matches CVE-2019-19781, but it's not a know scan attempt else: self.log( logging.DEBUG, "Error: unhandled CVE-2019-19781 scan attempt: {}". format(path)) self.send_response("") # if all else fails return nothing return self.send_response("")
def is_cgi(self): """Test whether self.path corresponds to a local CGI script or a CGI script. Returns True and updates the cgi_info attribute to the tuple (dir, rest) if self.path requires running a CGI script. Returns False otherwise. Returns True and set local_cgi to True if it's a local CI script. If any exception is raised, the caller should assume that self.path was rejected as invalid and act accordingly. The default implementation tests whether the normalized url path begins with one of the strings in self.cgi_directories (and the next character is a '/' or the end of the string). """ collapsed_path = _url_collapse_path(self.path) dir_sep = collapsed_path.find('/', 1) head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep + 1:] if head in self.local_cgi_path: self.cgi_info = head, tail self.local_cgi = True return True else: self.local_cgi = False return super().is_cgi()
def do_POST(self): self.log(logging.INFO, "POST Header: {}".format(self.path)) if 'Content-Length' in self.headers: collapsed_path = server._url_collapse_path(self.path) content_length = int(self.headers['Content-Length']) post_data = self.rfile.read(content_length).decode('utf-8') self.log(logging.INFO, "POST body: {}".format(post_data)) # RCE path is /vpns/portal/scripts/newbm.pl and payload is contained in POST data if content_length != 0 and collapsed_path == '/vpns/portal/scripts/newbm.pl': payload = urllib.parse.parse_qs(post_data)['title'][0] self.log(logging.CRITICAL, "Detected CVE-2019-19781 payload: {}".format(payload)) if self.struggle_check(self.path): return # send empty response as we're now done return self.send_response('')
def test_url_collapse_path(self): # verify tail is the last portion and head is the rest on proper urls test_vectors = { '': '//', '..': IndexError, '/.//..': IndexError, '/': '//', '//': '//', '/\\': '//\\', '/.//': '//', 'cgi-bin/file1.py': '/cgi-bin/file1.py', '/cgi-bin/file1.py': '/cgi-bin/file1.py', 'a': '//a', '/a': '//a', '//a': '//a', './a': '//a', './C:/': '/C:/', '/a/b': '/a/b', '/a/b/': '/a/b/', '/a/b/.': '/a/b/', '/a/b/c/..': '/a/b/', '/a/b/c/../d': '/a/b/d', '/a/b/c/../d/e/../f': '/a/b/d/f', '/a/b/c/../d/e/../../f': '/a/b/f', '/a/b/c/../d/e/.././././..//f': '/a/b/f', '../a/b/c/../d/e/.././././..//f': IndexError, '/a/b/c/../d/e/../../../f': '/a/f', '/a/b/c/../d/e/../../../../f': '//f', '/a/b/c/../d/e/../../../../../f': IndexError, '/a/b/c/../d/e/../../../../f/..': '//', '/a/b/c/../d/e/../../../../f/../.': '//', } for path, expected in test_vectors.items(): if isinstance(expected, type) and issubclass(expected, Exception): self.assertRaises(expected, server._url_collapse_path, path) else: actual = server._url_collapse_path(path) self.assertEqual(expected, actual, msg='path = %r\nGot: %r\nWanted: %r' % (path, actual, expected))
def test_url_collapse_path(self): # verify tail is the last portion and head is the rest on proper urls test_vectors = { "": "//", "..": IndexError, "/.//..": IndexError, "/": "//", "//": "//", "/\\": "//\\", "/.//": "//", "cgi-bin/file1.py": "/cgi-bin/file1.py", "/cgi-bin/file1.py": "/cgi-bin/file1.py", "a": "//a", "/a": "//a", "//a": "//a", "./a": "//a", "./C:/": "/C:/", "/a/b": "/a/b", "/a/b/": "/a/b/", "/a/b/.": "/a/b/", "/a/b/c/..": "/a/b/", "/a/b/c/../d": "/a/b/d", "/a/b/c/../d/e/../f": "/a/b/d/f", "/a/b/c/../d/e/../../f": "/a/b/f", "/a/b/c/../d/e/.././././..//f": "/a/b/f", "../a/b/c/../d/e/.././././..//f": IndexError, "/a/b/c/../d/e/../../../f": "/a/f", "/a/b/c/../d/e/../../../../f": "//f", "/a/b/c/../d/e/../../../../../f": IndexError, "/a/b/c/../d/e/../../../../f/..": "//", "/a/b/c/../d/e/../../../../f/../.": "//", } for path, expected in test_vectors.items(): if isinstance(expected, type) and issubclass(expected, Exception): self.assertRaises(expected, server._url_collapse_path, path) else: actual = server._url_collapse_path(path) self.assertEqual(expected, actual, msg="path = %r\nGot: %r\nWanted: %r" % (path, actual, expected))
def is_cgi(self): """Test whether self.path corresponds to a CGI script. Returns True and updates the cgi_info attribute to the tuple (dir, rest) if self.path requires running a CGI script. Returns False otherwise. If any exception is raised, the caller should assume that self.path was rejected as invalid and act accordingly. The default implementation tests whether the normalized url path begins with one of the strings in self.cgi_directories (and the next character is a '/' or the end of the string). """ collapsed_path = _url_collapse_path(self.path) for path in self.cgi_directories: if path in collapsed_path: dir_sep_index = collapsed_path.rfind(path) + len(path) head, tail = collapsed_path[:dir_sep_index], collapsed_path[ dir_sep_index + 1:] self.cgi_info = head, tail return True return False
def send_head(self): """This version delegates to the original when (1) authorization doesn't apply to a particular path or (2) credentials check out. Otherwise, it responds with UNAUTHORIZED or FORBIDDEN. The original passes GET requests to the SimpleHTTPRequestHandler, which requires a trailing slash for dirs below DOCROOT, if an html directory listing is to be generated and returned. Otherwise, it responds with a 301 MOVED_PERMANENTLY. """ if not self.auth_dict: return super().send_head() # # self.dlog("send_head - auth_dict", **self.auth_dict) collapsed_path = _url_collapse_path(self.path) is_protected = False # XXX iter var name too long, need below for restricted_path in self.auth_dict: if (collapsed_path.startswith(restricted_path.rstrip("/") + "/") or collapsed_path == restricted_path.rstrip("/")): is_protected = True break # This is just the entry for the path; unrelated to "description" field realm_info = self.auth_dict[restricted_path] privaterepo = realm_info.get('privaterepo', False) if (is_protected is False or privaterepo is False and "service=git-receive-pack" not in collapsed_path): return super().send_head() description = realm_info.get('description', "Basic auth requested") # XXX - this option is currently bunk, although it does trigger the # exporting of REMOTE_USER below, which the git exes seem to ignore. # If implementing, it would most likely be limited to unix systems # with read access to /etc/passwd and /etc/group. The actual modified # files would still end up being owned by the server process UID. realaccount = realm_info.get('realaccount', REQURE_ACCOUNT) try: secretsfile = realm_info.get('secretsfile') with open(secretsfile) as f: secretlines = f.readlines() except TypeError: self.send_error(HTTPStatus.EXPECTATION_FAILED, "Could not read .htpasswd file") return None else: secdict = {} for line in secretlines: if ':' not in line: continue u, p = line.split(":") if p.startswith("$apr1") and self.has_openssl is None: try: check_output(("openssl", "version")) except (FileNotFoundError, CalledProcessError): self.log_error("send_head - Apache md5 support needed" " but not found. See usage note.") self.has_openssl = False continue else: self.has_openssl = True elif p.startswith("$2y"): # Placeholder for passlib integration self.log_error("send_head - bcrypt support requested but " "not found. See usage note.") continue secdict.update({u.strip(): p.strip()}) del line, u, p # self.dlog("send_head - secdict", **secdict) authorization = self.headers.get("authorization") # if authorization: self.dlog("send_head - auth string sent: %r" % authorization) authorization = authorization.split() if len(authorization) == 2: import base64 import binascii os.environ.update(AUTH_TYPE=authorization[0]) if authorization[0].lower() != "basic": self.send_error( HTTPStatus.NOT_ACCEPTABLE, "Auth type %r not supported!" % authorization[0]) return None # try: authorization = authorization[1].encode('ascii') authorization = base64.decodebytes(authorization).decode( 'ascii') except (binascii.Error, UnicodeError): pass else: authorization = authorization.split(':') self.dlog("send_head - processed auth: " "{!r}".format(authorization)) if (len(authorization) == 2 and authorization[0] in secdict and self.verify_pass(secdict[authorization[0]], authorization[1])): if realaccount: os.environ.update(REMOTE_USER=authorization[0]) return super().send_head() else: self.send_error( HTTPStatus.FORBIDDEN, "Problem authenticating " "{!r}".format(authorization[0])) return None # # Auth string had > 1 space or exception was raised self.send_error( HTTPStatus.UNPROCESSABLE_ENTITY, "Problem reading authorization: " "{!r}".format(authorization[0])) return None else: self.send_response(HTTPStatus.UNAUTHORIZED) self.send_header("WWW-Authenticate", 'Basic realm="%s"' % description) self.end_headers() return None
def is_cgi(self): """This modified version of ``is_cgi`` still performs the same basic function as its Super, but the ancillary ``cgi_info`` var has been renamed to ``repo_info`` to better distinguish between the aliased Git CGI scripts dir (``/usr/libexec/git-core``) and the Git repo itself. Namespaces ---------- Initial cloning from a remote repo with existing namespaces takes some extra setup, which is another way of saying something's broken here. When including an existing namespace as the repo's prefix in the url arg to ``git clone``, this warning appears: ``warning: remote HEAD refers to nonexistent ref, unable to checkout.`` And the cloned work tree is empty until issuing a ``git pull origin master``. Upon updating ``remote.origin.url`` with a new namespace prefix, and pushing, everything seems okay. The remote HEAD is even updated to point to the new namespaced ref. It's as if ``git symbolic-ref`` were issued manually on the server side. When cloning without any (existing) namespace prefixing the repo component of the url, a familiar refrain appears:: Note: checking out '2641d08..' You are in 'detached HEAD' state. You can look around ... ... git checkout -b <new-branch-name> And ``status`` says ``not on any branch``. Checking out master without the ``-b`` miraculously puts everything in sync. After updating the remote url and issuing a ``push -u origin master``, the new namespace is created successfully on the remote. Update 1. -- it seems most of the above only applies to remotes that were initialized without the normal refs/heads/master but whose HEAD still pointed thus before being pushed to. """ # TODO migrate all parsing business from ``run_cgi()`` up here. The # basic purpose of this function, which is to split the path into head # and tail components, is redundant because ``run_cgi`` does it again. # # XXX ``SimpleHTTPRequestHandler`` calls ``posixpath.normpath``, which # seems pretty similar to ``http.server._url_collapse_path``. Might be # worth checking out. Guessing this one has to do with deterring # pardir URI mischief, but not certain. collapsed_path = _url_collapse_path(self.path) git_root, tail = self.find_repo(collapsed_path) # Attempt to accommodate namespaced setups. This must occur before # non-existent dirs are interpreted as "missing." ns = None if USE_NAMESPACES: ns_test = self.find_repo(collapsed_path, allow_fake=True) self.dlog("is_cgi - ns_test:", USE_NAMESPACES=USE_NAMESPACES, ns_test=ns_test, git_root=git_root) if ns_test[0] != git_root: git_root, tail = ns_test git_root, ns = self.find_repo(git_root) # # Here, the intent is surely to initialize a new repo. if git_root == "/" and "/" in ns: tail = ns + "/" + tail ns = None # For GET requests, ``self.path`` must be rewritten to prevent # 404s by aiding ``SimpleHTTPRequestHandler`` find the repo. else: self.path = collapsed_path = (git_root.rstrip("/") + "/" + tail.lstrip("/")) self.dlog("is_cgi - top", git_root=git_root, ns=ns, tail=tail) if ns: nsrepo = os.path.join(git_root.lstrip("/"), tail.partition("/")[0]) nspath = os.path.join(self.docroot, nsrepo) try: nshead = check_output( ("git -C %s symbolic-ref HEAD" % nspath).split()) except CalledProcessError as e: self.log_error("{!r}".format(e)) else: self.dlog("is_cgi: %s/HEAD:" % nsrepo, nshead=nshead.decode()) # # Disqualify GET requests for static resources in ``$GIT_DIR/objects``. if self.get_RE.match(collapsed_path): return False # XXX a temporary catch-all to handle requests for extant paths that # don't resolve to ``$GIT_DIR/objects``. Separating this block from the # RE block above is a lazy way of acknowledging that simply dropping # such requests outright or throwing errors might be preferable to # having ``send_head`` shunt them to ``SimpleHTTPRequestHandler``. Any # such logic, if/when needed, should go here. cgi_cand = os.path.join(self.docroot, collapsed_path.strip('/')) if os.path.exists(cgi_cand): return False # # Enforce a "CGI-bin present" policy to allow for easier integration of # external authorization facilities. Permissions problems may arise if # overridden, i.e., if ``FIRST_CHILD_OK == True``. if git_root == "/": # This should only run if all components have yet to be created or # if the topmost (1st child) is an existing repo. # gr_test = self.find_repo(collapsed_path, allow_fake=True) gr_test = self.find_repo(gr_test[0]) msg = None mutate_path = False if gr_test[0] == "/" and not self.is_repo( os.path.join(self.docroot, tail.lstrip("/").split("/")[0])): # This is for dry clones, so no component can actually exist... # if CREATE_MISSING is False: # ... and none will be created msg = "The requested path could not be found " \ "and the env var _CREATE_MISSING is not set" elif ('/info/refs?service=' in tail and '/' not in tail.split("/info/refs?service=")[0].lstrip("/")): # A lone, first-child of docroot has been requested if FIRST_CHILD_OK is True: # Let ``CREATE_MISSING`` logic below christen it a repo mutate_path = True else: msg = "CREATE_MISSING is set but path is only one" \ " level deep; set _FIRST_CHILD_OK to override" else: # Multiple components wanted, so this can fall through. # # XXX note this doesn't check for the presence of a # ``service`` query string or that the method is ``GET``. pass else: # First component indeed exists and is a git repo if FIRST_CHILD_OK: mutate_path = True else: msg = """\n =============== WARNING =============== The requested Git repo should not be a first child of the root URI, "/"; to override, export "_FIRST_CHILD_OK=1"; see usage; hit Ctrl-C (SIGINT) to exit """ self.dlog("is_cgi - git_root missing", gr_test=gr_test, tail=tail, mutate_path=mutate_path, docroot=self.docroot, collapsed_path=collapsed_path) if msg is not None: self.send_error(HTTPStatus.FORBIDDEN, msg) raise ConnectionAbortedError(msg) elif mutate_path is True: # Nest everything by a level (break out of DOCROOT) self.docroot, git_root = os.path.split(self.docroot) collapsed_path = '/' + git_root + collapsed_path # dir_sep = collapsed_path.find('/', 1) # # NOTE - this resets everything -- the stuff above merely weeds out the # corner cases. # # ``head`` = 1st component of ``self.path`` w/o trailing slash # ``tail`` = the rest, no leading slash # # This split is only a starting point, or baseline, to allow the # setting of initial values for ``root``, ``repo``, etc. head, tail = collapsed_path[:dir_sep], collapsed_path[dir_sep + 1:] # self.repo_info = head, tail, ns # # Attempt to create repo if it doesn't exist; applies to both upload # and receive requests if (CREATE_MISSING is True and '/info/refs?service=' in tail): uri = os.path.join( self.docroot, collapsed_path.split('/info/refs?service=')[0].strip('/')) try: # Assume mode is set according to umask os.makedirs(uri) except FileExistsError: pass # Target repo be empty if len(os.listdir(uri)) == 0: try: cp = check_output(('git -C %s init --bare' % uri).split()) except CalledProcessError as e: self.log_error('%r', e) else: self.dlog('is_cgi - created new repo', cp=cp) self.dlog( "is_cgi:", **{ "self.raw_requestline": self.raw_requestline, "collapsed_path": collapsed_path, "git_root": self.find_repo(collapsed_path)[0], "cgi_cand": cgi_cand, "self.repo_info": self.repo_info, "returned": True }) return True