Чит на телепортацію у Vintage Story: сервер довіряє вашим координатам

Версія гри

Протестовано на Vintage Story v1.21.6 (Stable), .NET 8.0. Розбір обмежено цією версією та показаними нижче шляхами обробки.

Попередження

Це технічна стаття для вивчення будови гри. Перевіряйте описані прийоми лише в одиночній грі, на власному сервері або з дозволу власника сервера. Дотримуйтеся правил сервера.

Попередня стаття

Це продовження статті Чит на креативний режим у Vintage Story через реверс-інжиніринг. Мод на креативний режим використовує Harmony-патчі для перехоплення пакетів. Експлойт з телепортацією простіший — жодних патчів, просто присвоєння поля.

Що ми робимо

Розбираємо клієнтський мод телепортації в перевіреній конфігурації. Для телепортації до гравця його сутність має бути завантажена на клієнті. У цьому прикладі не використовуються права адміністратора, серверний мод або Harmony-патчі.

Ось як це виглядає на реальному сервері:

TL;DR

Показаний шлях HandlePlayerPosition перевіряє PositionVersion, потім копіює координати клієнта в entity.ServerPos без перевірки відстані, швидкості чи колізій. У перевіреній конфігурації зміни entity.Pos передаються серверу цим шляхом. За цим фрагментом не можна судити про перевірки чи моніторинг в інших частинах системи.


Розбираємо синхронізацію позиції

Звичайний потік

Кожен ігровий тик (~15мс) клієнт надсилає UDP-пакет з поточною позицією гравця:

public void SendPlayerPositionPacket()
{
    Packet_EntityPosition entityPositionPacket =
        ServerPackets.getEntityPositionPacket(game.EntityPlayer.Pos, game.EntityPlayer, 0);

    Packet_UdpPacket udpPacket = new Packet_UdpPacket
    {
        Id = 2,
        EntityPosition = entityPositionPacket
    };

    if (game.FallBackToTcp)
        game.SendPacketClient(new Packet_Client { Id = 35, UdpPacket = udpPacket });
    else
        game.UdpNetClient.Send(udpPacket);
}

Пакет позиції формується в getEntityPositionPacket, який серіалізує X/Y/Z, yaw, pitch, motion та лічильник PositionVersion:

public static Packet_EntityPosition getEntityPositionPacket(EntityPos pos, Entity entity, int tick)
{
    return new Packet_EntityPosition
    {
        EntityId = entity.EntityId,
        X = CollectibleNet.SerializeDoublePrecise(pos.X),
        Y = CollectibleNet.SerializeDoublePrecise(pos.Y),
        Z = CollectibleNet.SerializeDoublePrecise(pos.Z),
        Yaw = CollectibleNet.SerializeFloatPrecise(pos.Yaw),
        Pitch = CollectibleNet.SerializeFloatPrecise(pos.Pitch),
        Roll = CollectibleNet.SerializeFloatPrecise(pos.Roll),
        MotionX = CollectibleNet.SerializeDoublePrecise(pos.Motion.X),
        MotionY = CollectibleNet.SerializeDoublePrecise(pos.Motion.Y),
        MotionZ = CollectibleNet.SerializeDoublePrecise(pos.Motion.Z),
        PositionVersion = entity.WatchedAttributes.GetInt("positionVersionNumber"),
        Tick = tick,
        // ... теги, поворот тіла, контроли
    };
}

Обробка на сервері

Сервер отримує цей пакет у ServerUdpNetwork.HandlePlayerPosition:

public void HandlePlayerPosition(Packet_EntityPosition packet, ServerPlayer player)
{
    if (packet == null)
        return;

    EntityPlayer entity = player.Entity;
    int num = entity.WatchedAttributes.GetInt("positionVersionNumber");
    if (packet.PositionVersion < num)
        return;

    player.LastReceivedClientPosition = server.ElapsedMilliseconds;

    entity.ServerPos.SetFromPacket(packet, entity);
    entity.Pos.SetFromPacket(packet, entity);

    // ... повідомити фізичні поведінки, транслювати іншим гравцям
}

А SetFromPacket — це просто пряме присвоєння:

