From d011f11e546a96b5238564aa6a96cb6626d15203 Mon Sep 17 00:00:00 2001 From: The Dax Date: Wed, 2 Jul 2025 22:13:30 -0400 Subject: [PATCH] Lobby server: implement a server-side version check against the version info the client sends. -TLDR: it checks file length and sha1 hash of the game exe, and all of the version strings. --- Cargo.lock | 7 +++ Cargo.toml | 3 + src/bin/kawari-lobby.rs | 135 ++++++++++++++++++++++++++++++++++++++++ src/config.rs | 2 + src/ipc/lobby/mod.rs | 3 +- 5 files changed, 148 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36c95e7..a69661d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,6 +762,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sha1_smol", "tokio", "tower-http", "tracing", @@ -1311,6 +1312,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index 503beae..b498050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,3 +108,6 @@ rkon = { version = "0.1" } # For serving static files on the website tower-http = { version = "0.6", features = ["fs", "cors"] } + +# For obtaining SHA1 hashes of game components +sha1_smol = { version = "1.0.1" } diff --git a/src/bin/kawari-lobby.rs b/src/bin/kawari-lobby.rs index f97bd39..85e72e0 100644 --- a/src/bin/kawari-lobby.rs +++ b/src/bin/kawari-lobby.rs @@ -1,6 +1,7 @@ use kawari::RECEIVE_BUFFER_SIZE; use kawari::common::GameData; use kawari::config::get_config; +use kawari::get_supported_expac_versions; use kawari::ipc::kawari::CustomIpcData; use kawari::ipc::kawari::CustomIpcSegment; use kawari::ipc::kawari::CustomIpcType; @@ -11,9 +12,134 @@ use kawari::lobby::send_custom_world_packet; use kawari::packet::ConnectionType; use kawari::packet::oodle::OodleNetwork; use kawari::packet::{PacketState, SegmentData, send_keep_alive}; +use std::fs; +use std::path::MAIN_SEPARATOR_STR; use tokio::io::AsyncReadExt; use tokio::net::TcpListener; +/// Allows the lobby server to do a thorough client version check. +/// First, it checks the local game executable's file length against the client-specified size. +/// Second, it calculates a SHA1 hash against the locally stored game executable and compares it to the client-specified hash. +/// Finally, it compares expansion pack version strings provided by the client against locally stored information. +/// If, and only if, all of these checks pass, does the client get allowed in. +fn do_game_version_check(client_version_str: &str) -> bool { + let config = get_config(); + const VERSION_STR_LEN: usize = 145; + + if client_version_str.len() != VERSION_STR_LEN { + tracing::error!( + "Version string sent by client is invalid or malformed, its length is {}! Rejecting session!", + client_version_str.len() + ); + return false; + } + + let game_exe_path = [ + config.game_location, + MAIN_SEPARATOR_STR.to_string(), + "ffxiv_dx11.exe".to_string(), + ] + .join(""); + if let Ok(game_md) = fs::metadata(&game_exe_path) { + let expected_exe_len = game_md.len(); + + let parts: Vec<&str> = client_version_str.split("+").collect(); + if parts[0].starts_with("ffxiv_dx11.exe") { + let exe_parts: Vec<&str> = parts[0].split("/").collect(); + match exe_parts[1].parse::() { + Ok(client_exe_len) => { + if client_exe_len != expected_exe_len { + tracing::error!( + "Client's game executable length is incorrect! Rejecting session! Got {}, expected {}", + client_exe_len, + expected_exe_len + ); + return false; + } else { + tracing::info!("Client's game executable length is OK."); + } + } + Err(err) => { + tracing::error!( + "Game's version string is malformed, unable to parse executable length field! Rejecting session! Got {}, further info: {}", + exe_parts[1], + err + ); + return false; + } + } + + let client_exe_hash = exe_parts[2]; + + match std::fs::read(&game_exe_path) { + Ok(game_exe_filebuffer) => { + let expected_exe_hash = sha1_smol::Sha1::from(game_exe_filebuffer) + .digest() + .to_string(); + if client_exe_hash != expected_exe_hash { + tracing::error!( + "Client's game executable is corrupted! Rejecting session! Got {}, expected {}", + client_exe_hash, + expected_exe_hash + ); + return false; + } else { + tracing::info!("Client's game executable hash is OK."); + } + } + Err(err) => { + panic!( + "Unable to read our game executable file! Stopping lobby server! Further information: {err}", + ); + } + } + + let client_expansion_versions = &parts[1..]; + + let supported_expansion_versions = get_supported_expac_versions(); + if client_expansion_versions.len() != supported_expansion_versions.len() { + tracing::error!( + "Client sent a malformed version string! It is missing one or more expansion versions! Rejecting session!" + ); + return false; + } + + // We need these in order, and hashmaps don't guarantee this. + let expected_versions = [ + &supported_expansion_versions["ex1"].0, + &supported_expansion_versions["ex2"].0, + &supported_expansion_versions["ex3"].0, + &supported_expansion_versions["ex4"].0, + &supported_expansion_versions["ex5"].0, + ]; + + for expansion in client_expansion_versions + .iter() + .zip(expected_versions.iter()) + { + // The client doesn't send a patch2 value in its expansion version strings, so we just pretend it doesn't exist on our side. + let expected_version = &expansion.1[..expansion.1.len() - 5].to_string(); + let client_version = *expansion.0; + if client_version != expected_version { + tracing::error!( + "One of the client's expansion versions does not match! Rejecting session! Got {}, expected {}", + client_version, + expected_version + ); + return false; + } + } + tracing::info!("All client version checks succeeded! Allowing session!"); + return true; + } + tracing::error!( + "Client sent a malformed version string! It doesn't declare the name of the game executable correctly! Rejecting session!" + ); + return false; + } + panic!("Our game executable doesn't exist! We can't do version checks!"); +} + #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); @@ -78,6 +204,15 @@ async fn main() { let config = get_config(); + // The lobby server does its own version check as well, but it can be turned off if desired. + if config.lobby.do_version_checks + && !do_game_version_check(version_info) + { + // "A version update is required." + connection.send_error(*sequence, 1012, 13101).await; + break; + } + let Ok(login_reply) = reqwest::get(format!( "http://{}/_private/service_accounts?sid={}", config.login.server_name, session_id diff --git a/src/config.rs b/src/config.rs index d9266ee..563a9a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,6 +66,7 @@ impl FrontierConfig { pub struct LobbyConfig { pub port: u16, pub listen_address: String, + pub do_version_checks: bool, } impl Default for LobbyConfig { @@ -73,6 +74,7 @@ impl Default for LobbyConfig { Self { port: 7000, listen_address: "0.0.0.0".to_string(), + do_version_checks: true, } } } diff --git a/src/ipc/lobby/mod.rs b/src/ipc/lobby/mod.rs index 7a27bee..34a609c 100644 --- a/src/ipc/lobby/mod.rs +++ b/src/ipc/lobby/mod.rs @@ -117,8 +117,7 @@ pub enum ClientLobbyIpcData { #[bw(ignore)] session_id: String, - #[brw(pad_before = 8)] // empty - #[br(count = 128)] + #[br(count = 145)] #[br(map = read_string)] #[bw(ignore)] version_info: String,