mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
Prevent savedata corruption and denial-of-service by adding four layers of protection to the save pipeline: - Bounded decompression (nullcomp.DecompressWithLimit): caps output size to prevent OOM from crafted payloads that expand to exhaust memory - Bounds-checked delta patching (deltacomp.ApplyDataDiffWithLimit): validates offsets before writing, returns errors for negative offsets, truncated patches, and oversized output; ApplyDataDiff now returns original data on error instead of partial corruption - Size limits on save handlers: rejects compressed payloads >512KB and decompressed data >1MB before processing; applied to main savedata, platedata, and platebox diff paths - Rotating savedata backups: 3 slots per character with 30-minute interval, snapshots the previous state before overwriting, backed by new savedata_backups table (migration 0007)
243 lines
8.1 KiB
Go
243 lines
8.1 KiB
Go
package channelserver
|
|
|
|
import (
|
|
"erupe-ce/common/stringsupport"
|
|
cfg "erupe-ce/config"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"erupe-ce/common/byteframe"
|
|
"erupe-ce/network/mhfpacket"
|
|
"erupe-ce/server/channelserver/compression/deltacomp"
|
|
"erupe-ce/server/channelserver/compression/nullcomp"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Save data size limits.
|
|
// The largest known decompressed savedata is ZZ at ~147KB. We use generous
|
|
// ceilings to accommodate unknown versions while still catching runaway data.
|
|
const (
|
|
saveDataMaxCompressedPayload = 524288 // 512KB max compressed payload from client
|
|
saveDataMaxDecompressedPayload = 1048576 // 1MB max decompressed savedata
|
|
)
|
|
|
|
func handleMsgMhfSavedata(s *Session, p mhfpacket.MHFPacket) {
|
|
pkt := p.(*mhfpacket.MsgMhfSavedata)
|
|
|
|
if len(pkt.RawDataPayload) > saveDataMaxCompressedPayload {
|
|
s.logger.Warn("Savedata payload exceeds size limit",
|
|
zap.Int("len", len(pkt.RawDataPayload)),
|
|
zap.Int("max", saveDataMaxCompressedPayload),
|
|
zap.Uint32("charID", s.charID),
|
|
)
|
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
|
return
|
|
}
|
|
|
|
characterSaveData, err := GetCharacterSaveData(s, s.charID)
|
|
if err != nil {
|
|
s.logger.Error("failed to retrieve character save data from db", zap.Error(err), zap.Uint32("charID", s.charID))
|
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
|
return
|
|
}
|
|
// Snapshot current house tier before applying the update so we can
|
|
// restore it if the incoming data is corrupted (issue #92).
|
|
prevHouseTier := make([]byte, len(characterSaveData.HouseTier))
|
|
copy(prevHouseTier, characterSaveData.HouseTier)
|
|
|
|
// Var to hold the decompressed savedata for updating the launcher response fields.
|
|
if pkt.SaveType == 1 {
|
|
// Diff-based update.
|
|
// diffs themselves are also potentially compressed
|
|
diff, err := nullcomp.DecompressWithLimit(pkt.RawDataPayload, saveDataMaxDecompressedPayload)
|
|
if err != nil {
|
|
s.logger.Error("Failed to decompress diff", zap.Error(err))
|
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
|
return
|
|
}
|
|
// Perform diff with bounds checking.
|
|
s.logger.Info("Diffing...")
|
|
patched, err := deltacomp.ApplyDataDiffWithLimit(diff, characterSaveData.decompSave, saveDataMaxDecompressedPayload)
|
|
if err != nil {
|
|
s.logger.Error("Failed to apply save diff", zap.Error(err), zap.Uint32("charID", s.charID))
|
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
|
return
|
|
}
|
|
characterSaveData.decompSave = patched
|
|
} else {
|
|
dumpSaveData(s, pkt.RawDataPayload, "savedata")
|
|
// Regular blob update.
|
|
saveData, err := nullcomp.DecompressWithLimit(pkt.RawDataPayload, saveDataMaxDecompressedPayload)
|
|
if err != nil {
|
|
s.logger.Error("Failed to decompress savedata from packet", zap.Error(err))
|
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
|
return
|
|
}
|
|
if s.server.erupeConfig.SaveDumps.RawEnabled {
|
|
dumpSaveData(s, saveData, "raw-savedata")
|
|
}
|
|
s.logger.Info("Updating save with blob")
|
|
characterSaveData.decompSave = saveData
|
|
}
|
|
characterSaveData.updateStructWithSaveData()
|
|
|
|
// Mitigate house theme corruption (issue #92): the game client
|
|
// sometimes sends house_tier as -1 (all 0xFF bytes), which causes
|
|
// the house theme to vanish on next login. If the new value looks
|
|
// corrupted, restore the previous value in both the struct and the
|
|
// decompressed blob so Save() persists consistent data.
|
|
if len(prevHouseTier) > 0 && characterSaveData.isHouseTierCorrupted() {
|
|
s.logger.Warn("Detected corrupted house_tier in save data, restoring previous value",
|
|
zap.Binary("corrupted", characterSaveData.HouseTier),
|
|
zap.Binary("restored", prevHouseTier),
|
|
zap.Uint32("charID", s.charID),
|
|
)
|
|
characterSaveData.restoreHouseTier(prevHouseTier)
|
|
}
|
|
|
|
s.playtime = characterSaveData.Playtime
|
|
s.playtimeTime = time.Now()
|
|
|
|
// Bypass name-checker if new
|
|
if characterSaveData.IsNewCharacter {
|
|
s.Name = characterSaveData.Name
|
|
}
|
|
|
|
// Force name to match session to prevent corruption detection false positives
|
|
// This handles SJIS/UTF-8 encoding differences and ensures saves succeed across all game versions
|
|
if characterSaveData.Name != s.Name && !characterSaveData.IsNewCharacter {
|
|
s.logger.Info("Correcting name mismatch in savedata", zap.String("savedata_name", characterSaveData.Name), zap.String("session_name", s.Name))
|
|
characterSaveData.Name = s.Name
|
|
characterSaveData.updateSaveDataWithStruct()
|
|
}
|
|
|
|
if characterSaveData.Name == s.Name || s.server.erupeConfig.RealClientMode <= cfg.S10 {
|
|
if err := characterSaveData.Save(s); err != nil {
|
|
s.logger.Error("Failed to save character data", zap.Error(err))
|
|
return
|
|
}
|
|
s.logger.Info("Wrote recompressed savedata back to DB.")
|
|
} else {
|
|
_ = s.rawConn.Close()
|
|
s.logger.Warn("Save cancelled due to corruption.")
|
|
if s.server.erupeConfig.DeleteOnSaveCorruption {
|
|
if err := s.server.charRepo.SetDeleted(s.charID); err != nil {
|
|
s.logger.Error("Failed to mark character as deleted", zap.Error(err))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if err := s.server.charRepo.SaveString(s.charID, "name", characterSaveData.Name); err != nil {
|
|
s.logger.Error("Failed to update character name in db", zap.Error(err))
|
|
}
|
|
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
|
}
|
|
|
|
func grpToGR(n int) uint16 {
|
|
var gr int
|
|
a := []int{208750, 593400, 993400, 1400900, 2315900, 3340900, 4505900, 5850900, 7415900, 9230900, 11345900, 100000000}
|
|
b := []int{7850, 8000, 8150, 9150, 10250, 11650, 13450, 15650, 18150, 21150, 23950}
|
|
c := []int{51, 100, 150, 200, 300, 400, 500, 600, 700, 800, 900}
|
|
|
|
for i := 0; i < len(a); i++ {
|
|
if n < a[i] {
|
|
if i == 0 {
|
|
for {
|
|
n -= 500
|
|
if n <= 500 {
|
|
if n < 0 {
|
|
i--
|
|
}
|
|
break
|
|
} else {
|
|
i++
|
|
for j := 0; j < i; j++ {
|
|
n -= 150
|
|
}
|
|
}
|
|
}
|
|
gr = i + 2
|
|
} else {
|
|
n -= a[i-1]
|
|
gr = c[i-1]
|
|
gr += n / b[i-1]
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return uint16(gr)
|
|
}
|
|
|
|
func dumpSaveData(s *Session, data []byte, suffix string) {
|
|
if !s.server.erupeConfig.SaveDumps.Enabled {
|
|
return
|
|
} else {
|
|
dir := filepath.Join(s.server.erupeConfig.SaveDumps.OutputDir, fmt.Sprintf("%d", s.charID))
|
|
path := filepath.Join(s.server.erupeConfig.SaveDumps.OutputDir, fmt.Sprintf("%d", s.charID), fmt.Sprintf("%d_%s.bin", s.charID, suffix))
|
|
_, err := os.Stat(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
err = os.MkdirAll(dir, os.ModePerm)
|
|
if err != nil {
|
|
s.logger.Error("Error dumping savedata, could not create folder")
|
|
return
|
|
}
|
|
} else {
|
|
s.logger.Error("Error dumping savedata")
|
|
return
|
|
}
|
|
}
|
|
err = os.WriteFile(path, data, 0644)
|
|
if err != nil {
|
|
s.logger.Error("Error dumping savedata, could not write file", zap.Error(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleMsgMhfLoaddata(s *Session, p mhfpacket.MHFPacket) {
|
|
pkt := p.(*mhfpacket.MsgMhfLoaddata)
|
|
if _, err := os.Stat(filepath.Join(s.server.erupeConfig.BinPath, "save_override.bin")); err == nil {
|
|
data, readErr := os.ReadFile(filepath.Join(s.server.erupeConfig.BinPath, "save_override.bin"))
|
|
if readErr != nil {
|
|
s.logger.Error("Failed to read save_override.bin", zap.Error(readErr))
|
|
} else {
|
|
doAckBufSucceed(s, pkt.AckHandle, data)
|
|
return
|
|
}
|
|
}
|
|
|
|
data, err := s.server.charRepo.LoadColumn(s.charID, "savedata")
|
|
if err != nil || len(data) == 0 {
|
|
s.logger.Warn("Failed to load savedata", zap.Uint32("charID", s.charID), zap.Error(err))
|
|
_ = s.rawConn.Close() // Terminate the connection
|
|
return
|
|
}
|
|
doAckBufSucceed(s, pkt.AckHandle, data)
|
|
|
|
decompSaveData, err := nullcomp.DecompressWithLimit(data, saveDataMaxDecompressedPayload)
|
|
if err != nil {
|
|
s.logger.Error("Failed to decompress savedata", zap.Error(err))
|
|
}
|
|
bf := byteframe.NewByteFrameFromBytes(decompSaveData)
|
|
_, _ = bf.Seek(88, io.SeekStart)
|
|
name := bf.ReadNullTerminatedBytes()
|
|
s.server.userBinary.Set(s.charID, 1, append(name, []byte{0x00}...))
|
|
s.Name = stringsupport.SJISToUTF8Lossy(name)
|
|
}
|
|
|
|
func handleMsgMhfSaveScenarioData(s *Session, p mhfpacket.MHFPacket) {
|
|
pkt := p.(*mhfpacket.MsgMhfSaveScenarioData)
|
|
saveCharacterData(s, pkt.AckHandle, "scenariodata", pkt.RawDataPayload, 65536)
|
|
}
|
|
|
|
func handleMsgMhfLoadScenarioData(s *Session, p mhfpacket.MHFPacket) {
|
|
pkt := p.(*mhfpacket.MsgMhfLoadScenarioData)
|
|
loadCharacterData(s, pkt.AckHandle, "scenariodata", make([]byte, 10))
|
|
}
|
|
|
|
func handleMsgSysAuthData(s *Session, p mhfpacket.MHFPacket) {}
|