Чит на телепортацию в 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: приложите ссылку на страницу и укажите, что нужно исправить.