Hacking Websockets: SQL injection
WebSocket application may be susceptible to all kinds of vulnerabilities. ffuf works great for enumerating and fuzzing and enumerating, sqlmap is the state of the art tool for SQL injection. Both of them support HTTP, neither of them supports WebSockets. In this article we develop a tool that allows us to use these awesome tools in WebSocket applications.
Table of Contents
Since every WebSocket application is different, I see no value in creating a CLI interface for our tool. Instead, we will create a hackable python script that can be adjusted for any purpose.
The approach is the following. Let’s create a Python application which starts a
web server listening on 127.0.0.1:8080
. We tell sqlmap to target
http://127.0.0.1:8080?fuzz=123
. The web server takes the payload from the web
request and adds it to a payload queue. Another function takes the payload from
the payload queue, sets up a WebSocket connection, and sends the payload to the
targeted WebSocket application. The WebSocket response is decoded, passed back
to the web request handler that finally sends it back to sqlmap.
To test the script, I wrote a dummy WebSocket application vulnerable to SQL injection. You can find all the code from this blog post on my GitLab.
HTTP handler
We will use the asynchronous HTTP server library aiohttp for creating the web server. The application is going to use asyncio.Queue for communications between the two threads.
Lets start with a handler function that runs for every HTTP request. It gets
the parameter fuzz
from the web requests and puts it to the queue for
processing. It then waits for the response and sends it to sqlmap.
async def handle_http_request(
req_queue: Queue[str], res_queue: Queue[str], request: web.Request
) -> web.Response:
value = request.query.get("fuzz")
if value is None:
return web.Response(status=400, text="Missing 'fuzz' parameter\n")
await req_queue.put(value)
response = await res_queue.get()
return web.Response(text=response)
Next we will create the main function for our script. Here we initialize our request and response queues and set up the web server.
async def main() -> None:
request_queue: Queue[str] = Queue(maxsize=1)
response_queue: Queue[str] = Queue(maxsize=1)
handler = partial(handle_http_request, request_queue, response_queue)
web_app = web.Application()
web_app.add_routes([web.get("/", handler)])
runner = web.AppRunner(web_app)
await runner.setup()
site = web.TCPSite(runner, HTTP_HOST, HTTP_PORT)
logging.info(f"Listening on {HTTP_HOST}:{HTTP_PORT}")
await asyncio.gather(site.start())
HTTP_HOST='localhost'
HTTP_PORT=8080
asyncio.run(main())
What is the partial
function?
The HTTP handler function expects one argument, but we pass
the queues too. The partial
functions sets two first arguments to our queues
and leaves the last one alone. It is a one-liner version of this:
def handler(request: web.Request) -> web.Response:
return handle_http_request(request_queue, response_queue, request)
The partial
function comes from functools
module as is part of a standard
Python library.
What is this async
stuff?
It is a method of writing asynchronous applications using the async
-
await
syntax. asyncio.run
runs a single asynchronous function (called
corutine), asyncio.gather
can run multiple of them simultaneously.
WebSockets client
There are a few libraries for WebSocket clients. The websockets library is well maintained and offers a good selection of features. Another good choice is a websocket-client library which also offers hook-based API similar to one available in JavaScript.
Let’s continue with the implementation. The websocket-client
function reads
payloads from the payload queue and sends them to the targeted WebSocket application. The
responses are put to the response queue. Recall that the HTTP handler reads from
the response queue and passes the response to the HTTP client.
If everything goes well, the whole process starts again. If no payloads are in the request queue, the process will wait. In case the connection closes it is started back again.
async def websocket_client(url: str, req_queue: Queue[str], res_queue: Queue[str]) -> None:
ws = await websockets.connect(url)
while True:
try:
payload = await req_queue.get()
await ws.send(payload)
x = await ws.recv()
if isinstance(x, bytes):
x = x.decode(errors="replace")
await res_queue.put(x)
except websockets.ConnectionClosed:
ws = await websockets.connect(url)
Now we have to modify our main function to start the WebSocket client alongside the web server. The modified lines are highlighted.
async def main() -> None:
request_queue: Queue[str] = Queue(maxsize=1)
response_queue: Queue[str] = Queue(maxsize=1)
web_app = web.Application()
web_app.add_routes([web.get("/", partial(handle_http_request, request_queue, response_queue))])
runner = web.AppRunner(web_app)
await runner.setup()
site = web.TCPSite(runner, HTTP_HOST, HTTP_PORT)
logging.info(f"Listening on {HTTP_HOST}:{HTTP_PORT}")
client = websocket_client(WS_TARGET, request_queue, response_queue)
await asyncio.gather(client, site.start())
HTTP_HOST='localhost'
HTTP_PORT=8080
WS_TARGET="ws://127.0.0.1:8765"
asyncio.run(main())
Finally, we have to initialize our logger to log the messages to the console. This will do the job:
logger = logging.getLogger()
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.INFO)
Remaining issues
We are close to a solution, but there are a two issues for us to consider.
If the connection closes during the
websocket_client
function, the program will hang indefinitely – the HTTP handler waits for response and the websocket client waits for request. We are stuck.A bad payload causing the connection to disconnect may cause infinite loop.
Both of these issues are easily fixed. The changed lines are highlighted.
async def websocket_client(url: str, req_queue: Queue[str], res_queue: Queue[str]) -> None:
ws = await websockets.connect(url)
payload = None
consecutive_errors = 0
while True:
try:
if payload is None:
payload = await req_queue.get()
await ws.send(payload)
x = await ws.recv()
if isinstance(x, bytes):
x = x.decode(errors="replace")
await res_queue.put(x)
payload = None
consecutive_errors = 0
except websockets.ConnectionClosed:
consecutive_errors += 1
if consecutive_errors > 3:
logging.fatal("Too many consecutive errors")
exit(1)
ws = await websockets.connect(url)
Demo
Click to see the code of the vulnerable WebSocket application
import asyncio
import json
import logging
import sqlite3
from functools import partial
import websockets.server
from websockets.server import serve
HOST = "127.0.0.1"
PORT = 8765
def setup_database(conn: sqlite3.Connection) -> None:
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS user (id NUMERIC, username TEXT, password TEXT)")
cursor.execute("""INSERT INTO user VALUES
(1, "admin", "admin"),
(2, "guest", "guest"),
(3, "daniel", "egypt"),
(4, "teal'c", "indeed")
""")
conn.commit()
def run() -> None:
logging.info(f"Listening on {HOST}:{PORT}")
asyncio.run(main())
async def echo(conn: sqlite3.Connection, ws: websockets.server.WebSocketServerProtocol) -> None:
cur = conn.cursor()
async for message in ws:
try:
msg_obj: dict[str, str] = json.loads(message)
username = msg_obj.get("username", None)
if not username:
await ws.send(json.dumps({"status": "error"}))
continue
user_id = cur.execute(f"SELECT id FROM user WHERE username='{username}'").fetchone()
if user_id is not None:
await ws.send(json.dumps({"status": "ok", "id": user_id[0]}))
continue
else:
await ws.send(json.dumps({"status": "error"}))
continue
except Exception as e:
await ws.send(json.dumps({"status": "exception", "exception": str(e)}))
await ws.close()
await ws.close()
async def main() -> None:
conn = sqlite3.connect(":memory:")
setup_database(conn)
print("Listening on localhost:8765")
async with serve(partial(echo, conn), "localhost", 8765):
await asyncio.Future()
asyncio.run(main())
Click to see the finished fuzzing script
import asyncio
import json
import logging
from asyncio import Queue
from functools import partial
import websockets
from aiohttp import web
async def handle_http_request(req_queue: Queue[str], res_queue: Queue[str], request: web.Request) -> web.Response:
value = request.query.get("fuzz")
if value is None:
return web.Response(status=400, text="Missing 'fuzz' parameter\n")
await req_queue.put(value)
response = await res_queue.get()
return web.Response(text=response)
async def websocket_client(url: str, req_queue: Queue[str], res_queue: Queue[str]) -> None:
ws = await websockets.connect(url)
payload = None
consecutive_errors = 0
while True:
try:
if payload is None:
payload = await req_queue.get()
msg = json.dumps({"username": payload})
await ws.send(msg)
x = await ws.recv()
if isinstance(x, bytes):
x = x.decode(errors="replace")
await res_queue.put(x)
payload = None
consecutive_errors = 0
except websockets.ConnectionClosed:
consecutive_errors += 1
if consecutive_errors > 3:
logging.fatal("Too many consecutive errors")
exit(1)
ws = await websockets.connect(url)
async def main() -> None:
request_queue: Queue[str] = Queue(maxsize=1)
response_queue: Queue[str] = Queue(maxsize=1)
web_app = web.Application()
web_app.add_routes([web.get("/", partial(handle_http_request, request_queue, response_queue))])
runner = web.AppRunner(web_app)
await runner.setup()
site = web.TCPSite(runner, HTTP_HOST, HTTP_PORT)
logging.info(f"Listening on {HTTP_HOST}:{HTTP_PORT}")
client = websocket_client(WS_TARGET, request_queue, response_queue)
await asyncio.gather(client, site.start())
HTTP_HOST = "localhost"
HTTP_PORT = 8080
WS_TARGET = "ws://127.0.0.1:8765"
logger = logging.getLogger()
logger.addHandler(logging.StreamHandler())
logger.setLevel(logging.INFO)
asyncio.run(main())
Here’s our script in action: