Files
shater/experimental/libbox/oom_report.go
T
omarandClaude Fable 5 bc2b53069a fix(security): audit remediation — file perms, const-time auth, leaks, CSPRNG
Backend audit fixes (upstream-file edits wrapped in // lx: markers):

- experimental/libbox oom_report.go/report.go: OOM reports + configuration.json
  (server secrets/keys) were written world-writable — 0o777 dirs / 0o666 files
  → 0o700 / 0o600. [sec-perms]
- daemon/server.go + experimental/libbox/command_server.go: gRPC auth secret
  compared with != (timing oracle) → crypto/subtle.ConstantTimeCompare.
  [sec-consttime]
- service/oomkiller/timer.go: network-extension cleanupTriggered logic was
  inverted, so FreeOSMemory was never called after a trigger; flip both
  assignments so a trigger schedules the deferred free and the next poll runs +
  clears it. [sec-oomcleanup]
- transport/v2rayxhttp/client.go (lx-native file): session id used math/rand →
  crypto/rand, matching Xray's uuid.New() entropy and removing the spoof surface.
- daemon/started_service_tailscale_ssh.go: forwardSSHAgentChannel leaked a
  goroutine + the ssh-agent fd on every closed session (second io.Copy blocked
  on an idle agent Read forever); tie both copies + the session ctx to a
  cancel that closes both ends. [sec-sshagent]
- daemon/managed_service.go: TriggerOOMReport had no gate — rate-limit to
  1/min so an authenticated client can't spin secret-bearing dumps. [sec-oomgate]
- route/reachability_lx.go (lx idle-suspend file): idle tick read r.idleStop in
  select while stopIdleSuspend niled it after close (race + goroutine leak on
  Close-during-tick); pass the stop channel to the loop by value.

go build ./... (default) and the D9 shaterd linux build (tags
with_quic,with_wireguard,with_utls,badlinkname,tfogo_checklinkname0,with_xhttp,
with_awg,with_lx_command) are green; go vet clean (2 pre-existing unsafe.Pointer
warnings in TriggerDebugCrash/debug.go, untouched); go test ./route/...
./daemon/... ./service/oomkiller/... green incl. -race with with_lx_idle_suspend
and v2rayxhttp with with_xhttp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 23:34:50 +03:00

260 lines
7.1 KiB
Go

//go:build darwin || linux || windows
package libbox
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing-box/experimental/libbox/internal/oomprofile"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/service/oomkiller"
"github.com/sagernet/sing/common/byteformats"
"github.com/sagernet/sing/common/memory"
)
var oomReportProfiles = []string{
"allocs",
"block",
"goroutine",
"heap",
"mutex",
"threadcreate",
}
type oomReportMetadata struct {
reportMetadata
RecordedAt string `json:"recordedAt"`
MemoryUsage string `json:"memoryUsage"`
AvailableMemory string `json:"availableMemory,omitempty"`
// Heap
HeapAlloc string `json:"heapAlloc,omitempty"`
HeapObjects uint64 `json:"heapObjects,omitempty,string"`
HeapInuse string `json:"heapInuse,omitempty"`
HeapIdle string `json:"heapIdle,omitempty"`
HeapReleased string `json:"heapReleased,omitempty"`
HeapSys string `json:"heapSys,omitempty"`
// Stack
StackInuse string `json:"stackInuse,omitempty"`
StackSys string `json:"stackSys,omitempty"`
// Runtime metadata
MSpanInuse string `json:"mSpanInuse,omitempty"`
MSpanSys string `json:"mSpanSys,omitempty"`
MCacheSys string `json:"mCacheSys,omitempty"`
BuckHashSys string `json:"buckHashSys,omitempty"`
GCSys string `json:"gcSys,omitempty"`
OtherSys string `json:"otherSys,omitempty"`
Sys string `json:"sys,omitempty"`
// GC & runtime
TotalAlloc string `json:"totalAlloc,omitempty"`
NumGC uint32 `json:"numGC,omitempty,string"`
NumGoroutine int `json:"numGoroutine,omitempty,string"`
NextGC string `json:"nextGC,omitempty"`
LastGC string `json:"lastGC,omitempty"`
}
type oomReporter struct {
startedService *daemon.StartedService
}
var _ oomkiller.OOMReporter = (*oomReporter)(nil)
func (r *oomReporter) WriteReport(memoryUsage uint64) error {
draftPath := filepath.Join(sWorkingPath, "oom_draft")
draftInfo, err := os.Stat(draftPath)
if err != nil {
if !os.IsNotExist(err) {
return err
}
draftInfo = nil
}
reportsDir := filepath.Join(sWorkingPath, "oom_reports")
// lx:begin sec-perms
// OOM reports embed the config snapshot (server secrets, keys) and logs;
// keep the tree owner-only (0700 dirs / 0600 files) instead of 0777/0666.
err = os.MkdirAll(reportsDir, 0o700)
// lx:end sec-perms
if err != nil {
return err
}
chownReport(reportsDir)
destPath, err := nextAvailableReportPath(reportsDir, time.Now().UTC())
if err != nil {
return err
}
err = r.writeSnapshot(destPath, memoryUsage)
if err != nil {
return err
}
return discardDraftIfCurrent(draftPath, draftInfo)
}
func (r *oomReporter) WriteDraft(memoryUsage uint64) error {
draftPath := filepath.Join(sWorkingPath, "oom_draft")
os.RemoveAll(draftPath)
return r.writeSnapshot(draftPath, memoryUsage)
}
func (r *oomReporter) DiscardDraft() error {
draftPath := filepath.Join(sWorkingPath, "oom_draft")
return os.RemoveAll(draftPath)
}
func discardDraftIfCurrent(draftPath string, draftInfo os.FileInfo) error {
if draftInfo == nil {
return nil
}
currentInfo, err := os.Stat(draftPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if !os.SameFile(draftInfo, currentInfo) {
return nil
}
return os.RemoveAll(draftPath)
}
func (r *oomReporter) writeSnapshot(destPath string, memoryUsage uint64) error {
now := time.Now().UTC()
// lx:begin sec-perms
err := os.MkdirAll(destPath, 0o700)
// lx:end sec-perms
if err != nil {
return err
}
chownReport(destPath)
for _, name := range oomReportProfiles {
writeOOMProfile(destPath, name)
}
writeReportFile(destPath, "cmdline", []byte(strings.Join(os.Args, "\000")))
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
metadata := oomReportMetadata{
reportMetadata: baseReportMetadata(),
RecordedAt: now.Format(time.RFC3339),
MemoryUsage: byteformats.FormatMemoryBytes(memoryUsage),
// Heap
HeapAlloc: byteformats.FormatMemoryBytes(memStats.HeapAlloc),
HeapObjects: memStats.HeapObjects,
HeapInuse: byteformats.FormatMemoryBytes(memStats.HeapInuse),
HeapIdle: byteformats.FormatMemoryBytes(memStats.HeapIdle),
HeapReleased: byteformats.FormatMemoryBytes(memStats.HeapReleased),
HeapSys: byteformats.FormatMemoryBytes(memStats.HeapSys),
// Stack
StackInuse: byteformats.FormatMemoryBytes(memStats.StackInuse),
StackSys: byteformats.FormatMemoryBytes(memStats.StackSys),
// Runtime metadata
MSpanInuse: byteformats.FormatMemoryBytes(memStats.MSpanInuse),
MSpanSys: byteformats.FormatMemoryBytes(memStats.MSpanSys),
MCacheSys: byteformats.FormatMemoryBytes(memStats.MCacheSys),
BuckHashSys: byteformats.FormatMemoryBytes(memStats.BuckHashSys),
GCSys: byteformats.FormatMemoryBytes(memStats.GCSys),
OtherSys: byteformats.FormatMemoryBytes(memStats.OtherSys),
Sys: byteformats.FormatMemoryBytes(memStats.Sys),
// GC & runtime
TotalAlloc: byteformats.FormatMemoryBytes(memStats.TotalAlloc),
NumGC: memStats.NumGC,
NumGoroutine: runtime.NumGoroutine(),
NextGC: byteformats.FormatMemoryBytes(memStats.NextGC),
}
if memStats.LastGC > 0 {
metadata.LastGC = time.Unix(0, int64(memStats.LastGC)).UTC().Format(time.RFC3339)
}
availableMemory := memory.Available()
if availableMemory > 0 {
metadata.AvailableMemory = byteformats.FormatMemoryBytes(availableMemory)
}
writeReportMetadata(destPath, metadata)
copyConfigSnapshot(destPath)
writeOOMLog(destPath, r.startedService.SavedLog())
return nil
}
func writeOOMLog(destPath string, entries []*log.Entry) {
if len(entries) == 0 {
return
}
var buffer bytes.Buffer
for _, entry := range entries {
writeWithoutColors(&buffer, entry.Message)
buffer.WriteByte('\n')
}
writeReportFile(destPath, "go.log", buffer.Bytes())
}
func writeWithoutColors(buffer *bytes.Buffer, message string) {
start := 0
for index := 0; index < len(message); {
if message[index] != '\x1b' || index+1 >= len(message) || message[index+1] != '[' {
index++
continue
}
end := index + 2
for end < len(message) && message[end] != 'm' {
end++
}
if end >= len(message) {
break
}
buffer.WriteString(message[start:index])
index = end + 1
start = index
}
buffer.WriteString(message[start:])
}
func writeOOMProfile(destPath string, name string) {
filePath, err := oomprofile.WriteFile(destPath, name)
if err != nil {
return
}
chownReport(filePath)
}
func promoteOOMDraftAt(workingPath string) {
draftPath := filepath.Join(workingPath, "oom_draft")
info, err := os.Stat(draftPath)
if err != nil || !info.IsDir() {
return
}
reportsDir := filepath.Join(workingPath, "oom_reports")
initReportDir(reportsDir)
destPath, err := nextAvailableReportPath(reportsDir, info.ModTime().UTC())
if err != nil {
os.RemoveAll(draftPath)
return
}
err = os.Rename(draftPath, destPath)
if err != nil {
os.RemoveAll(draftPath)
return
}
chownReport(destPath)
}
func promoteOOMDraft() {
promoteOOMDraftAt(sWorkingPath)
}
func PromoteOOMDraft() {
promoteOOMDraft()
}
func PromoteOOMDraftAt(workingPath string) {
promoteOOMDraftAt(workingPath)
}