public static void SetFromPacket(this EntityPos pos, Packet_EntityPosition packet, Entity entity)
{
    pos.X = CollectibleNet.DeserializeDoublePrecise(packet.X);
    pos.Y = CollectibleNet.DeserializeDoublePrecise(packet.Y);
    pos.Z = CollectibleNet.DeserializeDoublePrecise(packet.Z);
    pos.Yaw = CollectibleNet.DeserializeFloatPrecise(packet.Yaw);
    pos.Pitch = CollectibleNet.DeserializeFloatPrecise(packet.Pitch);
    pos.Roll = CollectibleNet.DeserializeFloatPrecise(packet.Roll);
    pos.HeadYaw = CollectibleNet.DeserializeFloatPrecise(packet.HeadYaw);
    pos.HeadPitch = CollectibleNet.DeserializeFloatPrecise(packet.HeadPitch);
    pos.Motion.X = CollectibleNet.DeserializeDoublePrecise(packet.MotionX);
    pos.Motion.Y = CollectibleNet.DeserializeDoublePrecise(packet.MotionY);
    pos.Motion.Z = CollectibleNet.DeserializeDoublePrecise(packet.MotionZ);
    // ...
}
Критична знахідка

Показаний шлях HandlePlayerPosition перевіряє PositionVersion, потім копіює координати клієнта в entity.ServerPos без перевірки відстані, швидкості чи колізій. У перевіреній конфігурації зміни entity.Pos передаються серверу цим шляхом. За цим фрагментом не можна судити про перевірки чи моніторинг в інших частинах системи.

Яких перевірок немає в показаному обробнику

Показаний обробник перевіряє версію позиції:

int num = entity.WatchedAttributes.GetInt("positionVersionNumber");
if (packet.PositionVersion < num)
    return;  // відхилити застарілі/повторно надіслані пакети

Ця перевірка PositionVersion запобігає перезапису нових позицій старими пакетами (наприклад, після серверного телепорту). Вона не запобігає:

  • Переміщенню далі, ніж фізично можливо за один тик
  • Руху швидше за допустиму швидкість гравця
  • Телепортації через всю карту одним пакетом
  • Проходженню крізь тверді блоки
  • Переміщення за координатами без перевірки швидкості чи відстані

У показаному шляху немає перевірки AntiAbuse або звернення до опції Config для перевірки позиції. Інші обробники чи серверні моди можуть додавати перевірки.

Порівняння з перевірками встановлення блоків

В тому ж коді є перевірки AntiAbuse для встановлення блоків:

if (server.Config.AntiAbuse != EnumProtectionLevel.Off && !flag
    && num > (double)((player.WorldData.PickingRange + 0.7f)
                    * (player.WorldData.PickingRange + 0.7f)))
{
    ServerMain.Logger.Audit("Client {0} tried to break/place a block out of range");
    return false;
}

А HandleMountPosition — який обробляє верхових сутностей — перевіряє володіння перед прийняттям координат:

Entity entityById = server.api.World.GetEntityById(packet.EntityId);
IMountable mountable = entityById?.GetInterface<IMountable>();
if (mountable == null || !mountable.IsMountedBy(player.Entity))
{
    return;  // відхилити — ви не їдете на цій сутності
}
// лише потім: entityById.ServerPos.SetFromPacket(packet, entityById);

Інфраструктура для валідації існує. Її застосовано до блоків та верхових сутностей. До позиції гравця — ні.


Експлойт

Ядро: 12 рядків

private void TeleportTo(double x, double y, double z)
{
    var player = clientApi.World.Player;
    if (player?.Entity == null) return;

    EntityPlayer entity = player.Entity;

    entity.Pos.X = x;
    entity.Pos.Y = y;
    entity.Pos.Z = z;
    entity.Pos.Motion.Set(0, 0, 0);

    entity.ServerPos.X = x;
    entity.ServerPos.Y = y;
    entity.ServerPos.Z = z;
    entity.ServerPos.Motion.Set(0, 0, 0);
}

Ми встановлюємо і Pos (клієнтська позиція рендерингу), і ServerPos (що клієнт вважає відомим серверу). Наступного тику SendPlayerPositionPacket зчитує entity.Pos, серіалізує та надсилає по UDP. Сервер викликає SetFromPacket — і ми на місці.

Жодних Harmony-патчів. Жодного створення пакетів вручну. Жодної рефлексії. Пишемо в публічне поле і дозволяємо існуючому мережевому циклу гри доставити його.

