Demo
The demo below will port scan any host and port from withing your local network.
Goal
The goal of this article is to conduct port scanning with JavaScript. Various ports on the domain localhost
should be scanned. It is assumed that our origin is a https
site, such as for example https://incolumitas.com
.
A Ubuntu 18.04 Linux system with a recent chrome browser will be used (Chrome/86.0.4240.75).
Port scanning from within the browser recently caused quite some uproar, when a security researcher observed that Ebay is port scanning his local network from within the browser. Here is another article that goes into much more technical detail compared to the previous one and tries to debug and reverse engineer the port scanning source code from the responsible company ThreatMatrix.
However, browser port scanning is known much longer than that. In fact, as long as you can use JavaScript and there is no strict same origin policy, it will likely be possible.
The Idea
When creating a WebSocket object
var ws = new WebSocket("ws://127.0.0.1:8888/")
that points to local HTTP server started with the command python -m http.server --bind 127.0.0.1 8888
, we get the following JavaScript error in the developer console:
WebSocket connection to 'ws://127.0.0.1:8888/' failed:
Error during WebSocket handshake: Unexpected response code: 404
On the other side, when creating a WebSocket object
var ws = new WebSocket("ws://127.0.0.1:8889/")
with a URL that points to non-existent service on port 8889, we get the following error in the developer console
WebSocket connection to 'ws://127.0.0.1:8889/' failed:
Error in connection establishment: net::ERR_CONNECTION_REFUSED
Boom. Problem solved. We can distinguish solely based on error messages whether a port is open or not.
Not so fast.
When trying to grab the error information with
// outputs: Error: null
var errorMessage = null;
try {
var ws = new WebSocket("ws://127.0.0.1:8889/")
} catch (err) {
// this code will never run
errorMessage = err.toString();
}
console.log('Error: ' + errorMessage)
we get a meager output of Error: null
. The error that is shown in the console, is not accessible to JavaScript!
But since we are very smart, we try to get error details via the WebSocket.onerror
event handler:
var ws = new WebSocket("ws://127.0.0.1:8889/")
ws.onerror = function(error) {
console.error("WebSocket error observed:", error);
};
However, the error
object does not differ for the two cases. Based on the error
object, it is not possible to determine
whether the port was open or not!
The same applies to <img>
tags.
The following code will not reveal error information that helps us to infer whether the port was open or not:
var img = new Image();
img.onerror = function (error) {
console.error("Image error observed:", error);
};
img.onload = img.onerror;
img.src = "http://127.0.0.1:8889/";
There is simply not much information in the onerror
kind of event messages available in JavaScript.
What to do?
Yes you guessed correctly.
We will attempt to check if we can detect open ports by measuring the response times ;)
About Timing Measurements in JavaScript
The most precise time measurements can be obtained via performance.now().
One problem is that performance.now()
has reduced accuracy as the following Hacker News discussion states. If I am not mistaken, accuracy was reduced to prevent Spectre and Meltdown kind of bugs.
The following snippet showcases performance.now()
accuracy:
const results = []
let then = 0
for(let i = 0; i < 500; i++) {
const now = performance.now()
if (Math.abs(now - then) > 1e-6) {
results.push(now)
then = now
}
}
console.log(results.join("\n"))
Based on the script above, it seems that the accuracy is fine grained enough for our use case.
To measure network and socket timeouts, we need single digit millisecond accuracy. According to the hacker news link above, chrome has accuracy of accurate to 100us. This is more than enough for our use case.
Port Scanning with Web Sockets
My first attempt was to use WebSockets to conduct localhost port scanning. I came up with the following JavaScript that measures WebSocket connection timeouts:
// start local server with
// python -m http.server --bind 127.0.0.1 8888
var checkPort = function(port) {
var t0 = performance.now()
// a random appendix to the URL to prevent caching
var random = Math.random().toString().replace('0.', '').slice(0, 7)
var ws = new WebSocket("ws://127.0.0.1:" + port + '/' + random)
var status = null;
ws.onerror = function() {
status = 'onerror: ' + (performance.now() - t0)
}
ws.onclose = function() {
status = 'onclose: ' + (performance.now() - t0)
}
setTimeout(() => {
console.log(status)
ws.close()
}, 200)
}
checkPort(8888)
The idea is the following: I want to test if the above snippet yields significantly different timeouts for a URL that points to a open TCP service compared to URL with a port where no service is running.
First, I started a simple HTTP server on the port 8888 with the command
python -m http.server --bind 127.0.0.1 8888
The I launched the browser, navigated to incolumitas.com
and pasted the above script into the console.
After repeating this step 10 times (open browser, navigate to site, paste code and take time measurement, close browser), I got the following timeouts:
onclose: 13.179999999920256
onclose: 9.160000000520085
onclose: 17.110000000684522
onclose: 7.840000000214786
onclose: 8.205000000089058
onclose: 15.51000000017666
onclose: 7.150000000365253
onclose: 13.845000000401342
onclose: 17.30500000030588
onclose: 9.01499999963562
And then I did the same process with the port 8889, where no service is running. I got the following timeouts after 10 runs:
onclose: 7.255000000441214
onclose: 5.694999999832362
onclose: 8.78999999986263
onclose: 6.68500000028871
onclose: 11.080000000220025
onclose: 6.844999999884749
onclose: 8.659999999508727
onclose: 7.13999999970838
onclose: 8.420000000114669
onclose: 5.494999999427819
There is a slight difference in timings, but it's not a vast difference. Based on timing, you cannot really distinguish whether a port is open or not.
But why did I do this process manually? Why did I restart the browser after each measurement taken?
The reason is, that Chromium makes it very hard to determine whether a port is open or closed by considering time measurements.
Furthermore, once a socket is created for a (host, port)
pair, this socket is shared among normal HTTP connections. The document "WebSocket Throttling Design" states:
The new WebSocket stack re-uses the HTTP stack for its handshake.
and
The major issue with this design as it stands is that proxy connections for WebSockets share the ConnectionPoolManager with direct connections.
A Dive into the Chromium WebSocket Source Code
For example, you can look into the Chrome WebSocket source code:
Those are interesting sections in the file websocket_stream.cc
. There a timeout interval variable is defined. The mechanism is intended to make it hard for JavaScript programs to recognize the timeout cause.
// The timeout duration of WebSocket handshake.
// It is defined as the same value as the TCP connection timeout value in
// net/socket/websocket_transport_client_socket_pool.cc to make it hard for
// JavaScript programs to recognize the timeout cause.
const int kHandshakeTimeoutIntervalInSeconds = 240;
This is the Start()
method that starts a WebSocket connection.
void Start(std::unique_ptr<base::OneShotTimer> timer) {
DCHECK(timer);
base::TimeDelta timeout(base::TimeDelta::FromSeconds(
kHandshakeTimeoutIntervalInSeconds));
timer_ = std::move(timer);
timer_->Start(FROM_HERE, timeout,
base::BindOnce(&WebSocketStreamRequestImpl::OnTimeout,
base::Unretained(this)));
url_request_->Start();
}
So what kind of timer is in the variable timer_
?
// A timer for handshake timeout.
std::unique_ptr<base::OneShotTimer> timer_;
A brief look into /master/base/timer/timer.h reveals what this timer does:
As the names suggest, OneShotTimer calls you back once after a time delay expires.
Therefore, this timer is used to fire when a timeout occurs in the WebSocket connection.
Then, we have a look into websocket_transport_client_socket_pool.cc and we see the method
void WebSocketTransportClientSocketPool::InvokeUserCallbackLater(
ClientSocketHandle* handle,
CompletionOnceCallback callback,
int rv) {
DCHECK(!pending_callbacks_.count(handle));
pending_callbacks_.insert(handle);
base::ThreadTaskRunnerHandle::Get()->PostTask(
FROM_HERE,
base::BindOnce(&WebSocketTransportClientSocketPool::InvokeUserCallback,
weak_factory_.GetWeakPtr(), handle, std::move(callback),
rv));
}
The method InvokeUserCallbackLater()
is invoked in all the cases:
- When a WebSocket connection is successful
- when a WebSocket connection failed because the port is closed
- when a WebSocket connection failed because the service does not speak the same protocol...
Top Down Approach
However, we are interested in the logic that aborts the control flow when the connection could not be established.
Put differently, where does the chrome source code decide that on this port is not running a valid web socket service?
So we have to scan the chrome WebSocket source code for the generation of this error message: Error during WebSocket handshake: Unexpected response code: 404
.
After a quick search in the GitHub chrome source code mirror, we find the location to be in the file websocket_basic_handshake_stream.cc on line 464:
switch (response_code) {
case HTTP_SWITCHING_PROTOCOLS:
return ValidateUpgradeResponse(headers);
// We need to pass these through for authentication to work.
case HTTP_UNAUTHORIZED:
case HTTP_PROXY_AUTHENTICATION_REQUIRED:
return OK;
// Other status codes are potentially risky (see the warnings in the
// WHATWG WebSocket API spec) and so are dropped by default.
default:
// A WebSocket server cannot be using HTTP/0.9, so if we see version
// 0.9, it means the response was garbage.
// Reporting "Unexpected response code: 200" in this case is not
// helpful, so use a different error message.
if (headers->GetHttpVersion() == HttpVersion(0, 9)) {
OnFailure("Error during WebSocket handshake: Invalid status line",
ERR_FAILED, base::nullopt);
} else {
OnFailure(base::StringPrintf("Error during WebSocket handshake: "
"Unexpected response code: %d",
headers->response_code()),
ERR_FAILED, headers->response_code());
}
result_ = HandshakeResult::INVALID_STATUS;
return ERR_INVALID_RESPONSE;
}
The method OnFailure()
is called, which is defined in the file websocket_stream.cc on line 151:
void OnFailure(const std::string& message,
int net_error,
base::Optional<int> response_code) override {
if (api_delegate_)
api_delegate_->OnFailure(message, net_error, response_code);
failure_message_ = message;
failure_net_error_ = net_error;
failure_response_code_ = response_code;
}
It does not look like the method OnFailure()
is delayed or fired with a timer. Therefore, it should be possible to notify timing differences.
Statistically significant Tests
Well, now we have learned the following two things:
- When we reuse sockets in Chromium, we will get skewed results. It is mandatory to either restart the browser, or at least close the socket with
ws.close()
. OnFailure()
is not artificially delayed. Therefore, there should be slight differences in timing when making a WebSocket connection to a closed port compared to a open port.
The following program attempts to connect N = 30
times to a open port and 30 times to a (likely) closed port.
After each attempt, the socket is closed, before a new connection is made.
var timePort = function(port) {
return new Promise((resolve, reject) => {
var t0 = performance.now()
// a random appendix to the URL to prevent caching
var random = Math.random().toString().replace('0.', '').slice(0, 7)
var ws = new WebSocket("ws://127.0.0.1:" + port + '/' + random)
ws.onerror = function() {
var elapsed = (performance.now() - t0)
// close the socket before we return
ws.close()
resolve(parseFloat(elapsed.toFixed(3)))
}
})
}
const port = 8888;
const N = 30;
(async () => {
var timings = [];
for (var i = 0; i < N; i++) {
timings.push(await timePort(port))
}
timings.sort((a, b) => a - b);
console.log(timings)
})();
The response times are plotted in a histogram, to visually show that there is a significant difference in response times. I used chart.js
.
The scale is logarithmic. It is very easy to spot that heavy throttling takes place.
This is very weird and inconsistent behavior. It seems like there is only a consistent pattern in the first 7 requests, then the open/closed property doesn't seem to correlate anymore.
And then I did the same for the Firefox browser (but only with 12 measurements, because throttling kicks in and the delays are becoming large):
What we see here, is that closed ports seem to be taking more time than open ports.
Those slides explain exactly what counter measures are employed against browser based port scanning. There is definitely throttling happening here.
Statistics with Image Tags
Port scanning can also be attempted by making requests with Image tags. This is the test that I used:
var timePortImage = function(port) {
return new Promise((resolve, reject) => {
var t0 = performance.now()
// a random appendix to the URL to prevent caching
var random = Math.random().toString().replace('0.', '').slice(0, 7)
var img = new Image;
img.onerror = function() {
var elapsed = (performance.now() - t0)
// close the socket before we return
resolve(parseFloat(elapsed.toFixed(3)))
}
img.src = "http://127.0.0.1:" + port + '/' + random + '.png'
})
}
const portOpen = 8888;
const portClosed = 9657;
const N = 30;
(async () => {
var timingsOpen = [];
var timingsClosed = [];
for (var i = 0; i < N; i++) {
timingsOpen.push(await timePortImage(portOpen))
timingsClosed.push(await timePortImage(portClosed))
}
console.log(JSON.stringify(timingsOpen))
console.log(JSON.stringify(timingsClosed))
})();
Port scanning with Image tags on Chrome with N=20
and service python -m http.server --bind localhost 8888
.
Another sample of port scanning with Image tags on Chrome with N=30
with a different HTTP server.
And a third example of port scanning with Image tags on Chrome with N=30
with nginx
running as a service on localhost:3333
.
But what happens when the scanned service (open port) is not a HTTP server? What if it is, lets say a unrelated TCP service?
This time, we will simply use netcat
to simulate an arbitrary TCP service. There is no response from netcat
on any incoming message.
We use the command ncat -l 4444 --keep-open --exec "/bin/cat"
to launch a simple TCP echo server.
Conclusion
As the plots above demonstrate, it does not matter if the scanned service is a toy HTTP server, real HTTP server (nginx) or any TCP service (netcat), we can see that the measured timings are significantly longer on open services!
Full Algorithm to Determine if a Port is open or not
Now we have enough information to design an algorithm that makes an very educated guess if a port is open or not.
Our algorithm is very very simple:
We want to determine if port p
is open or not.
We take N=30
measurements of port p
and N=30
measurements of a port q
that is very likely closed (lets say port q = 37857
).
If
- 80% of all measurements of
p
are larger thanq
measurements - And if the sum of all measurements of
p
are at least 1.3 times larger thanq
measurements
we consider the port p
to be open.
Of course our assumption is that q
is closed ;)
The full code is as follows:
// Author: Nikolai Tschacher
// tested on Chrome v86 on Ubuntu 18.04
var portIsOpen = function(hostToScan, portToScan, N) {
return new Promise((resolve, reject) => {
var portIsOpen = 'unknown';
var timePortImage = function(port) {
return new Promise((resolve, reject) => {
var t0 = performance.now()
// a random appendix to the URL to prevent caching
var random = Math.random().toString().replace('0.', '').slice(0, 7)
var img = new Image;
img.onerror = function() {
var elapsed = (performance.now() - t0)
// close the socket before we return
resolve(parseFloat(elapsed.toFixed(3)))
}
img.src = "http://" + hostToScan + ":" + port + '/' + random + '.png'
})
}
const portClosed = 37857; // let's hope it's closed :D
(async () => {
var timingsOpen = [];
var timingsClosed = [];
for (var i = 0; i < N; i++) {
timingsOpen.push(await timePortImage(portToScan))
timingsClosed.push(await timePortImage(portClosed))
}
var sum = (arr) => arr.reduce((a, b) => a + b);
var sumOpen = sum(timingsOpen);
var sumClosed = sum(timingsClosed);
var test1 = sumOpen >= (sumClosed * 1.3);
var test2 = false;
var m = 0;
for (var i = 0; i <= N; i++) {
if (timingsOpen[i] > timingsClosed[i]) {
m++;
}
}
// 80% of timings of open port must be larger than closed ports
test2 = (m >= Math.floor(0.8 * N));
portIsOpen = test1 && test2;
resolve([portIsOpen, m, sumOpen, sumClosed]);
})();
});
}
// how to use
portIsOpen('localhost', 8888, 30).then((res) => {
let [isOpen, m, sumOpen, sumClosed] = res;
console.log('Is localhost:8888 open? ' + isOpen);
})