obfuscated2: only XOR bytes actually delivered on Read
Go / Lint (old) (push) Failing after 4m40s
Go / Lint (latest) (push) Failing after 4m39s
Go / Lint (old) (pull_request) Failing after 4m41s
Go / Lint (latest) (pull_request) Failing after 4m40s

Read called XORKeyStream(b, b) — XOR-ing the entire caller buffer even
when the underlying transport returned fewer bytes. AES-CTR's keystream
position is then advanced by len(b), but the peer only consumed n
bytes' worth of keystream. After a single short read the two
keystreams diverge for the lifetime of the connection, every
subsequent MTProto message decrypts to garbage, and the engine fails
with "consume message: decrypt: msg_key is invalid".

The faketls layer makes short reads routine: each Read returns at most
one TLS Application record's payload, regardless of how big the caller
buffer is. So in practice the stream desynced almost immediately on
high-traffic clients (active supergroups, post-relogin catch-up) and
intermittently on quiet ones.

Match the upstream gotd/td fix and only XOR the n bytes that came out
of the transport. Add a regression test (chunkConn delivers ciphertext
in 7-byte chunks; client reads through Obfuscated2.Read with a 128-byte
buffer; plaintext must round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Igor Artamonov
2026-05-01 12:33:44 +03:00
parent 4768065e72
commit ccb349f3d2
2 changed files with 88 additions and 1 deletions
+6 -1
View File
@@ -53,7 +53,12 @@ func (o *Obfuscated2) Read(b []byte) (int, error) {
return n, err
}
if n > 0 {
o.decrypt.XORKeyStream(b, b)
// IMPORTANT: only XOR the n bytes that were actually read.
// XOR-ing the full b advances the CTR keystream past where the
// server is and permanently desyncs the stream — every later
// MTProto message decrypts to garbage and the engine fails
// with "msg_key is invalid".
o.decrypt.XORKeyStream(b[:n], b[:n])
}
return n, err
}
@@ -0,0 +1,82 @@
package obfuscated2
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"io"
"testing"
"github.com/stretchr/testify/require"
)
// chunkConn delivers data from buf in chunks of at most chunkSize bytes.
type chunkConn struct {
buf *bytes.Buffer
chunkSize int
}
func (c *chunkConn) Read(p []byte) (int, error) {
if c.buf.Len() == 0 {
return 0, io.EOF
}
want := len(p)
if want > c.chunkSize {
want = c.chunkSize
}
return c.buf.Read(p[:want])
}
func (c *chunkConn) Write(p []byte) (int, error) { return len(p), nil }
// TestShortReadKeepsKeystreamAligned ensures that when the underlying
// transport returns fewer bytes than the caller asked for, the CTR
// keystream is only advanced by the bytes actually delivered.
//
// The previous implementation called XORKeyStream(b, b) instead of
// XORKeyStream(b[:n], b[:n]); after a single short read the client and
// server keystreams diverged and every subsequent MTProto message
// failed integrity (msg_key invalid).
func TestShortReadKeepsKeystreamAligned(t *testing.T) {
a := require.New(t)
key := bytes.Repeat([]byte{0x11}, 32)
iv := bytes.Repeat([]byte{0x22}, 16)
enc, err := aes.NewCipher(key)
a.NoError(err)
dec, err := aes.NewCipher(key)
a.NoError(err)
encStream := cipher.NewCTR(enc, iv)
decStream := cipher.NewCTR(dec, iv)
plaintext := bytes.Repeat([]byte("Hello, MTProxy! "), 50)
ciphertext := make([]byte, len(plaintext))
encStream.XORKeyStream(ciphertext, plaintext)
wire := &chunkConn{buf: bytes.NewBuffer(append([]byte(nil), ciphertext...)), chunkSize: 7}
o := &Obfuscated2{
conn: wire,
keys: keys{decrypt: decStream},
}
got := make([]byte, len(plaintext))
off := 0
for off < len(plaintext) {
end := off + 128
if end > len(got) {
end = len(got)
}
n, err := o.Read(got[off:end])
if err != nil && err != io.EOF {
t.Fatalf("read at off %d: %v", off, err)
}
if n == 0 {
t.Fatalf("zero-length read at off %d", off)
}
off += n
}
a.Equal(plaintext, got, "short reads must not desync the keystream")
}