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

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.

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.

  1. 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.

  2. 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: