Skip to content

Salamander finalmask: Replace math/rand with crypto/rand in salt generation#6228

Open
IconHHw wants to merge 3 commits into
XTLS:mainfrom
IconHHw:SalamanderCryptoRand
Open

Salamander finalmask: Replace math/rand with crypto/rand in salt generation#6228
IconHHw wants to merge 3 commits into
XTLS:mainfrom
IconHHw:SalamanderCryptoRand

Conversation

@IconHHw
Copy link
Copy Markdown

@IconHHw IconHHw commented Jun 1, 2026

使用密码学安全的随机数生成器生成明文Salt

Copy link
Copy Markdown
Collaborator

@LjhAUMEM LjhAUMEM left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Fangliding
Copy link
Copy Markdown
Member

AI很喜欢写 rand.NewSource(time.Now().UnixNano()) 这种pattern

@LjhAUMEM
Copy link
Copy Markdown
Collaborator

LjhAUMEM commented Jun 2, 2026

原mathrand即使加了种子也是伪随机,虽然盐也是public可见的,但是换就换吧

@IconHHw
Copy link
Copy Markdown
Author

IconHHw commented Jun 2, 2026

math/rand 基于 Lagged Fibonacci generator,即使换成真随机种子,攻击者可以大胆假设目标就是基于 math/rand 的 Salamander,收集到足够多(假如无丢包或乱序是607个)连续 Salt 后,用607个连续Salt(每个8字节 Salt,恰好对应一次 Uint64() 调用),恢复出整个环形缓冲区作为当前状态,然后尝试预测后续所有 Salt,如成功预测则推断为 Salamander
(math/rand/v2 提供的 ChaCha8 伪随机数生成器应该可以解决这个问题)

@Fangliding
Copy link
Copy Markdown
Member

难绷

@IconHHw
Copy link
Copy Markdown
Author

IconHHw commented Jun 2, 2026

Sorry,我之前的描述不太准确,更正一下:Uint64()调用的结果会和 rngMask = (1 << 63) - 1 进行与运算,得到Int63()的调用结果(一个非负int64)用于填充Salt,填充时Salt只用到了其中的低56位,最高字节会被抛弃,也就是每7个Salt调用了8次Uint64(),而不是每个8字节 Salt 恰好对应一次 Uint64() 调用,攻击时需要考虑额外对齐问题

我让AI帮忙写了一个PoC(只取最初532个Salt,不考虑对齐问题)
package main

import (
	cRand "crypto/rand"
	"encoding/binary"
	"fmt"
	"math/rand"
)

const (
	rngLen = 607
	rngTap = 273
)