Пастка системи координат

Дві системи координат

Vintage Story використовує два різних початки відліку для координат. Помилка телепортує вас на інший край карти.

КонтекстПочаток відлікуПриклад
UI гри (оверлей F1, чат)Точка спавну світу(500, 148, -200)
Внутрішні (entity.Pos)Абсолютний початок світу(512500, 148, 511800)

Конвертація:

Внутрішні = UI-координати + позиція спавну
UI-координати = Внутрішні - позиція спавну

Для стандартного світу 1024k спавн знаходиться приблизно в (512000, ?, 512000). Отже UI-координата (500, 148, -200) насправді абсолютна (512500, 148, 511800).

UI гри віднімає позицію спавну при відображенні координат:

asBlockPos.Sub(game.SpawnPosition.AsBlockPos.X, 0, game.SpawnPosition.AsBlockPos.Z);

Виправлення в нашій функції телепортації:

var spawn = clientApi.World.DefaultSpawnPosition;
double absX = uiX + spawn.X;
double absY = uiY;         // Y не зміщується
double absZ = uiZ + spawn.Z;

TeleportTo(absX, absY, absZ);

Команди

Мод реєструє три клієнтські команди чату через api.RegisterCommand. Команди з префіксом . обробляються локально; спричинені ними зміни позиції все одно надсилаються серверу.

Як обробляються команди з крапкою

Ці повідомлення обробляються двома шляхами:

  1. На клієнті: HudDialogChat.HandleClientMessage перевіряє message.StartsWith("."), викликає HandleClientCommand і повертається без надсилання в мережу.
  2. На сервері (запасний варіант): ServerMain.HandleChatMessage мовчки відкидає будь-яке повідомлення, що починається з . — навіть якщо клієнт якимось чином його надіслав.

.tp <name> — телепортація до гравця

Шукає в clientApi.World.AllOnlinePlayers відповідне ім’я (без урахування регістру, підтримка часткового збігу), зчитує targetPlayer.Entity.Pos та викликає TeleportTo:

private void OnTpCommand(int groupId, CmdArgs args)
{
    string targetName = args.PopWord();

    IPlayer targetPlayer = null;
    foreach (var p in clientApi.World.AllOnlinePlayers)
    {
        if (p.PlayerName.Equals(targetName, StringComparison.OrdinalIgnoreCase))
        {
            targetPlayer = p;
            break;
        }
    }
    // ... запасний варіант — частковий збіг через StartsWith ...

    EntityPos targetPos = targetPlayer.Entity.Pos;
    TeleportTo(targetPos.X, targetPos.Y, targetPos.Z);
}

.tplist — список усіх гравців з координатами

Перебирає всіх онлайн-гравців, показує їхні позиції відносно спавну (як в UI гри) та розраховує відстань:

[BHacks] === Online Players ===
  Me: (512.3, 148.0, -203.7) [0m away]
  SomePlayer: (-1024.5, 155.0, 800.2) [1847m away]
  AnotherOne: (200.0, 72.0, 100.5) [380m away]

.tppos <x> <y> <z> — телепортація за координатами

Приймає UI-координати (відносно спавну), конвертує в абсолютні та телепортує:

.tppos 500 148 -200
[BHacks] Teleported to (500.0, 148.0, -200.0)

Повний потік пакетів

Client                                            Server
  |                                                 |
  |  .tp SomePlayer                                 |
  |  (оброблено локально, не надіслано серверу)      |
  |                                                 |
  |  entity.Pos.X = target.X                        |
  |  entity.Pos.Y = target.Y                        |
  |  entity.Pos.Z = target.Z                        |
  |                                                 |
  |  ~15мс потому: звичайний тик позиції             |
  |                                                 |
  |  UDP Packet {                                   |
  |    Id: 2                                        |
  |    X: target.X (serialized)                     |
  |    Y: target.Y (serialized)                     |
  |    Z: target.Z (serialized)                     |
  |    PositionVersion: current                     |
  |  }                                              |
  | ----------------------------------------------> |
  |                                                 |
  |                                                 |  HandlePlayerPosition:
  |                                                 |    перевірка версії: OK
  |                                                 |    SetFromPacket()
  |                                                 |    // немає перевірки дистанції
  |                                                 |    // немає перевірки швидкості
  |                                                 |    // позицію прийнято
  |                                                 |
  |                                                 |  Сервер тепер вважає гравця
  |                                                 |  в цільових координатах.
  |                                                 |  Чанки завантажуються навколо.
  |                                                 |  Інші гравці бачать вас там.
  |                                                 |

