Description
nps is an intranet tunneling proxy server (about 34k stars) that exposes forwarded services on public ports and is managed through a web UI. Its development stopped in 2021, but the tool is still widely deployed. Three defects share one theme: every secret that protects the proxy is recoverable.
The first is the comparison itself. Basic credentials are checked with plain
string equality, lib/common/util.go:71:
pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
return false
}
return pair[0] == user && pair[1] == passwd
CheckAuth guards the HTTP proxy endpoints that nps publishes to the
internet (server/proxy/base.go:67, called per host at
server/proxy/http.go:153). The web management login compares with the same
operator, for the administrator account
(web/controllers/login.go:61) and for every client web account
(web/controllers/login.go:82), and the tunnel key lookup compares digests
the same way (lib/file/db.go:127). Go string equality returns at the first
differing word, so all of these are timing oracles on secret material.
The second is the tunnel key design. A secret tunnel is addressed by a
32-byte key that is the unsalted MD5 of the tunnel password
(lib/crypt/crypt.go:59):
//Generate 32-bit MD5 strings
func Md5(s string) string {
h := md5.New()
h.Write([]byte(s))
return hex.EncodeToString(h.Sum(nil))
}
Visitors send those 32 bytes on the bridge connection and the server accepts
them by matching crypt.Md5(stored) == received
(lib/file/db.go:127, reached from bridge/bridge.go:282 for p2p and
server/server.go:72 for secret tunnels). The digest is therefore the
credential itself: capturing one handshake yields a working key, and MD5 does
nothing to slow down an offline dictionary attack on a human-chosen password.
The third is storage. The JSON database serializes tunnels, hosts and clients
with json.Marshal (lib/file/file.go:164, :170 and :176), and the
struct fields carry no json:"-" tags: VerifyKey (lib/file/obj.go:37),
WebPassword (obj.go:50) and Tunnel.Password (obj.go:137) all land in
the data files in cleartext. A tunnel even embeds its whole client object,
so tasks.json duplicates the client's key and web password as well.
Reproduction
Secrets on disk can be shown with the project's own store. This in-package test registers one client and one secret tunnel, writes the data files, and reads them back:
package file
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPocSecretsOnDisk(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "conf"), 0o700); err != nil {
t.Fatal(err)
}
db := NewJsonDb(dir)
client := NewClient("sup3r-s3cret-vkey", false, false)
client.Id = 1
client.WebUserName = "admin"
client.WebPassword = "Tr0ub4dor-3"
db.Clients.Store(client.Id, client)
db.StoreClientsToJsonFile()
tunnel := &Tunnel{Id: 1, Port: 8022, Mode: "secret",
Password: "tunnel-pass-123", Client: client}
db.Tasks.Store(tunnel.Id, tunnel)
db.StoreTasksToJsonFile()
clientsJson, _ := os.ReadFile(db.ClientFilePath)
tasksJson, _ := os.ReadFile(db.TaskFilePath)
t.Logf("clients.json: %s", clientsJson)
t.Logf("tasks.json: %s", tasksJson)
for _, secret := range []string{"sup3r-s3cret-vkey",
"Tr0ub4dor-3", "tunnel-pass-123"} {
if !strings.Contains(string(clientsJson), secret) &&
!strings.Contains(string(tasksJson), secret) {
t.Fatalf("secret %q not found in data files", secret)
}
}
}
Observed output, secrets verbatim in the data files (long records wrapped):
--- PASS: TestPocSecretsOnDisk (0.00s)
poc_test.go:36: clients.json: {"Cnf":{...},"Id":1,
"VerifyKey":"sup3r-s3cret-vkey",...,"WebUserName":"admin",
"WebPassword":"Tr0ub4dor-3",...}
poc_test.go:37: tasks.json: {"Id":1,"Port":8022,"Mode":"secret",
"Client":{...,"VerifyKey":"sup3r-s3cret-vkey",...,
"WebPassword":"Tr0ub4dor-3",...},"Password":"tunnel-pass-123",...}
The timing behavior was measured the same way, comparing a stored key against guesses with different common prefixes (50 million rounds each):
raw == common prefix 0: 3.0 ns/compare
raw == common prefix 8: 3.0 ns/compare
raw == common prefix 16: 3.0 ns/compare
raw == common prefix 24: 4.0 ns/compare
raw == common prefix 31: 4.0 ns/compare
raw == full match : 2.0 ns/compare
Go's runtime compares in word-sized chunks, so the leak is coarse: the mismatch position inside the final 8-byte word is visible as roughly a nanosecond, not per byte. On the real lookup path the compare sits inside about 280 ns of map iteration and hashing per call, so a remote attacker needs heavy averaging over many requests to lift the signal out of jitter. The measurement says: a defect that is real and measurable, not a free remote password recovery. The fix costs one line, which is why it should still be made.
Impact
Cleartext storage of sensitive information (CWE-312) is the main exposure. Anyone who can read the data directory, a backup of it, or exfiltrate the files through any other file-read flaw recovers, with no cracking at all:
- every tunnel password, which also gates visitor access to forwarded services;
- every web account password, including the administrator's web login;
- every client verify key, which is the credential npc clients use on the control connection. A leaked verify key lets the attacker impersonate the client, register tunnels, and route traffic through the victim's machine.
The unsalted MD5 key scheme (CWE-916) weakens the same boundary from the network side: the 32 bytes that open a secret tunnel are a plain MD5 digest, so a captured handshake needs no password, and a guessed password can be confirmed offline at full MD5 speed. The timing oracles (CWE-208) add a slow side channel on the same credentials. The primary chain needs local or backup access to the data files, which is why the overall rating is medium rather than high.
As supporting context for the same material: newly generated verify keys
come from crypt.GetRandomString(16) (lib/file/db.go:214), which seeds
math/rand with the wall-clock nanosecond
(lib/crypt/crypt.go:69), so even the "random" keys carry no more entropy
than a timestamp guess.
Solution
- Compare secrets in constant time with
crypto/subtle.ConstantTimeCompareat all three sites:CheckAuth, the web login handlers, and the MD5 lookup inGetTaskByMd5Password. Return zero on length mismatch first. - Stop writing secrets to disk. Either tag the three fields with
json:"-"and keep the secrets in a separate store with strict permissions, or at minimum restrict the data directory to the service user (mode 0700) and treat every copy ofconf/*.jsonas a full credential disclosure. - Replace the MD5-of-password scheme with a random 128-bit key per tunnel,
generated with
crypto/randrather than the timestamp-seeded generator, so that a captured key says nothing about any password and cannot be brute forced.
Until the project moves again (it has been dormant since October 2021), operators should assume the data files are passwords and protect them accordingly: restrict the directory, exclude it from world-readable backups, and rotate every tunnel password and verify key if a copy has ever left the host.
Timeline
- 2026-09-24: Reported publicly as ehang-io/nps#1339, found while scanning popular repositories with my static analyzer (ghoul). The repository has had no commit since 2021-10-09 and no release since v0.26.10, so no private disclosure attempt seemed useful.
References
- ehang-io/nps#1339 - Basic auth ==, tunnel passwords unsalted MD5, secrets serialized to disk
- ehang-io/nps - lib/common/util.go
- ehang-io/nps - lib/crypt/crypt.go
- ehang-io/nps - lib/file/file.go
- Timing attack - Wikipedia
- CWE-312: Cleartext Storage of Sensitive Information
- CWE-916: Use of Password Hash With Insufficient Computational Effort
- CWE-208: Observable Timing Discrepancy