เกมออนไลน์ทุกเกมมีโปรแกรมสองฝั่งที่ไม่ได้เชื่อกันโดยอัตโนมัติ นั่นคือ client (สำเนาของเกมที่รันอยู่บนเครื่องของผู้เล่นเอง) กับ server (โปรแกรมที่เก็บสถานะเกมชุดจริงที่ทุกคนเชื่อร่วมกัน) ที่ผ่านมาในหลักสูตรนี้เราเขียนโค้ดราวกับว่าทั้งสองฝั่งเห็นความจริงชุดเดียวกันเสมอ แต่ในเกมมัลติเพลเยอร์หรือเกมออนไลน์จริง มันไม่ได้เป็นแบบนั้น — ผู้เล่นควบคุมเครื่องที่ client ของตัวเองรันอยู่ได้เต็มที่ และช่องว่างระหว่างสิ่งที่ client อ้างว่าเกิดขึ้นกับสิ่งที่เกิดขึ้นจริง คือจุดที่การโกงเกิดขึ้นพอดี บทนี้จะพูดถึงกฎข้อเดียวที่ทุกอย่างในบทนี้สร้างต่อยอดมาจากมัน, การตรวจสอบฝั่ง server แบบเป็นรูปธรรมที่บังคับใช้กฎนั้น, ประเภทของ cheat ที่พบบ่อยอธิบายในระดับที่พอจะเข้าใจวิธีป้องกัน (ไม่ใช่วิธีทำ), และส่วนที่อยู่รอบ ๆ ปัญหานี้ — การเข้ารหัส, ความปลอดภัยของบัญชี, เครื่องมือระดับ kernel
client กับ server แลกเปลี่ยนข้อมูลชิ้นเล็ก ๆ ที่เรียกว่า packet (packet คือหน่วยข้อมูลหนึ่งชุดที่ส่งผ่านเครือข่าย เหมือนซองจดหมายหนึ่งซอง) ตลอดเวลา packet จาก client อาจบอกว่า "ฉันกดปุ่มเดินหน้า" "ฉันยิงอาวุธ" หรือ "ตอนนี้ฉันยืนอยู่ที่ตำแหน่ง (120, 4, 88)"
ปัญหาคือ ผู้เล่นเป็นเจ้าของเครื่องที่ client รันอยู่ พวกเขาสามารถต่อ debugger แล้วหยุดเกมกลางเฟรมได้ เปิดหน่วยความจำของเกมด้วยเครื่องมือแล้วแก้ตัวเลขระหว่างที่เกมกำลังรันอยู่ได้ เขียนไฟล์ executable ของเกมใหม่บางส่วนได้ หรือเขียนโปรแกรมที่คั่นกลางระหว่างเกมกับเครือข่ายเพื่อแก้ packet ก่อนที่มันจะออกจากเครื่องได้ ทั้งหมดนี้ไม่ได้แตะ server เลยแม้แต่นิดเดียว — มันเกิดขึ้นบนฮาร์ดแวร์ที่ผู้เล่นควบคุมเต็มที่ทั้งสิ้น
นั่นหมายความว่า packet ทุกอันที่ client ส่งมาเป็นแค่ คำกล่าวอ้าง ไม่ใช่ข้อเท็จจริง "ฉันสร้างดาเมจ 9999" ไม่ใช่ดาเมจที่เกิดขึ้นจริงตามกฎของเกม — มันคือตัวเลขที่โปรแกรมบนเครื่องของผู้เล่นเองตัดสินใจส่งมา ถ้า server เอาคำกล่าวอ้างของ client มาใช้กับสถานะเกมที่ทุกคนใช้ร่วมกันโดยไม่ตรวจสอบเลย กฎของเกมก็จะแข็งแรงเท่ากับความเต็มใจของคนโกงที่จะทำตามกฎเท่านั้น ซึ่งแทบไม่แข็งแรงเลย
วิธีที่ชัดที่สุดที่จะเห็นว่าทำไม "อย่าเชื่อ client" ถึงสำคัญ คือดูฟีเจอร์เดียวกันสองเวอร์ชัน: การจัดการข้อความดาเมจจากผู้เล่นที่โจมตี ทั้งสองเวอร์ชัน compile ผ่านทั้งคู่ แต่มีแค่เวอร์ชันเดียวที่ปลอดภัยพอจะรันกับผู้เล่นจริง
handler ฝั่ง server นี้รับข้อความจาก client แล้วนำตัวเลขที่ client ส่งมาไปใช้ตรง ๆ:
// NAIVE SERVER CODE -- do not copy this pattern
class DealDamageMessage
{
public int AttackerId;
public int TargetId;
public int DamageAmount; // taken straight from the client
}
void OnDealDamageReceived(DealDamageMessage msg)
{
Entity target = World.FindEntity(msg.TargetId);
target.Health -= msg.DamageAmount; // trusts the client completely
if (target.Health <= 0)
World.Kill(target);
}
สิ่งที่เกิดขึ้นกับ client ที่ถูกแก้ไข: คนโกงแก้ไข client ของตัวเอง (หรือเขียนโปรแกรมปลอม packet เครือข่าย) ให้ทุก DealDamageMessage ที่ส่งออกไปมี DamageAmount = 9999 เสมอ ไม่ว่าอาวุธที่ถืออยู่จริงจะเป็นอะไรก็ตาม
สิ่งนี้ไม่ต้องแตะ server เลยแม้แต่น้อย คนโกงแตะแค่คอมพิวเตอร์ของตัวเองเท่านั้น
วิธีแก้ไม่ใช่การเพิ่มเช็ค "ตัวเลขนี้ใหญ่เกินไปไหม?" ให้กับ DamageAmount — คนโกงที่มีวินัยแค่เลือกตัวเลขที่ต่ำกว่าเพดานที่คุณตั้งไว้ก็จบ วิธีแก้ที่แท้จริงคือเลิกเชื่อตัวเลขนั้นไปเลย: server ไปดูอาวุธและสเตตัสจริงของผู้โจมตีจากข้อมูลที่ตัวเองเป็นเจ้าของ แล้วคำนวณดาเมจเอง
// VALIDATING SERVER CODE
class DealDamageMessage
{
public int AttackerId;
public int TargetId;
// no DamageAmount field at all -- the client cannot send a
// number the server would need to trust
}
void OnDealDamageReceived(DealDamageMessage msg)
{
Entity attacker = World.FindEntity(msg.AttackerId);
Entity target = World.FindEntity(msg.TargetId);
if (attacker == null || target == null) return;
if (!attacker.IsAlive || !target.IsAlive) return;
if (!InAttackRange(attacker, target)) return; // sanity check
if (!OffCooldown(attacker, "attack")) return; // rate limit
// server computes the real number itself, from data
// only the server is allowed to change
int damage = CombatRules.ComputeDamage(attacker.Weapon,
attacker.Level,
attacker.Buffs,
target.Armor);
target.Health -= damage;
StartCooldown(attacker, "attack");
if (target.Health <= 0)
World.Kill(target);
}
ไล่ trace ด้วย client ที่ถูกแก้ไขตัวเดิม: client ของคนโกงยังส่ง DealDamageMessage ได้เร็วเท่าที่ต้องการ แต่ DamageAmount ไม่มีอยู่เป็นฟิลด์ที่ server อ่านอีกต่อไป server คำนวณดาเมจ 8 สำหรับมีดเริ่มต้นทุกครั้งไม่มีเปลี่ยนแปลง ไม่ว่า client จะอยากให้มันพูดว่าอะไรก็ตาม การแก้ client ไม่เปลี่ยนผลลัพธ์เลยแม้แต่นิดเดียว
นี่คือการเปลี่ยนแปลงหลัก: server แบบตรวจสอบไม่ได้แค่เช็คว่าข้อมูลจาก client ดูสมเหตุสมผล — มันปฏิบัติต่ออินพุตจาก client เป็นแค่ความตั้งใจ ("ฉันกำลังโจมตีเป้าหมายนี้") แล้วคำนวณผลลัพธ์เองจากข้อมูลที่ server เป็นเจ้าของ การเปลี่ยนแค่จุดเดียวนี้ปิดโอกาสโกงทั้งหมวดหมู่ได้ในคราวเดียว
if (msg.DamageAmount > 500) reject(); แล้วคิดว่าแก้ปัญหาแล้ว วิธีนี้หยุดการโกงที่โจ่งแจ้งที่สุด (ดาเมจ 9999) ได้ แต่หยุดคนโกงที่มีวินัยที่ส่งดาเมจ 400 ทุกฮิตไม่ได้ — ซึ่งยังสูงกว่าอาวุธจริงมาก แต่อยู่ใต้เพดาน เช็คขอบเขตเป็นเลเยอร์ที่สองที่มีประโยชน์ แต่ไม่ควรเป็นเลเยอร์เดียว สำหรับค่าที่ไม่ควรเชื่อ client ตั้งแต่แรกอยู่แล้วรูปแบบจากข้อ 2 มีชื่อเรียกว่า server authority (กฎที่ว่า server เก็บสถานะเกมชุดจริงเพียงชุดเดียว และ client แต่ละตัวถือแค่สำเนาที่ไม่มีสิทธิ์บังคับให้เปลี่ยนแปลงได้) เกือบทุกเทคนิคเฉพาะเจาะจงในบทนี้คือแนวคิดเดียวกันนี้ที่นำไปใช้กับข้อมูลคนละประเภท: ตำแหน่ง, health, inventory, เงินในเกม, cooldown
เปรียบเทียบสถาปัตยกรรมสองแบบ:
ภายใต้สถาปัตยกรรมแบบ client-authoritative client ที่ถูกแก้ไขสามารถอ้างว่าอยู่ที่ไหนก็ได้ มี health เท่าไหร่ก็ได้ หรือเป็นเจ้าของไอเทมอะไรก็ได้ และ server ไม่มีทางรู้ได้เองว่ามันผิด ภายใต้สถาปัตยกรรมแบบ server-authoritative client ส่งแค่ อินพุต (การกดปุ่ม, ทิศทางเล็ง, ไอเทมที่ต้องการใช้) และ server เป็นโปรแกรมเดียวเท่านั้นที่มีสิทธิ์เปลี่ยนอินพุตเหล่านั้นให้กลายเป็นสถานะเกมใหม่ ส่วน client ก็แค่แสดงผลสถานะที่ server ส่งกลับมา
server authority ไม่ได้มาฟรี ๆ มันกิน CPU ของ server เพิ่ม — เพราะ server ต้องจำลองเกมทั้งหมด ไม่ใช่แค่ตัดสินเหมือนกรรมการ — และมันเพิ่มดีเลย์เล็กน้อยก่อนที่ client จะเห็นผลลัพธ์ที่ยืนยันแล้วของอินพุตตัวเอง นี่คือเหตุผลที่เกมมักใช้ client-side prediction (ให้ client เดาผลลัพธ์ที่น่าจะเป็นทันที แล้วค่อยแก้เงียบ ๆ ถ้า server ไม่เห็นด้วยภายหลัง) เพื่อซ่อนดีเลย์นั้น ต้นทุนนี้เองคือเหตุผลว่าทำไม server authority ถึงเป็นแนวป้องกันหลัก ไม่ใช่ของฟรี สตูดิโอต้องจ่ายแรงงานวิศวกรรมจริง ๆ เพื่อสิ่งนี้ เพราะทางเลือกอื่นคือเกมที่เชื่อถือไม่ได้เลย
ตำแหน่งเป็นหนึ่งในค่าที่ถูกโกงบ่อยที่สุด เพราะ "อยู่ในที่ที่ไม่ควรไปถึงได้ตอนนี้" มีประโยชน์โดยตรง — จบเรซก่อนเวลา, ยืนอยู่ในกำแพงเพื่อหลบไม่ให้โดนตี, หรือไปถึงทรัพยากรหายากก่อนคนอื่น ระบบการเคลื่อนที่แบบ server-authoritative ยังต้องมีเช็คที่เป็นรูปธรรม เพราะอินพุตปลอม (ตำแหน่งที่อ้าง, delta เวลาที่อ้างว่าใหญ่มาก) ยังหลุดผ่านไปได้ ถ้า server ไม่ตรวจซ้ำผลลัพธ์กับความเป็นจริง
เช็คนี้ง่ายมาก: จากตำแหน่งล่าสุดที่รู้ของผู้เล่นและเวลาจริงที่ผ่านไป มีระยะทางสูงสุดที่เป็นไปได้ที่พวกเขาจะเคลื่อนที่ได้จริง ตำแหน่งที่อ้างเกินกว่าค่าสูงสุดนั้นเป็นไปไม่ได้ตามกฎของเกมเอง
class PlayerMoveState
{
public Vector3 LastPosition;
public float LastServerTime;
public float MaxSpeed; // units per second, from this player's stats
}
bool TryMove(PlayerMoveState state, Vector3 claimedPosition, float serverNow)
{
float dt = serverNow - state.LastServerTime;
if (dt <= 0f) return false; // out-of-order or replayed packet
float distance = Vector3.Distance(state.LastPosition, claimedPosition);
float maxDistance = state.MaxSpeed * dt * 1.15f; // +15% tolerance for lag
if (distance > maxDistance)
{
Flag(state, "movement speed exceeded"); // impossible -- reject
return false; // client is snapped back to LastPosition
}
state.LastPosition = claimedPosition;
state.LastServerTime = serverNow;
return true;
}
ไล่ trace ผู้เล่นปกติ: ความเร็วสูงสุด 6 unit/วินาที ผ่านไป 0.1 วินาทีนับจากอัปเดตล่าสุด -> ระยะทางสูงสุด = 6 * 0.1 * 1.15 = 0.69 unit การก้าวปกติ 0.5 unit ผ่านสบาย ๆ
ไล่ trace การโกงเทเลพอร์ต: ตำแหน่งที่อ้างของผู้เล่นคนเดิมกระโดดไป 500 unit ภายในหน้าต่างเวลา 0.1 วินาทีเดียวกัน
ค่าความคลาดเคลื่อนที่ยอมรับได้ (1.15f ด้านบน) มีอยู่เพราะดีเลย์เครือข่ายทำให้ server ไม่มีทางเห็นภาพที่อัปเดตล่าสุดแบบสมบูรณ์แบบตลอดเวลา — ต้องมีช่องว่างเล็กน้อยเพื่อไม่ให้ผู้เล่นที่มี lag ปกติ (ไม่ได้โกง) ถูกปฏิเสธตลอดเวลา ตั้งค่านี้แน่นเกินไปจะทำให้ผู้เล่นที่ lag แต่ไม่ได้โกงถูกปฏิเสธผิด ๆ หลวมเกินไปก็จะจับได้แค่การเทเลพอร์ตที่สุดโต่งที่สุดเท่านั้น
ความเร็วและตำแหน่งไม่ใช่สิ่งเดียวที่ client ที่ถูกแก้ไขสามารถขยายผลได้ การกระทำใดก็ตามที่มี cooldown หรือความถี่จำกัด — ยิงอาวุธ, ร่ายเวท, เปิดร้านค้า, รับรางวัลประจำวัน — สามารถถูกละเมิดได้โดย client ที่ส่งข้อความ "ทำสิ่งนี้" เร็วกว่าที่ client จริง (และมนุษย์ที่กดปุ่มจริง) จะทำได้มาก นี่คือญาติที่หยาบกว่าของ speed hack: แทนที่จะเคลื่อนที่เร็วขึ้น cheat นี้กระทำการเร็วขึ้นแทน
วิธีป้องกันคือ cooldown ต่อการกระทำที่เก็บไว้บน server ทั้งหมด ถูกตรวจสอบก่อนทุกการกระทำจะได้รับอนุญาตให้เกิดขึ้น โดยไม่ขึ้นกับสิ่งที่ client บอกเกี่ยวกับเวลาของตัวเองเลย:
class ActionCooldowns
{
Dictionary<string, float> lastUsedServerTime = new Dictionary<string, float>();
public bool TryUse(string actionName, float cooldownSeconds, float serverNow)
{
if (lastUsedServerTime.TryGetValue(actionName, out float last))
{
if (serverNow - last < cooldownSeconds)
return false; // still on cooldown -- refuse the action
}
lastUsedServerTime[actionName] = serverNow;
return true;
}
}
ไล่ trace: เวทไฟบอลมี cooldown 2 วินาที client ที่ถูกแก้ไขยิงข้อความ CastSpell("fireball") 40 ครั้งในหนึ่งวินาที TryUse ปล่อยให้ครั้งแรกผ่านไปได้ แล้วปฏิเสธอีก 39 ครั้ง เพราะยังไม่ผ่านไป 2 วินาทีนับจากการใช้ครั้งล่าสุดที่ยอมรับ — ไม่ว่าจะมีข้อความมาถึงกี่อันก็ตาม เอฟเฟกต์บนหน้าจอของผู้เล่นอาจสแปม แต่มีไฟบอลเพียงลูกเดียวเท่านั้นที่มีอยู่จริงในสถานะเกมที่ใช้ร่วมกัน
ข้อ 2, 4, และ 5 แต่ละข้อแสดงเช็คเฉพาะเจาะจงหนึ่งอย่าง — คำนวณดาเมจเองแทนที่จะเชื่อ, จำกัดระยะทางการเคลื่อนที่, จำกัดอัตราการกระทำ ในเกมจริงเช็คเหล่านี้มีรูปแบบเดียวกันทุกครั้ง นี่คือรูปแบบนั้นในรูป pseudocode ที่ทั่วไปพอจะใช้ได้กับแทบทุกการกระทำที่ client ร้องขอ:
function HandleClientAction(playerId, action, serverNow):
player = LookupPlayer(playerId)
// 1. Identity and ownership -- is this really this player's
// connection, and do they own what they claim to act on?
if player == null or not player.IsConnected:
return Reject("unknown or disconnected player")
if action.RequiresOwnership and not player.Owns(action.TargetItemId):
return Reject("does not own target item")
// 2. State preconditions -- is this action even legal right now?
if not player.IsAlive:
return Reject("dead players cannot act")
if not player.IsInRange(action.TargetId, action.MaxRange):
return Reject("target out of range")
// 3. Rate limiting -- has enough real server time passed?
if not player.Cooldowns.TryUse(action.Name, action.CooldownSeconds, serverNow):
return Reject("action on cooldown")
// 4. Compute the result from server-owned data -- never from
// any numeric value the client itself supplied
result = ComputeResult(player, action) // e.g. damage, loot, currency
// 5. Apply to the one shared game state, then tell every
// relevant client what actually happened
ApplyToWorld(result)
BroadcastToRelevantClients(result)
return Accept(result)
แต่ละขั้นตอนที่มีเลขกำกับปิดโอกาสการโกงหนึ่งหมวดหมู่: ขั้นที่ 1 หยุดคนโกงที่แสร้งเป็นคนอื่นหรือกระทำกับไอเทมที่ตัวเองไม่ได้เป็นเจ้าของ ขั้นที่ 2 หยุดการกระทำที่ผิดลำดับกฎของเกมเอง (โจมตีทั้งที่ตายแล้ว, ตีสิ่งที่อยู่ไกลสุดขอบแผนที่) ขั้นที่ 3 หยุดการละเมิดความเร็วและอัตรา และขั้นที่ 4 คือบทเรียนจากข้อ 2 ที่ขยายให้ทั่วไปขึ้น — server เป็นสิ่งเดียวเท่านั้นที่มีสิทธิ์ตัดสินว่า "ผลลัพธ์" คืออะไรจริง ๆ
การตั้งชื่อหมวดหมู่การโกงจริง ๆ ที่ข้อก่อนหน้านี้ป้องกันไว้ ในระดับที่พอจะเข้าใจวิธีป้องกัน — ไม่ใช่คู่มือวิธีทำ จะช่วยให้เข้าใจภาพรวมได้ หมวดหมู่แรกคือ memory manipulation (การแก้ไขหน่วยความจำ): เครื่องมือที่มองเข้าไปในหน่วยความจำของโปรเซสเกมเอง (ตัวเลขที่โปรแกรมที่กำลังรันใช้อยู่ตอนนั้น เช่น กระสุนปัจจุบัน, health ปัจจุบัน, หรือเงินในเกมปัจจุบัน) แล้วแก้ค่าตรง ๆ ขณะที่เกมกำลังรันอยู่ ทั้งหมดนี้เกิดขึ้นบนเครื่องของคนโกงเองเท่านั้น เทคนิคที่เกี่ยวข้องกันคือการต่อ debugger เข้ากับ client ที่กำลังรันแล้วแก้ค่าหรือข้ามโค้ดในลักษณะเดียวกัน
ฟังดูทรงพลัง และในเกมแบบ client-authoritative มันก็ทรงพลังจริง ๆ: ถ้าสำเนาของ client เกี่ยวกับ "กระสุน = 30" เป็นตัวเลขเดียวกับที่ server เชื่อ การแก้เป็น 999 ตรง ๆ ก็ชนะทันที แต่ลองดูสิ่งที่มันเปลี่ยนแปลงจริง ๆ: ไบต์ข้างในโปรเซส client การแก้หน่วยความจำในคอมพิวเตอร์ของตัวเองไม่มีทางไปถึงเครือข่ายแล้วเปลี่ยนสำเนาแยกต่างหากของ server ที่เก็บค่าเดียวกันนี้ไว้เลย
วิธีป้องกัน: นี่คือ server authority จากข้อ 3 นั่นเอง ถ้า server เป็นสิ่งเดียวที่ลดกระสุนเมื่อมีการยิง และการอัปเดตครั้งต่อไปของ server เขียนทับสิ่งที่ client กำลังแสดงอยู่เฉย ๆ การแก้หน่วยความจำของ client จะเปลี่ยนแค่สิ่งที่หน้าจอของคนโกงเองแสดงชั่วขณะหนึ่งเท่านั้น และไม่เปลี่ยนแปลงอะไรเลยกับสถานะเกมที่ใช้ร่วมกันซึ่งคนอื่นทุกคนเห็น ค่าไหนก็ตามที่สำคัญต่อความยุติธรรม — health, เงินในเกม, inventory, cooldown, ตำแหน่ง — ต้องอยู่บน server จริง ๆ ไม่ใช่แค่ถูกสะท้อนไว้ที่นั่นเฉย ๆ
speed hack พยายามทำให้ client เชื่อว่าเวลาผ่านไปมากกว่าที่เกิดขึ้นจริง — เร่งนาฬิกาของ client เอง หรืออ้าง deltaTime (เวลาที่ผ่านไปนับจากอัปเดตล่าสุด ปกติวัดโดย game engine เอง) ที่ใหญ่กว่าความจริงตรง ๆ — เพื่อให้อะไรก็ตามที่คำนวณจากเวลา (การเคลื่อนที่, ตัวจับเวลา cooldown, แอนิเมชัน) วิ่งเร็วกว่าที่ควรจะเป็น
วิธีป้องกันมีอยู่แล้วในบทนี้: ข้อ 4 และ 5 ต่างก็คำนวณเวลาจากนาฬิกาของ server เอง ไม่เคยใช้ค่าที่ client รายงานเกี่ยวกับตัวเองเลย
// VULNERABLE: trusts a client-reported time delta
void OnMoveRequest(MoveMessage msg)
{
// msg.ClientDeltaTime is whatever the client's cheat tool wants it to be
player.Position += player.InputDirection * player.Speed * msg.ClientDeltaTime;
}
// SAFE: server measures elapsed time itself
void OnMoveRequest(MoveMessage msg)
{
float dt = ServerClock.Now - player.LastUpdateServerTime; // server's own clock
player.Position += player.InputDirection * player.Speed * dt;
player.LastUpdateServerTime = ServerClock.Now;
}
เวอร์ชันที่มีช่องโหว่ปล่อยให้ client ที่ถูกแก้ไขแค่รายงาน ClientDeltaTime = 1.0 ในทุก ๆ network tick ทำให้ผู้เล่นเคลื่อนที่เป็นระยะทางของหนึ่งวินาทีเต็มหลายสิบครั้งต่อวินาที เวอร์ชันที่ปลอดภัยไม่มีทางถูกหลอกแบบนี้ได้ เพราะตัวเลขที่มันเอาไปคูณมาจากนาฬิกาของ server เอง ซึ่งเครื่องของคนโกงไม่มีทางเอื้อมเข้าไปแตะได้เลย นี่คือหลักการเดียวกับการจำกัดระยะทางในข้อ 4 แต่เอาไปใช้กับอินพุตเวลาแทนที่จะเป็นเอาต์พุตตำแหน่ง — เกมจริงมักใช้เช็คทั้งสองแบบร่วมกัน
wallhack แสดงศัตรู (หรือไอเทม หรือเป้าหมาย) ทะลุกำแพงและ geometry อื่น ๆ ที่ควรจะบังมันไว้ aimbot ล็อกเป้าเล็งของผู้เล่นไปที่ศัตรูโดยอัตโนมัติ มักใช้ข้อมูลพื้นฐานตัวเดียวกับที่ wallhack ใช้ ทั้งสองอย่างนี้ไม่ได้ปลอมตัวเลขแบบที่ damage hack ทำ — ทั้งคู่แค่อ่านข้อมูลที่อยู่บนเครื่องของคนโกงอยู่แล้ว แล้วเอามาแสดงผลหรือใช้กระทำการ
นี่คือรายละเอียดสำคัญ: ในเกมที่สร้างมาแบบไม่ระมัดระวัง server ส่งตำแหน่งของทุกเอนทิตีในแมพทั้งหมดให้ client ทุกตัวตลอดเวลา แล้วพึ่งพา renderer ของ client เองให้ตัดสินใจว่าจะวาดอะไรบนหน้าจอ — สิ่งที่ "ถูกซ่อน" ถูกซ่อนแค่เพราะโค้ดเกมปกติเลือกที่จะไม่วาดมันเท่านั้น client ที่ถูกแก้ไขสามารถแค่วาดมันออกมาเฉย ๆ หรือป้อนตำแหน่งเหล่านั้นตรงเข้าไปในระบบช่วยเล็งได้เลย ข้อมูลนี้ไม่เคยเป็นความลับจริง ๆ เลย มันนั่งอยู่ในหน่วยความจำของ client ตลอดเวลา ถูกส่งไปที่นั่นโดย server เอง
วิธีป้องกัน: interest management (เรียกอีกอย่างว่า network-level visibility culling) หมายถึง server คำนวณแยกกันสำหรับ client แต่ละตัวว่า client ตัวนั้นมีสิทธิ์รู้เกี่ยวกับเอนทิตีไหนได้บ้างในตอนนี้ — โดยอิงจาก line-of-sight (มีเส้นตรงที่ไม่ถูกกีดขวางด้วย geometry ไปถึงเอนทิตีนั้นหรือไม่), ระยะทาง, หรือการออกแบบที่ตั้งใจอย่าง fog of war — แล้วใส่เฉพาะเอนทิตีเหล่านั้นในข้อมูลที่ส่งให้ client ตัวนั้น ถ้าตำแหน่งของศัตรูไม่เคยถูกส่งให้ client เลย ก็ไม่มีอะไรในหน่วยความจำของ client ให้ wallhack อ่านหรือวาดได้เลย ไม่ว่าจะสแกนหน่วยความจำยังไงก็ตาม
List<Entity> BuildVisibleSnapshot(Player viewer, List<Entity> allEntities)
{
List<Entity> visible = new List<Entity>();
foreach (Entity e in allEntities)
{
if (e == viewer.Entity) { visible.Add(e); continue; }
float dist = Vector3.Distance(viewer.Position, e.Position);
if (dist > viewer.MaxViewDistance) continue; // too far
if (!HasLineOfSight(viewer.Position, e.Position)) continue; // blocked
visible.Add(e); // only entities that pass both checks are sent
}
return visible;
}
ไล่ trace: player A ยืนอยู่ในทุ่งโล่งกับศัตรูตัวหนึ่งที่ห่างออกไป 20 unit (line of sight โล่ง อยู่ในระยะ MaxViewDistance) และศัตรูอีกตัวห่างออกไป 5 unit แต่อยู่อีกฝั่งของกำแพงทึบ BuildVisibleSnapshot จะรวมศัตรูตัวแรกและตัดตัวที่สองออก — packet เครือข่ายที่ส่งให้ client ของ player A ไม่มีตำแหน่งของศัตรูตัวที่สองอยู่จริง ๆ เลย wallhack ที่รันอยู่บนเครื่องของ player A จึงไม่มีข้อมูลให้เปิดเผยเลย เพราะตัวกรองฝั่ง server ทำงานไปแล้วก่อนที่จะมีอะไรถูกส่งออกไปด้วยซ้ำ
aimbot ถูกแก้ได้แค่บางส่วนจาก interest management มันเอาข้อมูลของศัตรูที่ผู้เล่นไม่ควรเห็นเลยออกไปได้ แต่ตำแหน่งของศัตรูที่มองเห็นได้จริงตามกฎยังต้องถูกส่งไปเพื่อให้เกมที่เล่นตรง ๆ วาดมันได้ และ aimbot ก็ล็อกเข้ากับตำแหน่งนั้นได้ การจับ aimbot ที่เล็งไปที่เป้าหมายที่มองเห็นได้จริงตามกฎต้องใช้วิธีป้องกันอีกแบบหนึ่ง ซึ่งจะพูดถึงต่อไป
ไม่ใช่ทุกการโกงที่จะปิดกั้นได้ด้วยกฎการตรวจสอบ aimbot ที่เล็งไปที่ศัตรูที่ผู้เล่นมีสิทธิ์เห็นได้จริงตามกฎ ไม่ได้ทำอะไรที่กฎของ server จะปฏิเสธได้ตรง ๆ เลย — "เล็งแม่นยำไปที่เป้าหมายที่มองเห็นได้" ก็เป็นสิ่งที่ผู้เล่นมนุษย์ที่เก่งมาก ๆ ทำได้เช่นกัน สิ่งเดียวกันนี้ใช้ได้กับการโกงที่อ่านข้อมูลที่มองเห็นได้เร็วกว่าที่มนุษย์จะตอบสนองได้ โดยไม่ได้แตะค่าใด ๆ ที่ server เป็นเจ้าของเลย
วิธีป้องกันตรงนี้ต่างออกไปโดยพื้นฐาน: แทนที่จะเช็คการกระทำหนึ่งครั้งกับกฎตายตัว server (หรือบริการวิเคราะห์ข้อมูลแยกต่างหาก) บันทึกสถิติพฤติกรรมของผู้เล่นตลอดช่วงเวลา — เปอร์เซ็นต์ความแม่นยำ, อัตรายิงหัว, ความสม่ำเสมอของการเล็งที่ตามเป้าหมายที่เคลื่อนที่ผ่าน recoil ของอาวุธ, เวลาตอบสนองนับจากศัตรูปรากฏจนถึงผู้เล่นยิงครั้งแรก — แล้วตั้งค่าสถานะผู้เล่นที่ตัวเลขอยู่นอกช่วงที่มนุษย์จริงทำได้มาก ๆ
class AccuracyTracker
{
int shotsTaken = 0;
int shotsHit = 0;
public void RecordShot(bool hit)
{
shotsTaken++;
if (hit) shotsHit++;
if (shotsTaken >= 50) // only judge once there is enough data
{
float accuracy = (float)shotsHit / shotsTaken;
// 92%+ sustained accuracy over 50+ shots is far above
// typical human performance for this weapon type
if (accuracy > 0.92f)
FlagForReview(accuracy, shotsTaken);
}
}
}
ไล่ trace: ผู้เล่นที่เก่งจริง ๆ อาจรักษาความแม่นยำ 35-55% กับอาวุธยิงเร็วตลอดแมตช์จริงได้ ผู้เล่นที่ความแม่นยำที่บันทึกไว้อยู่ที่ 96% จาก 200 นัด ไม่ได้เป็นไปไม่ได้ตามกฎเดี่ยว ๆ ที่ server เช็คได้ทันที แต่มันอยู่นอกช่วงปกติของมนุษย์มากพอที่จะควรตรวจสอบเพิ่มเติม — ไม่ว่าจะเป็นแบบอัตโนมัติ (การตรวจสอบที่เข้มขึ้นเริ่มทำงาน) หรือผู้ตรวจสอบที่เป็นมนุษย์ดูวิดีโอย้อนหลังของแมตช์นั้น
วิธีป้องกันทุกอย่างที่พูดมาสมมติว่า packet ที่ server ได้รับคือ packet ที่ client ส่งมาจริง ๆ ไม่ถูกแก้ไขระหว่างทาง และ packet ที่ server ส่งออกไปถูกรับโดย client ตัวที่ตั้งใจส่งให้เท่านั้น สมมติฐานนี้ต้องมีวิธีป้องกันของตัวเอง: เส้นทางเครือข่ายระหว่าง client กับ server เป็นช่องทางที่บุคคลที่สามอาจอ่านหรือแทรกแซงได้ — จาก Wi-Fi เครือข่ายเดียวกัน, เราเตอร์ที่ถูกแฮก, หรือเครื่องมือ proxy ที่คนโกงรันบนเครื่องตัวเองโดยตั้งใจ
มีการป้องกันสองอย่างแยกกันที่สำคัญตรงนี้:
https:// ปลอดภัย หรือโปรโตคอลเข้ารหัสเฉพาะของเกม) หมายความว่าเครื่องมือดักฟังหรือแก้ไข packet ทั่วไปจะเห็นแค่ไบต์ที่ถูกสลับ ไม่ใช่ข้อความอ่านได้แบบ DamageAmount=8 วิธีนี้ไม่ได้หยุด cheat ที่ฝังอยู่ใน client เอง (มันมีข้อมูลที่ยังไม่เข้ารหัสอยู่แล้วก่อนที่การเข้ารหัสจะเกิดขึ้น) แต่มันหยุดการโจมตีประเภทที่ง่ายกว่ามาก: เครื่องมือแยกต่างหากที่ดักและแก้ไข packet ระหว่างทางโดยไม่แตะโค้ดของเกมเองเลยทุกอย่างที่พูดมาป้องกันตัวเกมเองจากการถูกแทรกแซงระหว่างแมตช์ พื้นผิวการโจมตีอีกแบบหนึ่งคือ บัญชี ของผู้เล่น (ข้อมูลตัวตนและความคืบหน้าที่ผูกกับการล็อกอิน) — ผู้โจมตีที่ไม่ได้พยายามโกงในแมตช์เลย แต่พยายามเจาะเข้าบัญชีของคนอื่นเพื่อขโมยไอเทมที่ซื้อไว้, ขายต่อ, หรือใช้มันแพร่การโกงต่อไป
การป้องกันในทางปฏิบัติสามอย่างครอบคลุมพื้นผิวนี้ได้ส่วนใหญ่:
class LoginRateLimiter
{
Dictionary<string, List<float>> recentAttempts = new Dictionary<string, List<float>>();
const int MaxAttemptsPerWindow = 5;
const float WindowSeconds = 60f;
public bool AllowAttempt(string account, float nowServerTime)
{
if (!recentAttempts.ContainsKey(account))
recentAttempts[account] = new List<float>();
List<float> attempts = recentAttempts[account];
attempts.RemoveAll(t => nowServerTime - t > WindowSeconds); // drop old ones
if (attempts.Count >= MaxAttemptsPerWindow)
return false; // too many recent attempts -- block for now
attempts.Add(nowServerTime);
return true;
}
}
ไล่ trace: สคริปต์หนึ่งลองรหัสผ่าน 200 ตัวกับบัญชีเดียวใน 10 วินาที การพยายาม 5 ครั้งแรกภายในหน้าต่าง 60 วินาทีจะผ่านเข้าไปถึงการเช็ครหัสผ่านจริง (แล้วล้มเหลว เพราะเดาผิด) ครั้งที่ 6 เป็นต้นไปถูกบล็อกโดย AllowAttempt ก่อนที่จะไปถึงการเช็ครหัสผ่านด้วยซ้ำ ไม่ว่าสคริปต์จะส่งมาอีกกี่ครั้งก็ตาม ผู้เล่นจริงที่พิมพ์รหัสผ่านผิดสองครั้งแทบไม่ได้รับผลกระทบเลย
วิธีป้องกันทุกอย่างในบทนี้สามารถ และในที่สุดก็จะ ถูกหาทางเลี่ยงได้โดยคนที่ตั้งใจมากพอ — มีตลาดจริง ๆ ของคนที่สร้างและขายเครื่องมือโกง และพวกเขาอัปเดตเครื่องมือเหล่านั้นเมื่อสตูดิโอปะช่องโหว่ Anti-cheat ไม่ใช่ปัญหาที่สตูดิโอแก้ครั้งเดียวจบ มันเป็นงานต่อเนื่อง ใกล้เคียงกับการที่ธนาคารคอยป้องกันการฉ้อโกงมากกว่าการแก้บัคตัวเดียว
ความจริงสามข้อของการแข่งขันที่ไม่มีวันจบนี้ พูดตรง ๆ:
สรุปตรง ๆ: ไม่มีเทคนิคไหนในนี้เลย ไม่ว่าจะใช้เดี่ยว ๆ หรือรวมกัน ที่ทำให้เกมกันโกงได้ถาวร เป้าหมายที่เป็นจริงคือทำให้การโกงมีต้นทุนและความเสี่ยงสูงพอ และการตรวจจับเร็วพอ ที่ผู้เล่นส่วนใหญ่จะได้แมตช์ที่ยุติธรรม — ไม่ใช่การไปถึงศูนย์การโกง ซึ่งไม่มีเกมมัลติเพลเยอร์ที่วางขายจริงเกมไหนเคยทำได้จริง
class BuyItemMessage
{
public int ItemId;
public int Price; // sent by the client
public int PlayerGold; // sent by the client, "for convenience"
}
void OnBuyItem(BuyItemMessage msg, Player player)
{
if (msg.PlayerGold >= msg.Price)
{
player.Inventory.Add(msg.ItemId);
player.Gold = msg.PlayerGold - msg.Price;
}
}
handler นี้เชื่อค่าสองตัวที่ไม่ควรเชื่อเลย: msg.Price (client อ้างได้ว่าไอเทมไหนก็ตามราคา 0 เงิน) และ msg.PlayerGold (client อ้างได้ว่ามีเงินเท่าไหร่ก็ได้ แล้ว server ก็เขียนทับบันทึกเงินของผู้เล่นเองด้วยสิ่งที่ client บอก) นี่แย่กว่า handler ดาเมจแบบไร้เดียงสาในข้อ 2 เสียอีก เพราะมันไม่ได้แค่ใช้ค่าผิด — มันปล่อยให้ client กำหนดยอดเงินทั้งหมดของผู้เล่นไปข้างหน้าเลย
วิธีแก้เป็นแบบเดียวกับข้อ 2: ไปดูข้อมูลจริงบน server และไม่รับตัวเลขที่ client เสนอเกี่ยวกับตัวเองเลย
class BuyItemMessage
{
public int ItemId; // only the intent -- which item -- is needed
}
void OnBuyItem(BuyItemMessage msg, Player player)
{
ItemDef item = ItemDatabase.Lookup(msg.ItemId); // server-owned price
if (item == null) return;
if (player.Gold < item.Price) return; // server's own gold record
player.Inventory.Add(msg.ItemId);
player.Gold -= item.Price; // computed from server state, not received
}
ตอนนี้ client บอกได้แค่ว่าต้องการไอเทมไหน ราคาและเงินปัจจุบันของผู้เล่นทั้งคู่มาจากข้อมูลที่ server เป็นเจ้าของ ไม่เหลืออะไรให้ client ที่ถูกแก้ไขโกหกได้เลย
bool TryUsePotion(PlayerState player, float serverNow) ที่อนุญาตให้ใช้ potion ได้แค่ครั้งเดียวทุก 3 วินาทีจริง โดยใช้นาฬิกาของ server เอง จากนั้นไล่ trace ว่าจะเกิดอะไรขึ้นถ้า client ที่ถูกแก้ไขส่งคำขอ UsePotion 10 ครั้งภายในหน้าต่างเวลา 0.2 วินาทีเดียวกันclass PlayerState
{
public float LastPotionServerTime = -999f; // far in the past initially
}
bool TryUsePotion(PlayerState player, float serverNow)
{
const float cooldownSeconds = 3f;
if (serverNow - player.LastPotionServerTime < cooldownSeconds)
return false; // still on cooldown, reject
player.LastPotionServerTime = serverNow; // record using server's clock
// ... apply the potion's real effect here ...
return true;
}
ไล่ trace: คำขอ 10 ครั้งมาถึงระหว่าง serverNow = 10.0 ถึง serverNow = 10.2 คำขอแรก (ที่ 10.0) ผ่าน เพราะ 10.0 - (-999) >= 3 และตั้งค่า LastPotionServerTime = 10.0 อีก 9 คำขอที่เหลือมาถึงก่อนที่ serverNow จะไปถึง 13.0 ดังนั้นแต่ละคำขอจะไม่ผ่านเช็คและถูกปฏิเสธ มี potion ถูกใช้แค่ลูกเดียวเท่านั้น ไม่ว่า client ที่ถูกแก้ไขจะส่งคำขอมากี่ครั้งก็ตาม เพราะเช็คนี้อิงกับนาฬิกาของ server เองทั้งหมด ไม่ใช่สิ่งที่ client อ้างเกี่ยวกับเวลาเลย
BuildVisibleSnapshot จากข้อ 9 ร่างว่าคุณจะขยายเช็คการมองเห็นยังไงเพื่อซ่อนผู้เล่นที่หมอบในหญ้าที่อยู่เกิน 8 unit ด้วย และอธิบายในหนึ่งหรือสองประโยคว่าทำไมสิ่งนี้ถึงหยุด wallhack (หรือการโกงที่คล้ายกัน) ไม่ให้เปิดเผยผู้เล่นที่ซ่อนอยู่ได้เลย ไม่ว่า cheat จะทำอะไรบน client ก็ตามbool CanSee(Player viewer, Entity target)
{
float dist = Vector3.Distance(viewer.Position, target.Position);
if (target.IsCrouchedInGrass && dist > 8f)
return false; // hidden by the stealth rule, regardless of line of sight
if (dist > viewer.MaxViewDistance) return false;
if (!HasLineOfSight(viewer.Position, target.Position)) return false;
return true;
}
List<Entity> BuildVisibleSnapshot(Player viewer, List<Entity> allEntities)
{
List<Entity> visible = new List<Entity>();
foreach (Entity e in allEntities)
if (e == viewer.Entity || CanSee(viewer, e))
visible.Add(e);
return visible;
}
สิ่งนี้ได้ผลด้วยเหตุผลเดียวกับที่ interest management เอาชนะ wallhack ทั่วไปได้: เช็คนี้ทำงานทั้งหมดบน server ก่อนที่ packet เครือข่ายที่อธิบายสิ่งที่ viewer มองเห็นได้จะถูกสร้างขึ้นด้วยซ้ำ ผู้เล่นที่หมอบอยู่เกิน 8 unit ไม่ใช่หนึ่งในเอนทิตีที่ถูกรวมอยู่ใน packet นั้นเลย ดังนั้นตำแหน่งของพวกเขาจึงไม่มีทางไปถึง client ของศัตรูเลย ไม่ว่าจะแก้หน่วยความจำ, อ่าน packet, หรือแก้ไข client บนเครื่องของศัตรูมากแค่ไหน ก็ไม่มีทางเปิดเผยข้อมูลที่ไม่เคยถูกส่งไปที่เครื่องนั้นตั้งแต่แรกได้เลย — ความลับถูกเก็บไว้บน server ไม่ใช่ถูกซ่อนแต่ยังอยู่บน client