func main() {
	seedBytes := make([]byte, 8)
	cRand.Read(seedBytes)
	seed := int64(binary.BigEndian.Uint64(seedBytes))

	// ── Phase A: collect salts ───────────────────────────────────────
	// Use multiple-of-7 count so readPos aligns to 0, simplifying analysis.
	const numSalts = 532 // = 76 * 7
	src := rand.NewSource(seed)
	r := rand.New(src)
	salts := make([][8]byte, numSalts)
	for i := range salts {
		_, _ = r.Read(salts[i][:])
	}
	fmt.Printf("\nPhase A: Collected %d Read(8) salts (= %d × 7-salt cycles)\n",
		numSalts, numSalts/7)

	// ── Phase B: extract low 56 bits of underlying Uint64s ───────────
	// Read(8) byte layout (7-salt cycle, consumes exactly 8 Uint64s):
	//   Salt 0: U[0].b[0..6] + U[1].b[0]
	//   Salt 1: U[1].b[1..6] + U[2].b[0..1]
	//   Salt 2: U[2].b[2..6] + U[3].b[0..2]
	//   Salt 3: U[3].b[3..6] + U[4].b[0..3]
	//   Salt 4: U[4].b[4..6] + U[5].b[0..4]
	//   Salt 5: U[5].b[5..6] + U[6].b[0..5]
	//   Salt 6: U[6].b[6]    + U[7].b[0..6]
	//
	// Int63 = Uint64 & 0x7FFFFFFFFFFFFFFF. 7 bytes output per Int63 = 56 bits.
	// Bits 56–62 of Uint64 are NEVER output. Bit 63 is always 0.

	low56, numU64 := extractLow56(salts)
	fmt.Printf("Phase B: Extracted low 56 bits of %d Uint64 values\n", numU64)
	fmt.Printf("         (7 bits lost per Uint64: bits 56–62, bit 63 always 0)\n")

	// Verify low56 self-consistency via LFG recurrence:
	//   low56[k] = (low56[k-607] + low56[k-273]) mod 2^56   for k >= 607
	mismatches := 0
	for k := rngLen; k < numU64; k++ {
		expected := (low56[k-rngLen] + low56[k-rngTap]) & 0xFFFFFFFFFFFFFF
		if low56[k] != expected {
			mismatches++
		}
	}
	fmt.Printf("         LFG consistency check: %d/%d mismatches %s\n",
		mismatches, numU64-rngLen, check(mismatches == 0))

	// ── Phase C: reconstruct state from low56 alone ──────────────────
	// Set high 7 bits to 0. The low56 values form a CLOSED system:
	// future low56 depend ONLY on known low56 values, never on high7.
	// High7 values are temporarily in rand.readVal between Read calls
	// but are always discarded when readPos wraps to 0.
	//
	// vec reconstruction: vec[i] = Uint64[(333-i+607)%607]
	// We only have low56, so: vec[i] = low56[(333-i+607)%607]

	low56 = low56[numU64-rngLen:] // keep only the last 607 low56 values
	var vec [rngLen]int64
	for i := range vec {
		vec[i] = int64(low56[(333-i+rngLen)%rngLen])
	}
	feed, tap := 334, 0

	// After 532 = 76×7 salts, the Read state is aligned: readPos = 0.
	// readVal is stale but pos=0 means the next byte triggers a fresh Int63.
	var rv uint64 = 0
	var rp uint8 = 0

	// Create a fresh reference generator at same position.
	r2 := rand.New(rand.NewSource(seed))
	for i := 0; i < numSalts; i++ {
		var tmp [8]byte
		_, _ = r2.Read(tmp[:])
	}

	// ── Phase D: predict future salts ────────────────────────────────
	fmt.Println("\nPhase D: Predict next 10 Read(8) salts using low56-only state:")
	for i := 0; i < 10; i++ {
		var predicted [8]byte
		simulateRead8(&vec, &feed, &tap, &rv, &rp, predicted[:])

		var actual [8]byte
		_, _ = r2.Read(actual[:])

		ok := predicted == actual
		fmt.Printf("  Salt #%d: predicted=%x  actual=%x  %s\n",
			i, predicted, actual, check(ok))
	}
}

// ── Low-56-bit extraction from Read(8) salts ────────────────────────────

// extractLow56 reconstructs the low 56 bits of each underlying Uint64 from
// a sequence of Read(8) salts using the known 7-salt/8-Uint64 byte layout.
func extractLow56(salts [][8]byte) ([]uint64, int) {
	numUint64s := len(salts) * 8 / 7
	low56 := make([]uint64, numUint64s)

	for block := 0; block+7 <= len(salts); block += 7 {
		u := block * 8 / 7
		s := salts[block:]

		// Salt 0: bytes 0..6 from U[u], byte 7 from U[u+1] byte 0
		for j := 0; j < 7; j++ {
			low56[u] |= uint64(s[0][j]) << (j * 8)
		}
		low56[u+1] |= uint64(s[0][7]) // byte 0 of U[u+1]

		// Salt 1: bytes 0..5 from U[u+1] bytes 1..6, bytes 6..7 from U[u+2] bytes 0..1
		for j := 0; j < 6; j++ {
			low56[u+1] |= uint64(s[1][j]) << ((j + 1) * 8)
		}
		low56[u+2] |= uint64(s[1][6]) | uint64(s[1][7])<<8

		// Salt 2: bytes 0..4 from U[u+2] bytes 2..6, bytes 5..7 from U[u+3] bytes 0..2
		for j := 0; j < 5; j++ {
			low56[u+2] |= uint64(s[2][j]) << ((j + 2) * 8)
		}
		low56[u+3] |= uint64(s[2][5]) | uint64(s[2][6])<<8 | uint64(s[2][7])<<16

		// Salt 3: bytes 0..3 from U[u+3] bytes 3..6, bytes 4..7 from U[u+4] bytes 0..3
		for j := 0; j < 4; j++ {
			low56[u+3] |= uint64(s[3][j]) << ((j + 3) * 8)
		}
		low56[u+4] |= uint64(s[3][4]) | uint64(s[3][5])<<8 |
			uint64(s[3][6])<<16 | uint64(s[3][7])<<24

		// Salt 4: bytes 0..2 from U[u+4] bytes 4..6, bytes 3..7 from U[u+5] bytes 0..4
		for j := 0; j < 3; j++ {
			low56[u+4] |= uint64(s[4][j]) << ((j + 4) * 8)
		}
		for j := 0; j < 5; j++ {
			low56[u+5] |= uint64(s[4][j+3]) << (j * 8)
		}

		// Salt 5: bytes 0..1 from U[u+5] bytes 5..6, bytes 2..7 from U[u+6] bytes 0..5
		for j := 0; j < 2; j++ {
			low56[u+5] |= uint64(s[5][j]) << ((j + 5) * 8)
		}
		for j := 0; j < 6; j++ {
			low56[u+6] |= uint64(s[5][j+2]) << (j * 8)
		}

		// Salt 6: byte 0 from U[u+6] byte 6, bytes 1..7 from U[u+7] bytes 0..6
		low56[u+6] |= uint64(s[6][0]) << (6 * 8)
		for j := 0; j < 7; j++ {
			low56[u+7] |= uint64(s[6][j+1]) << (j * 8)
		}
	}

	return low56, numUint64s
}

