Description
vnt is a peer-to-peer VPN. Each client opens its control and relay connection to
a server over one of three TLS-based transports (quic://, tcp://, wss://),
and the server relays traffic between clients that cannot reach each other
directly. The client chooses how it verifies the server's TLS certificate
through CertValidationMode, and that type defaults to the variant that verifies
nothing.
vnt-core/src/tls/verifier.rs:134:
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub enum CertValidationMode {
#[default]
InsecureSkipVerification,
VerifyFingerprint([u8; 32]),
Standard,
}
The default variant maps to InsecureVerifier, whose ServerCertVerifier
implementation accepts every certificate and every handshake signature without
looking at them (vnt-core/src/tls/verifier.rs:76):
impl ServerCertVerifier for InsecureVerifier {
fn verify_server_cert(/* ... */) -> Result<ServerCertVerified, Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(/* ... */) -> Result<HandshakeSignatureValid, Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(/* ... */) -> Result<HandshakeSignatureValid, Error> {
Ok(HandshakeSignatureValid::assertion())
}
// ...
}
build_verifier returns this verifier for the default mode, and
create_tls_client_config installs it through rustls' dangerous() builder
(verifier.rs:191 and :210).
Nothing forces the user past this default. --cert-mode and the cert_mode
config key are optional, and every path that assembles the client configuration
falls back to InsecureSkipVerification when it is absent.
src/args_config.rs:368:
let cert_mode = args
.cert_mode
.or_else(|| file.cert_mode.as_deref().and_then(|s| s.parse().ok()))
.unwrap_or(CertValidationMode::InsecureSkipVerification);
The same unwrap_or(CertValidationMode::InsecureSkipVerification) appears in the
two other builders (src/args_config.rs:487 and :524), in the JNI entry point
(vnt-jni/src/lib.rs:1522), and in the web service
(vnt-web/src/service_http.rs:3071). The bundled config.example.toml and the
web UI both ship cert_mode = "skip" as the default. So a plain
vnt2_cli --server tcp://host:port --network-code net runs with server
verification turned off, and it does so on all three TLS transports:
tcp.rs:66, wss.rs:80 and quic.rs:84 each build the client through
create_tls_client_config.
The project treats skipping verification as unacceptable everywhere except this
default. The subscription path rejects it outright
(vnt-core/src/managed_config.rs:81 bails with "subscription access forbids
skipping server certificate verification"), and the dynamic server-list fetch
over HTTPS keeps standard certificate and hostname checks
(vnt-core/src/utils/http_get.rs). The primary client connection is the one
place the safe rule was not applied.
Reproduction
An attacker who can answer at the server address (a rogue relay, a spoofed
dynamic:// list or DNS reply, or plain on-path interception) presents any
self-signed certificate. The victim starts vnt with no certificate mode, which
is the documented default:
vnt2_cli --server tcp://vpn.example:29872 --network-code demo --password s3cr3t
The client builds its TLS configuration through CertValidationMode::default(),
installs InsecureVerifier, and completes the handshake with the attacker's
certificate, because verify_server_cert returns
ServerCertVerified::assertion() without inspecting the presented chain.
The same result can be confirmed inside vnt-core, next to the crate's own
handshake test in vnt-core/src/tls/verifier.rs. It reuses that module's
try_handshake helper and generate_deterministic_cert, but gives the client
the default mode and the server an unrelated certificate:
#[tokio::test]
async fn default_mode_accepts_a_rogue_certificate() {
// A certificate the client has never seen and has no reason to trust.
let (rogue_cert, rogue_key) = generate_deterministic_cert("attacker").unwrap();
let server_config = Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![rogue_cert], rogue_key)
.unwrap(),
);
// The client configuration a user gets when no cert mode is set anywhere.
let client_config = Arc::new(
CertValidationMode::default()
.create_tls_client_config()
.unwrap(),
);
// The handshake still succeeds: InsecureVerifier accepts the certificate
// unconditionally, so the client trusts a key it never verified.
try_handshake(server_config, client_config)
.await
.expect("default (skip) mode completes the handshake with any certificate");
}
Impact
Because the client accepts any certificate, an on-path attacker can present a self-signed certificate and become the TLS peer, that is, the VPN server. What that buys the attacker depends on whether a network password is set:
- Without
--password, vnt does not encrypt node-to-node data; the server relays plaintext. (The README states that in this mode relayed traffic "can be viewed by the server".) A man-in-the-middle server therefore reads and injects every packet that crosses the tunnel. - With
--password, node payloads are encrypted end to end with ChaCha20-Poly1305 keyed bySHA256(password), so the relay cannot read them directly. But the client still sends the server akey_signvalue during registration, andkey_signisSHA256("KEY-BEGIN" || password || "KEY-END")truncated to 16 bytes (vnt-core/src/crypto/chacha20_poly1305.rs:23). That is an unsalted, single-pass hash of the password, so a MITM server that collects it can brute-force the password offline and then derive the sameSHA256(password)data key. The attacker also controls the whole control plane: virtual IP assignment, routing and hole-punching coordination, and, if--allow-ikev2or--allow-wireguardis set, plaintext IPv4 packets injected as if from the server.
This contradicts the project's stated security model, which advertises TLS
transport with certificate binding to "prevent server-spoofing attacks" (README).
The weakness is an insecure default (CWE-1188) that disables certificate
validation (CWE-295); it is present in vnt 2.0.0 through the current 2.0.9
release and on main.
Solution
Do not initialize CertValidationMode to a mode that verifies nothing. The
safest change is to remove the insecure default and require the user to choose a
mode for TLS transports, failing closed with an error that names the options when
none is given. skip should stay reachable only when the user sets it
explicitly, and startup should log it as a warning rather than the current info
line. Where the server uses a CA-issued certificate, standard is the natural
choice; where it uses the self-signed deterministic certificate, the server
already prints its SHA-256 fingerprint at startup, so finger:<hash> pins it.
The subscription path already enforces exactly this by rejecting skip, so the
fix is to hold the primary connection to the same rule.
Until a fix lands, always pass a verifying mode: --cert-mode standard with a
CA-issued server certificate, or --cert-mode finger:<sha256> to pin a
self-signed one. Set a high-entropy --password as well, so payloads stay
end-to-end encrypted against a rogue relay; note that a weak password can still
be recovered offline from key_sign.
Timeline
- 2026-09-23: Reported publicly as vnt-dev/vnt#202, found while scanning popular
repositories with my static analyzer. No response yet; vnt 2.0.9 and the
current
mainbranch are affected.