fix: MMO 서버 로직 버그 6건 수정

1. PlayerId 스푸핑 방지: OnTransformPlayer, OnActionPlayer, OnStatePlayer에서
   브로드캐스트 전 packet.PlayerId = hashKey로 강제 교체

2. HP/MP 클라이언트 조작 방지: OnStatePlayer에서 범위 클램핑
   (0 ≤ Hp ≤ MaxHp, 0 ≤ Mp ≤ MaxMp)

3. CreateParty 파티원 등록 누락 수정:
   - memberIds 파라미터 사용 시 모든 멤버를 playerPartyMap에 등록
   - 리더 중복 추가 방지 (Contains 체크)

4. OnIntoChannel 채널 만석 유령 상태 방지:
   이전 채널 제거 후 새 채널 입장 실패 시 이전 채널로 복귀

5. HandleAuth async 경합 방지:
   authenticatingTokens HashSet으로 동일 토큰 동시 인증 차단

6. 레이드 맵 미반환 수정:
   TryReleaseRaidMap 헬퍼 추가, OnChangeMap/OnSessionDisconnected에서
   레이드 맵(1001+) 유저 0명 시 인스턴스 맵 해제

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 00:34:40 +09:00
parent 39ef81d48a
commit 7f2cd281da
2 changed files with 116 additions and 35 deletions

View File

