feat: encryption support

This commit is contained in:
beerpiss 2023-11-16 03:28:00 +07:00
parent 48988828d9
commit ebca84ceb8
10 changed files with 1292 additions and 109 deletions

4
.gitignore vendored
View File

@ -7,10 +7,6 @@
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk

1141
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "saekawa"
version = "0.1.0"
version = "0.2.0"
authors = ["beerpsi <lacvtg.a1.2023@gmail.com>"]
edition = "2021"
license = "0BSD"
@ -16,24 +16,24 @@ codegen-units = 1
panic = "abort"
[dependencies]
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "consoleapi"] }
serde = { version = "1.0.192", features = ["derive"] }
serde_json = "1.0.108"
aes = "0.8.3"
anyhow = "1.0.75"
cbc = "0.1.2"
chrono = "0.4.31"
confy = "0.5.1"
env_logger = "0.10.1"
faster-hex = "0.8.1"
flate2 = "1.0.28"
lazy_static = "1.4.0"
log = "0.4.20"
url = "2.4.1"
chrono = "0.4.31"
env_logger = "0.10.1"
retour = { version = "0.3.1", features = ["static-detour"] }
widestring = "1.0.2"
flate2 = "1.0.28"
ureq = { version = "2.8.0", features = ["json"] }
num_enum = "0.7.1"
serde-aux = "4.2.0"
pbkdf2 = "0.12.2"
retour = { version = "0.3.1", features = ["static-detour"] }
serde = { version = "1.0.192", features = ["derive"] }
serde-aux = "4.2.0"
serde_json = "1.0.108"
sha1 = "0.10.6"
hex = "0.4.3"
aes = "0.8.3"
cbc = "0.1.2"
ureq = { version = "2.8.0", features = ["json"] }
url = "2.4.1"
widestring = "1.0.2"
winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "consoleapi"] }

View File

@ -20,11 +20,13 @@ edit your segatools game.bat to look like the green line:
This hook requires the game's network communications to be decrypted, which can be done
by patching the game binary with your preferred patcher.
Support for encrypted requests might come soon.
If you choose not to patch, you will need to obtain the necessary keys and provide them
in `saekawa.toml`.
### Credits
- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was shamelessly lifted from his
[Mikado](https://github.com/adamaq01/Mikado), a similar hook for SDVX.
- Adam Thibert ([adamaq01](https://github.com/adamaq01)). A lot of the code was
shamelessly lifted from his [Mikado](https://github.com/adamaq01/Mikado), a similar
hook for SDVX.
### License
0BSD

View File

@ -3,6 +3,8 @@
enable = true
# Whether the hook should export your class medals and emblems or not.
export_class = true
# Whether FAILED should override FULL COMBO and ALL JUSTICE.
fail_over_lamp = false
# Timeout for web requests, in milliseconds
timeout = 3000

View File

@ -51,12 +51,23 @@ pub struct CardsConfiguration {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CryptoConfiguration {
pub key: String,
pub iv: String,
pub salt: String,
#[serde(with = "faster_hex")]
pub key: Vec<u8>,
#[serde(with = "faster_hex")]
pub iv: Vec<u8>,
#[serde(with = "faster_hex")]
pub salt: Vec<u8>,
#[serde(default = "default_iterations")]
pub iterations: u32,
}
fn default_iterations() -> u32 {
70
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TachiConfiguration {
pub base_url: String,

View File

@ -1,6 +1,8 @@
use std::{fmt::Debug, io::Read};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use flate2::read::ZlibDecoder;
use log::debug;
use serde::{Deserialize, Serialize};
use widestring::U16CString;
@ -15,6 +17,8 @@ use winapi::{
use crate::{CONFIGURATION, UPSERT_USER_ALL_API_ENCRYPTED};
type Aes256CbcDec = cbc::Decryptor<aes::Aes256>;
pub fn request_agent() -> ureq::Agent {
let timeout = CONFIGURATION.general.timeout;
let timeout = if timeout > 10000 { 10000 } else { timeout };
@ -78,6 +82,7 @@ where
Ok(response)
}
/// Queries a HINTERNET handle for its URL, then return the result.
pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
let mut buf_length = 255;
let mut buffer = [0u16; 255];
@ -124,8 +129,9 @@ pub fn read_hinternet_url(handle: HINTERNET) -> Result<String> {
Err(anyhow!("Could not get URL from HINTERNET handle: {ec}"))
}
pub unsafe fn read_potentially_deflated_buffer(buf: *const u8, len: usize) -> Result<Vec<u8>> {
let mut slice = std::slice::from_raw_parts(buf, len);
/// Read all bytes of a slice into a buffer.
pub fn read_slice(buf: *const u8, len: usize) -> Result<Vec<u8>> {
let mut slice = unsafe { std::slice::from_raw_parts(buf, len) };
let mut ret = Vec::with_capacity(len);
slice.read_to_end(&mut ret)?;
@ -133,23 +139,52 @@ pub unsafe fn read_potentially_deflated_buffer(buf: *const u8, len: usize) -> Re
Ok(ret)
}
pub fn is_upsert_user_all_endpoint(endpoint: &str, is_encrypted: bool) -> bool {
pub fn read_maybe_compressed_buffer(buf: impl AsRef<[u8]>) -> Result<String> {
let mut ret = String::new();
let mut decoder = ZlibDecoder::new(buf.as_ref());
let zlib_result = decoder.read_to_string(&mut ret);
if zlib_result.is_ok() {
return Ok(ret);
}
ret.clear();
let result = buf.as_ref().read_to_string(&mut ret);
if result.is_ok() {
return Ok(ret);
}
Err(anyhow!(
"Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.",
zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."),
))
}
/// Determine if we're looking at the UpsertUserAllApi endpoint,
/// which is the endpoint that contains our scores.
pub fn is_upsert_user_all_endpoint(endpoint: &str) -> bool {
if endpoint == "UpsertUserAllApi" {
return true;
}
if is_encrypted && endpoint == UPSERT_USER_ALL_API_ENCRYPTED.as_str() {
if UPSERT_USER_ALL_API_ENCRYPTED
.as_ref()
.is_some_and(|v| v == endpoint)
{
return true;
}
false
}
/// Determine if it is an encrypted endpoint by checking if the endpoint
/// is exactly 32 characters long, and consists of all hex characters.
///
/// While this may trigger false positives, this should not happen as long
/// as CHUNITHM title APIs keep their `{method}{object}Api` endpoint
/// convention.
pub fn is_encrypted_endpoint(endpoint: &str) -> bool {
// If it's a hex string of 32 characters, we're most likely dealing
// with title server encryption. Chance of false positive is very low,
// but technically not 0.
if endpoint.len() != 32 {
return false;
}
@ -161,3 +196,15 @@ pub fn is_encrypted_endpoint(endpoint: &str) -> bool {
true
}
pub fn decrypt_aes256_cbc(
body: &mut [u8],
key: impl AsRef<[u8]>,
iv: impl AsRef<[u8]>,
) -> Result<Vec<u8>> {
let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?;
Ok(cipher
.decrypt_padded_mut::<Pkcs7>(body)
.map_err(|err| anyhow!("{:#}", err))?
.to_owned())
}

View File

@ -4,7 +4,7 @@ mod log;
mod saekawa;
mod types;
use ::log::error;
use ::log::{debug, error};
use lazy_static::lazy_static;
use pbkdf2::pbkdf2_hmac_array;
use sha1::Sha1;
@ -46,27 +46,22 @@ lazy_static! {
result.unwrap().to_string()
};
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: String = {
pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option<String> = {
if CONFIGURATION.crypto.salt.is_empty() {
// return a bullshit value
return "ffffffffffffffffffffffffffffffff".to_string();
return None;
}
let salt = match hex::decode(&CONFIGURATION.crypto.salt) {
Ok(salt) => salt,
Err(err) => {
error!("Could not parse salt as hex string: {:#}", err);
std::process::exit(1);
}
};
let key = pbkdf2_hmac_array::<Sha1, 16>(
let key_bytes = pbkdf2_hmac_array::<Sha1, 16>(
b"UpsertUserAllApi",
&salt,
CONFIGURATION.crypto.iterations
&CONFIGURATION.crypto.salt,
CONFIGURATION.crypto.iterations,
);
hex::encode(key)
let key = faster_hex::hex_string(&key_bytes);
debug!("Running with encryption support: UpsertUserAllApi maps to {key}");
Some(key)
};
}

View File

@ -1,9 +1,7 @@
use std::{ffi::CString, io::Read, ptr};
use std::ffi::CString;
use ::log::{debug, error, info};
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use anyhow::{anyhow, Result};
use flate2::read::ZlibDecoder;
use retour::static_detour;
use winapi::{
ctypes::c_void,
@ -15,18 +13,16 @@ use winapi::{
},
};
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
use crate::{
helpers::{
self, call_tachi, is_encrypted_endpoint, is_upsert_user_all_endpoint, read_hinternet_url,
read_potentially_deflated_buffer,
call_tachi, decrypt_aes256_cbc, is_encrypted_endpoint, is_upsert_user_all_endpoint,
read_hinternet_url, read_maybe_compressed_buffer, read_slice, request_tachi,
},
types::{
game::UpsertUserAllRequest,
tachi::{ClassEmblem, Import, ImportClasses, ImportScore},
},
CONFIGURATION, TACHI_IMPORT_URL, TACHI_STATUS_URL,
CONFIGURATION, TACHI_IMPORT_URL, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED,
};
type WinHttpWriteDataFunc = unsafe extern "system" fn(HINTERNET, LPCVOID, DWORD, LPDWORD) -> BOOL;
@ -40,8 +36,7 @@ pub fn hook_init() -> Result<()> {
return Ok(());
}
let resp: serde_json::Value =
helpers::request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
let resp: serde_json::Value = request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?;
let user_id = resp["body"]["whoami"]
.as_u64()
.ok_or(anyhow::anyhow!("Couldn't parse user from Tachi response"))?;
@ -55,9 +50,7 @@ pub fn hook_init() -> Result<()> {
};
unsafe {
DetourWriteData.initialize(winhttpwritedata, move |a, b, c, d| {
winhttpwritedata_hook(a, b, c, d)
})?;
DetourWriteData.initialize(winhttpwritedata, winhttpwritedata_hook)?;
DetourWriteData.enable()?;
};
@ -76,7 +69,7 @@ pub fn hook_release() -> Result<()> {
Ok(())
}
unsafe fn winhttpwritedata_hook(
fn winhttpwritedata_hook(
h_request: HINTERNET,
lp_buffer: LPCVOID,
dw_number_of_bytes_to_write: DWORD,
@ -84,7 +77,7 @@ unsafe fn winhttpwritedata_hook(
) -> BOOL {
debug!("hit winhttpwritedata");
let orig = || {
let orig = || unsafe {
DetourWriteData.call(
h_request,
lp_buffer,
@ -102,59 +95,55 @@ unsafe fn winhttpwritedata_hook(
};
debug!("winhttpwritedata URL: {url}");
let endpoint = match url.split('/').last() {
Some(endpoint) => endpoint,
None => {
error!("Could not get name of endpoint");
return orig();
}
let Some(endpoint) = url.split('/').last() else {
error!("Could not get name of endpoint");
return orig();
};
let is_encrypted = is_encrypted_endpoint(endpoint);
if is_encrypted
&& (CONFIGURATION.crypto.key.is_empty()
|| CONFIGURATION.crypto.iv.is_empty()
|| CONFIGURATION.crypto.salt.is_empty())
{
error!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'.");
return orig();
}
let is_upsert_user_all = is_upsert_user_all_endpoint(endpoint, is_encrypted);
let is_upsert_user_all = is_upsert_user_all_endpoint(endpoint);
// Exit early if release mode and the endpoint is not what we're looking for
if cfg!(not(debug_assertions)) && !is_upsert_user_all {
return orig();
}
let raw_request_body = match read_potentially_deflated_buffer(
lp_buffer as *const u8,
dw_number_of_bytes_to_write as usize,
) {
Ok(data) => data,
Err(err) => {
error!("There was an error reading the request body: {:#}", err);
return orig();
}
};
let is_encrypted = is_encrypted_endpoint(endpoint);
if is_encrypted
&& (CONFIGURATION.crypto.key.is_empty()
|| CONFIGURATION.crypto.iv.is_empty()
|| UPSERT_USER_ALL_API_ENCRYPTED.is_none())
{
error!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'.");
return orig();
}
let request_body_decrypted = if is_encrypted {
// TODO: Decrypt
Vec::new()
let mut raw_request_body =
match read_slice(lp_buffer as *const u8, dw_number_of_bytes_to_write as usize) {
Ok(data) => data,
Err(err) => {
error!("There was an error reading the request body: {:#}", err);
return orig();
}
};
debug!("raw request body: {:?}", raw_request_body);
let request_body_compressed = if is_encrypted {
match decrypt_aes256_cbc(
&mut raw_request_body,
&CONFIGURATION.crypto.key,
&CONFIGURATION.crypto.iv,
) {
Ok(res) => res,
Err(err) => {
error!("Could not decrypt request: {:#}", err);
return orig();
}
}
} else {
raw_request_body
};
let mut request_body_bytes = Vec::with_capacity(request_body_decrypted.len());
let mut decoder = ZlibDecoder::new(&request_body_decrypted[..]);
if let Err(err) = decoder.read_to_end(&mut request_body_bytes) {
debug!(
"Could not inflate request body, treating it as uncompressed: {:#}",
err
);
request_body_bytes = request_body_decrypted;
}
let request_body = match String::from_utf8(request_body_bytes) {
let request_body = match read_maybe_compressed_buffer(&request_body_compressed[..]) {
Ok(data) => data,
Err(err) => {
error!("There was an error decoding the request body: {:#}", err);
@ -162,7 +151,7 @@ unsafe fn winhttpwritedata_hook(
}
};
debug!("winhttpwritedata request body: {request_body}");
debug!("decoded request body: {request_body}");
// Reached in debug mode
if !is_upsert_user_all {
@ -245,13 +234,13 @@ fn get_proc_address(module: &str, function: &str) -> Result<*mut __some_function
let fun_name = CString::new(function).unwrap();
let module = unsafe { GetModuleHandleA(module_name.as_ptr()) };
if (module as *const c_void) == ptr::null() {
if (module as *const c_void).is_null() {
let ec = unsafe { GetLastError() };
return Err(anyhow!("could not get module handle, error code {ec}"));
}
let addr = unsafe { GetProcAddress(module, fun_name.as_ptr()) };
if (addr as *const c_void) == ptr::null() {
if (addr as *const c_void).is_null() {
let ec = unsafe { GetLastError() };
return Err(anyhow!("could not get function address, error code {ec}"));
}

View File

@ -76,7 +76,7 @@ pub struct UserPlaylog {
pub is_clear: bool,
}
fn default_judge_heaven<'a>() -> u32 {
fn default_judge_heaven() -> u32 {
0
}