Blog ·
Why a vulnerability scanner should send zero bytes
The line between a check and an attack is the bytes you put on the wire. How to turn that promise into a number your tests can fail on, with the measurements.
By Provibr
If you run infrastructure for other people, you eventually want to know whether port 3306 on one of your customers' machines is answering the whole internet. It is a reasonable question, the customer would want the answer, and the moment you go looking you have become a party that sends unsolicited traffic to a machine under somebody else's contract.
What separates that from an intrusion is not your intent, and it is not a checkbox in your terms of service. It is what actually left the socket. Everything else is a story about what left the socket.
We built an exposure check into our agent recently, with a rule that started as a sentence in a design document: send no mutating query, no login attempt, no default credential, no exploit payload, no broad banner harvest. This is how that sentence became something a test can fail on, and what the resulting probes cost in bytes.
A promise in a comment is a promise with a shelf life
The problem with the sentence above is not that it is hard to follow. It is that it is easy to follow for about six months.
Somebody adds an EHLO to the SMTP probe so it can detect whether STARTTLS is offered, which is genuinely useful information. The existing tests still pass, because they assert on what the probe found and not on what it said. From that day the software sends protocol commands to customer machines, and the design document still says it does not. Nothing went red. Nothing ever will.
So we made it a number where a number is possible. Every socket the check opens is wrapped in a stream that counts bytes written, and the total travels back with the result. Four of the six kinds of probe have an exact figure asserted in the tests: zero for the two that send nothing, thirty two for SSH, eight for PostgreSQL. The other two cannot, because a client hello and an SMB negotiate vary in length with what is offered inside them; their bound is a shape instead, which is one message and nothing after it. Adding an EHLO to a probe asserted at zero is now a red test with a number in it.
Two details in that counter turned out to matter.
It counts writes and not reads. What the other side volunteers is not something we caused; what we send is. And it is a shared counter rather than a field on our own stream type, because the TLS probe hands its socket to a library which then writes its own handshake records over it. Without a shared counter, the one probe that uses somebody else's code would be the one probe going uncounted, which is precisely where a byte budget is hardest to reason about.
What each probe costs
| Probe | What we send | What is read |
|---|---|---|
| Open only | nothing, asserted at 0 bytes | nothing |
| Banner: FTP, Telnet, SMTP, MySQL | nothing, asserted at 0 bytes | one bounded greeting |
| SSH | one identification line, asserted at 32 bytes | the server identification line, then its algorithm list if it volunteers one |
| TLS | one client hello, length varies | server hello and certificate |
| SMB | one negotiate, length varies | the negotiate response |
| PostgreSQL | the SSL request, asserted at 8 bytes | one byte |
Nothing follows the opening. No second message, no keepalive, no completion of a handshake. Once the other side has spoken, the socket closes.
SSH: thirty two bytes, and why it cannot go further
The SSH protocol has both ends send an identification line first. Ours is one line of thirty two bytes, and it is the only thing the probe writes. After that it reads: the server's identification line, and then, if the server sends one unprompted, its key exchange message with the offered algorithms in it.
The important part is what is absent. The probe never sends its own key exchange message. That is not a policy, it is a structural bound: without it the key exchange cannot proceed, no session is ever established, and there is therefore no state in the protocol at which an authentication request could be sent. "No login attempt" stops being a rule that someone has to remember.
The identification line names the product rather than imitating a client. An administrator reading their authentication log should be able to see who connected. A neutral or spoofed string would make a legitimate check look like reconnaissance, which is a bad trade for the two seconds of obscurity it buys.
There is a cost, and it is worth naming because it shows up as a data quality issue rather than an error. Some servers wait for the client's key exchange message before sending their own, and the protocol permits that in either order. When that happens the probe gets a version string and no algorithm list. The coverage for that port is then partial, and it must be recorded as partial. If it were recorded as complete with an empty list, the rule engine would conclude that no outdated algorithms are offered, which is the worst possible output: a confident "safe" derived from an absence of data.
The protocols that speak first, and the temptation to answer
FTP, Telnet, SMTP and MySQL all greet you. The probe for those sends nothing at all: connect, read a bounded number of bytes, close. That is not a banner harvest, because there is no way to open such a port without receiving the greeting, and nothing was asked for.
The EHLO temptation is real and we decided against it. Knowing whether a mail server offers STARTTLS is genuinely valuable. It also requires sending a protocol command, and the rule was about facts the other side gives up unprompted. The consequence is that an SMTP finding lands at a lower severity and a lower confidence than it otherwise would. That is the honest price and it should be visible in the finding rather than argued away.
PostgreSQL is the interesting middle case. Eight bytes is the entire SSL request packet: a length and a magic number, which is the first thing any client sends and which is not a query, not a credential and not a login. The server answers with a single byte saying whether it will speak TLS. One byte of answer is enough to distinguish a database that requires encryption from one that does not, and there is no version of this check that sends less.
An open port is not a vulnerability
Port 445 answering is not SMB1 being enabled. Those are two separate facts and they need two separate fields.
Our probe records a state for the port, which is whether anybody is there, and at most one piece of evidence, which is what they said. A negotiate that comes back without an old dialect leaves you with an open port and nothing else, and that is a complete result rather than a partial one. The temptation to collapse the two into a single severity is strong because it produces a much more exciting dashboard.
Evidence hygiene belongs in the same paragraph, because a greeting is not a harmless string. FTP and SMTP banners carry the customer's hostname in practice, sometimes the hostname of their customer, and occasionally a path on disk. Storing them raw means your findings table becomes a place where personal and structural information about other people's networks accumulates, retained for as long as findings are retained, visible to anyone who can read a finding.
So what leaves the probe is a normalised protocol token, a version fragment cut out by a documented rule, the number of bytes read, and a hash of the raw bytes. The hash makes two scans comparable without anybody keeping the text. That last property is the one you miss if you skip it: without it, "did this change since last month" requires the thing you decided not to keep.
The sensor does not have opinions
One more separation, and it is the one that makes the rest maintainable.
The agent measures. It resolves no hostnames, keeps no port list of its own, and picks no targets: the address and the ports are written out by the platform, which checked that the address is currently allocated to that organisation and that server before it sent them. Given no targets, the agent measures nothing.
The judgement happens on the platform, from a rule set that can be read and versioned. This matters because a finding outlives the code that produced it. Six months from now somebody will ask why a scan in September was rated high, and the only way to answer that is to have the rules and the measurements as separate, individually retrievable things. If the severity were baked in at the sensor, the answer would be "because that version of the agent thought so", which is not an answer.
The same reasoning is why the agent connects outbound and is never listening, and why credentials stay on the host rather than travelling to us. A component that only ever reports what it measured is a component whose blast radius you can describe in a sentence.
What we would write down
- Turn the promise into a number. If your rule is about what you send, count what you send and assert on the count. A comment cannot fail.
- Prefer bounds that are structural. Not sending a key exchange message means authentication is unreachable, which is stronger than a rule saying not to authenticate.
- Let a probe be partial. Missing data must be visible as missing, because the alternative is a confident negative that nobody can audit.
- Keep "open" and "vulnerable" in different fields, and make the rule engine combine them somewhere you can read.
- Do not store raw banners. Normalise, hash, and keep the hash for comparison.
- Put the opinion on the platform, so that a judgement from last quarter is still explainable this quarter.
For clarity about our own product: this is one detector, it is behind an organisation level switch that is currently off, and it is not something you can buy today. The measurements above are from the implementation and its tests, not from a marketing page.
Built for Provibr, where the agent runs inside the customer's own network. The verification side of the same design, proving that a hostname belongs to the person asking, is in how to prove you own a hostname.