// ── LFG core ────────────────────────────────────────────────────────────

func stepUint64(vec *[rngLen]int64, feed, tap *int) uint64 {
	*tap--
	if *tap < 0 {
		*tap += rngLen
	}
	*feed--
	if *feed < 0 {
		*feed += rngLen
	}
	v := uint64(vec[*feed]) + uint64(vec[*tap])
	vec[*feed] = int64(v)
	return v
}

// simulateRead8 exactly replicates Rand.Read(8) on the given LFG state.
// readVal and readPos persist across calls (same as Rand.readVal / Rand.readPos).
//
// Even though our vec stores only low56 (high7=0), the simulation is exact
// because Read output bytes come from Int63 low 56 bits. The high7 spill
// in readVal is always discarded when readPos wraps to 0.
func simulateRead8(vec *[rngLen]int64, feed, tap *int, readVal *uint64, readPos *uint8, out []byte) {
	for b := 0; b < len(out); b++ {
		if *readPos == 0 {
			*readVal = stepUint64(vec, feed, tap) & 0x7FFFFFFFFFFFFFFF
			*readPos = 7
		}
		out[b] = byte(*readVal)
		*readVal >>= 8
		*readPos--
	}
}

// ── Display helpers ─────────────────────────────────────────────────────

func check(ok bool) string {
	if ok {
		return "✓"
	}
	return "✗ FAIL"
}

func repeat(c byte, n int) []byte {
	b := make([]byte, n)
	for i := range b {
		b[i] = c
	}
	return b
}

@RPRX
Copy link
Copy Markdown
Member

RPRX commented Jun 2, 2026

@IconHHw 修一下 fmt

@IconHHw
Copy link
Copy Markdown
Author

IconHHw commented Jun 3, 2026

@IconHHw 修一下 fmt

好的,已修改

@RPRX
Copy link
Copy Markdown
Member

RPRX commented Jun 3, 2026

看了下还有很多 math/rand,比如 Sudoku 你看看,不过如果是需要对端可预测的 rand 那确实不能真随机

@IconHHw
Copy link
Copy Markdown
Author

IconHHw commented Jun 3, 2026

transport\internet\finalmask\sudoku\table.go:需要根据预共享密钥,与对端生成一致的随机序列,不宜修改;

transport\internet\finalmask\sudoku\codec.go:随机填充用到了math/rand,安全起见我改成math/rand/v2了;

common\utils\browser.go:该方法用 CPU 信息(型号、核心数、缓存行等)的 FNV hash 作为种子为每台机器确定性地生成相同的随机序列,似乎保持现状比较合适;

common\dice\dice.go、common\session\session.go、proxy\hysteria\client.go、transport\internet\finalmask\xicmp\client.go、transport\internet\hysteria\config.go、transport\internet\hysteria\congestion\bbr\bbr_sender.go、transport\internet\hysteria\dialer.go、transport\internet\hysteria\udphop\conn.go、transport\internet\splithttp\dialer.go:Go 1.22+以后,math/rand的包级函数已经默认使用了ChaCha8算法,似乎不太需要额外修改

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants