1
Fork 0
mirror of https://github.com/redstrate/Kawari.git synced 2025-07-09 15:37:45 +00:00

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.
This commit is contained in:
The Dax 2025-07-02 22:13:30 -04:00 committed by Joshua Goins
parent ca90d8b787
commit d011f11e54
5 changed files with 148 additions and 2 deletions

7
Cargo.lock generated
View file

@ -762,6 +762,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml_ng", "serde_yaml_ng",
"sha1_smol",
"tokio", "tokio",
"tower-http", "tower-http",
"tracing", "tracing",
@ -1311,6 +1312,12 @@ dependencies = [
"unsafe-libyaml", "unsafe-libyaml",
] ]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"

View file

@ -108,3 +108,6 @@ rkon = { version = "0.1" }
# For serving static files on the website # For serving static files on the website
tower-http = { version = "0.6", features = ["fs", "cors"] } tower-http = { version = "0.6", features = ["fs", "cors"] }
# For obtaining SHA1 hashes of game components
sha1_smol = { version = "1.0.1" }

View file

@ -1,6 +1,7 @@
use kawari::RECEIVE_BUFFER_SIZE; use kawari::RECEIVE_BUFFER_SIZE;
use kawari::common::GameData; use kawari::common::GameData;
use kawari::config::get_config; use kawari::config::get_config;
use kawari::get_supported_expac_versions;
use kawari::ipc::kawari::CustomIpcData; use kawari::ipc::kawari::CustomIpcData;
use kawari::ipc::kawari::CustomIpcSegment; use kawari::ipc::kawari::CustomIpcSegment;
use kawari::ipc::kawari::CustomIpcType; use kawari::ipc::kawari::CustomIpcType;
@ -11,9 +12,134 @@ use kawari::lobby::send_custom_world_packet;
use kawari::packet::ConnectionType; use kawari::packet::ConnectionType;
use kawari::packet::oodle::OodleNetwork; use kawari::packet::oodle::OodleNetwork;
use kawari::packet::{PacketState, SegmentData, send_keep_alive}; use kawari::packet::{PacketState, SegmentData, send_keep_alive};
use std::fs;
use std::path::MAIN_SEPARATOR_STR;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::net::TcpListener; 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::<u64>() {
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] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@ -78,6 +204,15 @@ async fn main() {
let config = get_config(); 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!( let Ok(login_reply) = reqwest::get(format!(
"http://{}/_private/service_accounts?sid={}", "http://{}/_private/service_accounts?sid={}",
config.login.server_name, session_id config.login.server_name, session_id

View file

@ -66,6 +66,7 @@ impl FrontierConfig {
pub struct LobbyConfig { pub struct LobbyConfig {
pub port: u16, pub port: u16,
pub listen_address: String, pub listen_address: String,
pub do_version_checks: bool,
} }
impl Default for LobbyConfig { impl Default for LobbyConfig {
@ -73,6 +74,7 @@ impl Default for LobbyConfig {
Self { Self {
port: 7000, port: 7000,
listen_address: "0.0.0.0".to_string(), listen_address: "0.0.0.0".to_string(),
do_version_checks: true,
} }
} }
} }

View file

@ -117,8 +117,7 @@ pub enum ClientLobbyIpcData {
#[bw(ignore)] #[bw(ignore)]
session_id: String, session_id: String,
#[brw(pad_before = 8)] // empty #[br(count = 145)]
#[br(count = 128)]
#[br(map = read_string)] #[br(map = read_string)]
#[bw(ignore)] #[bw(ignore)]
version_info: String, version_info: String,