У показаному шляху сервер обробляє оновлення як пакет руху. Це не робить переміщення непомітним: сервер отримує змінені координати. За цими фрагментами не можна встановити, чи реєструють або виявляють зміну інші системи.


Чому це працює

У вивченому шляху синхронізації клієнт обчислює рух і повідомляє координати. Показаний серверний обробник приймає їх після перевірки версії позиції, не перевіряючи швидкість чи відстань. Цей висновок стосується лише вивченого шляху та перевіреної конфігурації.

Це свідоме проєктне рішення. Серверно-авторитетна фізика вимагала б:

  • Запуску повного виявлення колізій для кожного гравця на сервері кожен тик
  • Надсилання вводу (WASD, стрибок, присідання) замість позицій
  • Узгодження клієнтського передбачення із серверним станом
  • Компенсації затримки

Приймання координат клієнта без перевірки руху дозволяє модифікованому клієнту повідомляти координати поза звичайними обмеженнями руху.


Обмеження

Телепортація лише до завантажених гравців

.tp <name> зчитує координати з targetPlayer.Entity.Pos. Але Entity заповнений лише для гравців, чиї сутності завантажені на вашому клієнті — зазвичай ті, хто в межах вашої дальності промальовки. Далекі гравці присутні в AllOnlinePlayers з Entity == null:

if (targetPlayer.Entity == null)
{
    clientApi.ShowChatMessage("[BHacks] Player has no entity (not fully loaded?)");
    return;
}

.tppos не має такого обмеження — можна телепортуватися за будь-якими координатами, завантаженими чи ні. Сервер завантажить чанки навколо вашої нової позиції.

Що бачать інші гравці

З точки зору інших гравців, ви зникаєте з однієї точки і з’являєтесь в іншій на наступному серверному тику (~15мс). Немає анімації переміщення, немає сліду, немає переходу — ви просто виникаєте на цільовій позиції.

Сервер транслює вашу нову позицію через UDP-пакети BulkEntityPosition усім гравцям, що відстежують вашу сутність. Якщо ви телепортуєтесь за межі чиєїсь дальності промальовки, ви деспавнитесь на їхньому клієнті. Якщо телепортуєтесь у чиюсь дальність промальовки — з’явитесь.

Запису в серверному логу немає. HandlePlayerPosition не логує зміни позиції, незалежно від дистанції. Єдиний спосіб, яким адмін може помітити — візуально спостерігаючи за вами або перевіряючи ваші координати вручну.


Підсумок

Хак телепортації — 12 рядків присвоєння позиції + конвертація координат. Жодних Harmony-патчів, жодних маніпуляцій з пакетами — просто запис у entity.Pos та очікування наступного мережевого тику.

Чому це працює:

  1. Клієнт володіє своєю позицією. SendPlayerPositionPacket зчитує з entity.Pos кожні ~15мс і надсилає серверу.
  2. Сервер довіряє безумовно. HandlePlayerPosition викликає SetFromPacket без валідації дистанції, швидкості чи колізій.
  3. Команди залишаються локальними. Префікс . гарантує, що команди не потраплять на сервер — клієнт перехоплює їх, а сервер відкидає як запасний варіант.
  4. Конвертація координат — єдина складність. UI гри показує координати відносно спавну; внутрішні позиції абсолютні. Забувши додати DefaultSpawnPosition, ви опинитесь на іншому краї карти.
  5. Інфраструктура валідації існує, але не використовується. У встановлення блоків є перевірки AntiAbuse. У верхових позицій — перевірка володіння. У позиції гравця — нічого.

Читайте також

Підхід із Harmony розібрано в статті про режим Creative: у ній локальний стан клієнта відокремлено від серверних прав.

Обидві статті зібрано в розділі теми Vintage Story.

Повідомити про помилку

Знайшли помилку? Напишіть мені в LinkedIn: додайте посилання на сторінку й зазначте, що потрібно виправити.