[OpenVPN] Framed data-channel keepalive ping reaches IP stack and terminates endpoint
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 38.1k
- Forks
- 4.6k
- Avg merge
- 19d 15h
- Merged PRs (30d)
- 1
Description
Operating system
macOS
System version
macOS 26.5.2 (25F84), arm64
Installation type
Original sing-box Command Line
If you are using a graphical client, please provide the version of the client.
Also reproduced in SFM 1.14.0-beta.1 (Build 1); minimal reproduction uses the original CLI.
Version
sing-box version 1.14.0-beta.1
Environment: go1.26.5 darwin/arm64
Tags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,with_tailscale,with_ccm,with_ocm,with_cloudflared,with_naive_outbound,with_usbip,with_openvpn,with_openconnect,badlinkname,tfogo_checklinkname0
Revision: 8bc6787c7ff785e5f6343241affdadd5ca239bd7
CGO: enabled
Description
An OpenVPN client using legacy LZO framing establishes successfully, then the endpoint terminates when the server sends a framed data-channel keepalive ping.
Expected behavior
The OpenVPN data-channel keepalive is consumed internally after compression/fragment framing is decoded, and is never delivered to the IP device.
Actual behavior
The endpoint reports write packet to device: invalid IP packet and enters the terminal error state.
I instrumented the rejected packet. After LZO framing is removed, it is exactly the standard 16-byte OpenVPN data-channel keepalive payload:
2a187bf3641eb4cb07ed2d0a981fc748
The apparent cause is the ordering between peer_data.go and client_data.go in sing-openvpn commit 64b754d1c277:
peer_data.gochecksopenVPNDataChannelPingPayloadbefore compression framing is decoded.- A framed ping (for example, the no-compress LZO marker followed by the ping) does not match at that stage.
dataChannelFraming.Decodelater removes the framing marker.- The restored 16-byte ping is queued as an incoming tunnel packet.
- sing-box's OpenVPN stack device rejects it as a non-IP packet and the client read loop terminates.
I tested compression_lzo values adaptive, asym, yes, and no; changing the client configuration does not fix the filtering-order issue.
A small patch that filters the keepalive again after framing decode, in both incoming receive paths, fixes the problem. With the patch, the same local reproduction stayed connected across multiple server ping intervals and HTTP/HTTPS transfers completed without an invalid packet error.
Reproduction
The following script is fully local and does not use TUN, a graphical client, or a remote VPN server.
Requirements:
- Original sing-box
1.14.0-beta.1CLI - OpenVPN
2.7.xbuilt with LZO support - OpenSSL and Python 3
Save as reproduce-openvpn-framed-ping.sh and run:
chmod +x reproduce-openvpn-framed-ping.sh
./reproduce-openvpn-framed-ping.sh /path/to/sing-box-1.14.0-beta.1
Complete reproduction script:
#!/usr/bin/env bash
set -euo pipefail
SING_BOX="${1:-sing-box}"
OPENVPN="${OPENVPN:-openvpn}"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/openvpn-framed-ping.XXXXXX")"
trap 'pkill -P $$ openvpn 2>/dev/null || true; rm -rf "$WORK"' EXIT
cd "$WORK"
cat > ca.cnf <<'CNF'
[req]
distinguished_name=dn
x509_extensions=v3_ca
prompt=no
[dn]
CN=OpenVPN Repro CA
[v3_ca]
basicConstraints=critical,CA:TRUE
keyUsage=critical,keyCertSign,cRLSign
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid:always,issuer
CNF
openssl req -x509 -newkey rsa:2048 -nodes -days 2 \
-keyout ca.key -out ca.crt -config ca.cnf >/dev/null 2>&1
cat > leaf.cnf <<'CNF'
[req]
distinguished_name=dn
req_extensions=v3_req
prompt=no
[dn]
CN=PLACEHOLDER
[v3_req]
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=PLACEHOLDER_USAGE
subjectAltName=DNS:PLACEHOLDER
CNF
sed 's/PLACEHOLDER/server/g; s/server_USAGE/serverAuth/' leaf.cnf > server.cnf
openssl req -new -newkey rsa:2048 -nodes -keyout server.key \
-out server.csr -config server.cnf >/dev/null 2>&1
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 2 -out server.crt -extfile server.cnf -extensions v3_req >/dev/null 2>&1
sed 's/PLACEHOLDER/client/g; s/client_USAGE/clientAuth/' leaf.cnf > client.cnf
openssl req -new -newkey rsa:2048 -nodes -keyout client.key \
-out client.csr -config client.cnf >/dev/null 2>&1
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 2 -out client.crt -extfile client.cnf -extensions v3_req >/dev/null 2>&1
cat > server.ovpn <<CFG
mode server
tls-server
dev null
proto udp
local 127.0.0.1
port 31194
ca $WORK/ca.crt
cert $WORK/server.crt
key $WORK/server.key
dh none
ifconfig-noexec
route-noexec
script-security 0
topology net30
ifconfig 10.8.0.1 10.8.0.2
push "topology net30"
push "ifconfig 10.8.0.2 10.8.0.1"
push "ping 2"
push "ping-restart 10"
ping 2
ping-restart 10
data-ciphers AES-256-GCM
comp-lzo adaptive
allow-compression yes
verb 4
CFG
python3 - <<'PY'
import json, pathlib
root = pathlib.Path.cwd()
config = {
"log": {"level": "debug", "timestamp": True},
"endpoints": [{
"type": "openvpn-client",
"tag": "openvpn",
"mode": "tls",
"server": "127.0.0.1",
"server_port": 31194,
"network": "udp",
"tls": {
"server_name": "server",
"certificate": (root / "ca.crt").read_text(),
"client_certificate": (root / "client.crt").read_text(),
"client_key": (root / "client.key").read_text(),
"remote_certificate_tls": "server"
},
"data_ciphers": ["AES-256-GCM"],
"compression_lzo": "asym",
"allow_compression": "asym"
}]
}
(root / "client.json").write_text(json.dumps(config, indent=2) + "\n")
PY
"$OPENVPN" --config "$WORK/server.ovpn" >server.log 2>&1 &
SERVER_PID=$!
sleep 1
echo "The client establishes, then should fail in about 2 seconds:"
"$SING_BOX" run -c "$WORK/client.json" 2>&1 | tee client.log
wait "$SERVER_PID"
The client establishes immediately and fails when the first framed server keepalive is delivered.
Logs
Stock sing-box 1.14.0-beta.1 client log:
+0800 2026-07-24 13:18:47 INFO endpoint/openvpn-client[openvpn]: tunnel established to 127.0.0.1:31194 over udp
+0800 2026-07-24 13:18:49 ERROR endpoint/openvpn-client[openvpn]: write packet to device: invalid IP packet
Instrumented error:
write packet to device: invalid IP packet: length=16 head=2a187bf3641eb4cb07ed2d0a981fc748
OpenVPN server confirms negotiated LZO framing and the 2-second timers:
Data Channel: cipher 'AES-256-GCM', peer-id: 0, compression: 'lzo'
Timers: ping 2, ping-restart 10
Supporter
- I am a sponsor
Integrity requirements
- I confirm that I have read the documentation, understand the meaning of all the configuration items I wrote, and did not pile up seemingly useful options or default values.
- I confirm that I have provided the server and client configuration files and process that can be reproduced locally, instead of a complicated client configuration file that has been stripped of sensitive data.
- I confirm that I have provided the simplest configuration that can be used to reproduce the error I reported, instead of depending on remote servers, TUN, graphical interface clients, or other closed-source software.
- I confirm that I have provided the complete configuration files and logs, rather than just providing parts I think are useful out of confidence in my own intelligence.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with peer_data.go and client_data.go in sing-openvpn, then trace dataChannelFraming.Decode in both incoming receive paths. Run the supplied reproduce-openvpn-framed-ping.sh script with OpenVPN 2.7.x and the specified sing-box binary. Done means the framed keepalive is consumed internally, the client remains connected across server ping intervals, and no invalid IP packet error occurs.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100