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 by SHA256(password), so the relay cannot read them directly. But the client still sends the server a key_sign value during registration, and key_sign is SHA256("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 same SHA256(password) data key. The attacker also controls the whole control plane: virtual IP assignment, routing and hole-punching coordination, and, if --allow-ikev2 or --allow-wireguard is 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 main branch are affected.

References