@@ -18,6 +18,9 @@ public class GameServer : ServerBase
private readonly Dictionary<ushort, Action<NetPeer, int, byte[]>> packetHandlers;
private readonly UuidGenerator userUuidGenerator;
// 동일 토큰 동시 인증 방지
private readonly HashSet<string> authenticatingTokens = new();
public GameServer(int port, string connectionString) : base(port, connectionString)
{
packetHandlers = new Dictionary<ushort, Action<NetPeer, int, byte[]>>
@@ -103,6 +106,17 @@ public class GameServer : ServerBase
{
AccTokenPacket accTokenPacket = Serializer.Deserialize<AccTokenPacket>(new ReadOnlyMemory<byte>(payload));
string token = accTokenPacket.Token;
// 동일 토큰 동시 인증 방지
if (!authenticatingTokens.Add(token))
{
Log.Warning("[Server] 동일 토큰 동시 인증 시도 차단 PeerId={Id}", peer.Id);
peer.Disconnect();
return;
}
try
{
string username = "";
tokenHash.TryGetValue(token, out int hashKey);
if (hashKey <= 1000)
@@ -147,6 +161,11 @@ public class GameServer : ServerBase
Log.Information("[Server] 인증 완료 HashKey={Key} PeerId={Id}", hashKey, peer.Id);
OnSessionConnected(peer, hashKey);
}
finally
{
authenticatingTokens.Remove(token);
}
}
protected override void OnSessionConnected(NetPeer peer, int hashKey)
{
@@ -186,6 +205,9 @@ public class GameServer : ServerBase
// 파티 자동 탈퇴
HandlePartyLeaveOnExit(channelId, hashKey);
// 레이드 맵이었으면 해제 체크
TryReleaseRaidMap(cm.GetChannel(channelId), player.CurrentMapId);
// 같은 채널 유저들에게 나갔다고 알림
SendExitChannelPacket(peer, hashKey, channelId, player);
}
@@ -447,6 +469,20 @@ public class GameServer : ServerBase
{
Log.Warning("[GameServer] INTO_CHANNEL 채널 인원 초과 HashKey={Key} ChannelId={ChannelId} UserCount={Count}/{Max}",
hashKey, packet.ChannelId, newChannel.UserCount, newChannel.UserCountMax);
// 이전 채널에서 이미 제거된 경우 → 이전 채널로 복귀
if (preChannelId >= 0)
{
Player? fallbackPlayer = new()
{
HashKey = hashKey,
PlayerId = hashKey,
Nickname = ((Session)peer.Tag).UserName
};
cm.AddUser(preChannelId, hashKey, fallbackPlayer, peer);
Log.Information("[GameServer] INTO_CHANNEL 만석 → 이전 채널({ChannelId})로 복귀 HashKey={Key}", preChannelId, hashKey);
}
byte[] full = PacketSerializer.Serialize((ushort)PacketCode.INTO_CHANNEL,
new IntoChannelPacket { ChannelId = -1 });
SendTo(peer, full);
@@ -662,6 +698,9 @@ public class GameServer : ServerBase
player.PosZ = packet.Position.Z;
player.RotY = packet.RotY;
// PlayerId 강제 교체 (클라이언트 스푸핑 방지)
packet.PlayerId = hashKey;
// 같은 맵 유저들에게 위치/방향 브로드캐스트 (나 제외)
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.TRANSFORM_PLAYER, packet);
BroadcastToMap(channelId, player.CurrentMapId, data, peer, DeliveryMethod.Unreliable);
@@ -684,6 +723,9 @@ public class GameServer : ServerBase
return;
}
// PlayerId 강제 교체 (클라이언트 스푸핑 방지)
packet.PlayerId = hashKey;
// 같은 맵 유저들에게 행동 브로드캐스트 (나 제외)
byte[] data = PacketSerializer.Serialize((ushort)PacketCode.ACTION_PLAYER, packet);
BroadcastToMap(channelId, player.CurrentMapId, data, peer);
@@ -707,6 +749,15 @@ public class GameServer : ServerBase
return;
}
// PlayerId 강제 교체 (클라이언트 스푸핑 방지)
packet.PlayerId = hashKey;
// HP/MP 범위 클램핑 (클라이언트 조작 방지)
if (packet.MaxHp < 0) packet.MaxHp = 0;
if (packet.MaxMp < 0) packet.MaxMp = 0;
packet.Hp = Math.Clamp(packet.Hp, 0, packet.MaxHp);
packet.Mp = Math.Clamp(packet.Mp, 0, packet.MaxMp);
player.Hp = packet.Hp;
player.MaxHp = packet.MaxHp;
player.Mp = packet.Mp;
@@ -957,6 +1008,10 @@ public class GameServer : ServerBase
}
SendTo(peer, PacketSerializer.Serialize((ushort)PacketCode.CHANGE_MAP, response));
// 레이드 맵(1001+)에서 나갔고 남은 유저가 0이면 인스턴스 맵 해제
TryReleaseRaidMap(channel, oldMapId);
Log.Debug("[GameServer] CHANGE_MAP HashKey={Key} OldMap={OldMapId} NewMap={MapId}", hashKey, oldMapId, packet.MapId);
}
@@ -1188,6 +1243,22 @@ public class GameServer : ServerBase
BroadcastToChannel(channelId, data);
}
// 레이드 맵(1001+)에서 유저가 빠졌을 때, 남은 유저가 0이면 인스턴스 맵 해제
private static void TryReleaseRaidMap(Channel.Channel channel, int mapId)
{
if (mapId < 1001)
{
return;
}
AMap? map = channel.GetMap(mapId);
if (map != null && map.GetUsers().Count == 0)
{
channel.RemoveInstanceMap(mapId);
Log.Debug("[GameServer] 레이드 맵 해제 MapId={MapId}", mapId);
}
}
private void SendError(NetPeer peer, ErrorCode code)
{
ErrorPacket err = new() { Code = code };

View File

@@ -28,6 +28,12 @@ public class PartyManager
memeberIds = new List<int>();
}
// 리더 중복 방지: 기존 멤버 목록에 리더가 없을 때만 추가
if (!memeberIds.Contains(leaderId))
{
memeberIds.Add(leaderId);
}
party = new PartyInfo
{
PartyId = partyId,
@@ -35,10 +41,14 @@ public class PartyManager
PartyName = partyName,
PartyMemberIds = memeberIds
};
party.PartyMemberIds.Add(leaderId);
parties[partyId] = party;
playerPartyMap[leaderId] = partyId;
// 모든 멤버를 playerPartyMap에 등록
foreach (int memberId in memeberIds)
{
playerPartyMap[memberId] = partyId;
}
return true;
}