add Rust ScrapHacks prototype and network sniffer/parser
This commit is contained in:
		
							parent
							
								
									58407ecc9f
								
							
						
					
					
						commit
						63962c95cc
					
				
					 27 changed files with 5008 additions and 0 deletions
				
			
		
							
								
								
									
										2
									
								
								tools/remaster/scrap_net/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								tools/remaster/scrap_net/.gitignore
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,2 @@ | |||
| /target | ||||
| /.history | ||||
							
								
								
									
										1015
									
								
								tools/remaster/scrap_net/Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										1015
									
								
								tools/remaster/scrap_net/Cargo.lock
									
										
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										28
									
								
								tools/remaster/scrap_net/Cargo.toml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								tools/remaster/scrap_net/Cargo.toml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | |||
| [package] | ||||
| name = "scrap_net" | ||||
| version = "0.1.0" | ||||
| edition = "2021" | ||||
| authors = ["Daniel Seiller <earthnuker@gmail.com>"] | ||||
| description = "Scrapland Remastered network sniffer, proxy (and soon hopefully parser)" | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [dependencies] | ||||
| chacha20 = { version = "0.9", features = ["std"] } | ||||
| poly1305 = { version = "0.8", features = ["std"] } | ||||
| rhexdump = "0.1" | ||||
| tokio = { version = "1.21", features = ["full"] } | ||||
| clap = {version = "4.0", features = ["derive"]} | ||||
| rand = "0.8" | ||||
| dialoguer = "0.10" | ||||
| binrw = "0.11" | ||||
| modular-bitfield = "0.11" | ||||
| hex = "0.4" | ||||
| lazy_static = "1.4.0" | ||||
| rustyline-async = "0.3" | ||||
| futures-util = "0.3.24" | ||||
| itertools = "0.10.5" | ||||
| anyhow = "1.0.68" | ||||
| 
 | ||||
| [profile.release] | ||||
| lto="fat" | ||||
| opt-level = 3 | ||||
							
								
								
									
										22
									
								
								tools/remaster/scrap_net/get_app.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								tools/remaster/scrap_net/get_app.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| from distutils.command.install_data import install_data | ||||
| import winreg as reg | ||||
| import vdf | ||||
| from pathlib import Path | ||||
| import pefile | ||||
| app_id="897610" | ||||
| try: | ||||
|     key = reg.OpenKey(reg.HKEY_LOCAL_MACHINE,"SOFTWARE\\Valve\\Steam") | ||||
| except FileNotFoundError: | ||||
|     key = reg.OpenKey(reg.HKEY_LOCAL_MACHINE,"SOFTWARE\\Wow6432Node\\Valve\\Steam") | ||||
| path=Path(reg.QueryValueEx(key,"InstallPath")[0]) | ||||
| libraryfolders=vdf.load((path/"steamapps"/"libraryfolders.vdf").open("r"))['libraryfolders'] | ||||
| for folder in libraryfolders.values(): | ||||
|     path=Path(folder['path']) | ||||
|     if app_id in folder['apps']: | ||||
|         install_dir = vdf.load((path/"steamapps"/f"appmanifest_{app_id}.acf").open("r"))['AppState']['installdir'] | ||||
|         install_dir=path/"steamapps"/"common"/install_dir | ||||
|         for file in install_dir.glob("**/*.exe"): | ||||
|             pe = pefile.PE(file, fast_load=True) | ||||
|             entry = pe.OPTIONAL_HEADER.AddressOfEntryPoint | ||||
|             if pe.get_dword_at_rva(entry) == 0xE8: | ||||
|                 print(file) | ||||
							
								
								
									
										93
									
								
								tools/remaster/scrap_net/src/hex_ii.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								tools/remaster/scrap_net/src/hex_ii.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,93 @@ | |||
| use itertools::Itertools; | ||||
| use std::fmt::Display; | ||||
| use std::ops::{Deref, DerefMut}; | ||||
| 
 | ||||
| #[derive(Debug, PartialEq, Eq)] | ||||
| enum HexII { | ||||
|     Ascii(char), | ||||
|     Byte(u8), | ||||
|     Null, | ||||
|     Full, | ||||
|     Eof, | ||||
| } | ||||
| 
 | ||||
| impl From<&u8> for HexII { | ||||
|     fn from(v: &u8) -> Self { | ||||
|         match v { | ||||
|             0x00 => Self::Null, | ||||
|             0xFF => Self::Full, | ||||
|             c if c.is_ascii_graphic() => Self::Ascii(*c as char), | ||||
|             v => Self::Byte(*v), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Display for HexII { | ||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||
|         match self { | ||||
|             HexII::Ascii(v) => write!(f, ".{}", v)?, | ||||
|             HexII::Byte(v) => write!(f, "{:02x}", v)?, | ||||
|             HexII::Null => write!(f, "  ")?, | ||||
|             HexII::Full => write!(f, "##")?, | ||||
|             HexII::Eof => write!(f, " ]")?, | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| struct HexIILine(Vec<HexII>); | ||||
| 
 | ||||
| impl Display for HexIILine { | ||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||
|         for (i, v) in self.0.iter().enumerate() { | ||||
|             if i != 0 { | ||||
|                 write!(f, " ")?; | ||||
|             } | ||||
|             write!(f, "{}", v)?; | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<&[u8]> for HexIILine { | ||||
|     fn from(l: &[u8]) -> Self { | ||||
|         Self(l.iter().map(HexII::from).collect()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Deref for HexIILine { | ||||
|     type Target = Vec<HexII>; | ||||
|     fn deref(&self) -> &Self::Target { | ||||
|         &self.0 | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl DerefMut for HexIILine { | ||||
|     fn deref_mut(&mut self) -> &mut Self::Target { | ||||
|         &mut self.0 | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub fn hex_ii_dump<T: Iterator<Item = u8>>(data: T, base_offset: usize, total: usize) { | ||||
|     const CHUNK_SIZE: usize = 0x10; | ||||
|     let mut num_digits = (std::mem::size_of_val(&total) * 8) - (total.leading_zeros() as usize); | ||||
|     if (num_digits % 8) != 0 { | ||||
|         num_digits += 8 - (num_digits % 8) | ||||
|     } | ||||
|     num_digits >>= 2; | ||||
|     for (mut offset, line) in data.chunks(CHUNK_SIZE).into_iter().enumerate() { | ||||
|         offset += base_offset; | ||||
|         let mut line = HexIILine::from(line.collect::<Vec<_>>().as_slice()); | ||||
|         if line.len() < CHUNK_SIZE { | ||||
|             line.push(HexII::Eof); | ||||
|         } | ||||
|         while line.len() < CHUNK_SIZE { | ||||
|             line.push(HexII::Null); | ||||
|         } | ||||
|         if line.iter().all(|v| v == &HexII::Null) { | ||||
|             continue; | ||||
|         } | ||||
|         let offset = format!("{:digits$x}", offset * CHUNK_SIZE, digits = num_digits); | ||||
|         println!("{} | {:<16} |", offset, line); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										640
									
								
								tools/remaster/scrap_net/src/main.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										640
									
								
								tools/remaster/scrap_net/src/main.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,640 @@ | |||
| use anyhow::{bail, ensure, Result}; | ||||
| use binrw::BinReaderExt; | ||||
| use binrw::{BinRead, NullString}; | ||||
| use chacha20::cipher::KeyInit; | ||||
| use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; | ||||
| use chacha20::ChaCha20; | ||||
| use clap::Parser; | ||||
| use dialoguer::theme::ColorfulTheme; | ||||
| use dialoguer::Select; | ||||
| use futures_util::FutureExt; | ||||
| use poly1305::Poly1305; | ||||
| use rand::{thread_rng, Rng}; | ||||
| use rhexdump::hexdump; | ||||
| use rustyline_async::{Readline, ReadlineError, SharedWriter}; | ||||
| use std::collections::BTreeMap; | ||||
| use std::error::Error; | ||||
| use std::fmt::Display; | ||||
| use std::io::Cursor; | ||||
| use std::io::Write; | ||||
| use std::iter; | ||||
| use std::net::SocketAddr; | ||||
| use std::net::ToSocketAddrs; | ||||
| use std::net::{IpAddr, Ipv4Addr}; | ||||
| use std::path::PathBuf; | ||||
| use std::time::{Duration, Instant}; | ||||
| use tokio::io::AsyncBufReadExt; | ||||
| use tokio::net::UdpSocket; | ||||
| use tokio::time; | ||||
| 
 | ||||
| mod hex_ii; | ||||
| mod parser; | ||||
| 
 | ||||
| const KEY: &[u8; 32] = b"\x02\x04\x06\x08\x0a\x0c\x0e\x10\x12\x14\x16\x18\x1a\x1c\x1e\x20\x22\x24\x26\x28\x2a\x2c\x2e\x30\x32\x34\x36\x38\x3a\x3c\x3e\x40"; | ||||
| const INFO_PACKET: &[u8] = b"\x7f\x01\x00\x00\x07"; | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| struct ServerFlags { | ||||
|     dedicated: bool, | ||||
|     force_vehicle: bool, | ||||
|     _rest: u8, | ||||
| } | ||||
| 
 | ||||
| impl Display for ServerFlags { | ||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||
|         let force_vehicle = if self.force_vehicle { "F" } else { " " }; | ||||
|         let dedicated = if self.dedicated { "D" } else { " " }; | ||||
|         write!(f, "{}{}", force_vehicle, dedicated)?; | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<u8> for ServerFlags { | ||||
|     fn from(v: u8) -> Self { | ||||
|         ServerFlags { | ||||
|             dedicated: v & 0b1 != 0, | ||||
|             force_vehicle: v & 0b10 != 0, | ||||
|             _rest: (v & 0b11111100) >> 2, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone, BinRead)] | ||||
| #[br(little, magic = b"\xba\xce", import(rtt: Duration, addr: SocketAddr))] | ||||
| pub struct Server { | ||||
|     #[br(calc=addr)] | ||||
|     addr: SocketAddr, | ||||
|     #[br(calc=rtt)] | ||||
|     rtt: Duration, | ||||
|     #[br(map = |v: (u8,u8)| format!("{}.{}",v.0,v.1))] | ||||
|     version: String, | ||||
|     port: u16, | ||||
|     max_players: u16, | ||||
|     cur_players: u16, | ||||
|     #[br(map = u8::into)] | ||||
|     flags: ServerFlags, | ||||
|     #[br(pad_size_to(0x20), map = |s :NullString| s.to_string())] | ||||
|     name: String, | ||||
|     #[br(pad_size_to(0x10), map = |s :NullString| s.to_string())] | ||||
|     mode: String, | ||||
|     #[br(pad_size_to(0x20), map = |s :NullString| s.to_string())] | ||||
|     map: String, | ||||
|     _pad: u8, | ||||
| } | ||||
| 
 | ||||
| fn pad_copy(d: &[u8], l: usize) -> Vec<u8> { | ||||
|     let diff = d.len() % l; | ||||
|     if diff != 0 { | ||||
|         d.iter() | ||||
|             .copied() | ||||
|             .chain(iter::repeat(0).take(l - diff)) | ||||
|             .collect() | ||||
|     } else { | ||||
|         d.to_vec() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn pad(d: &mut Vec<u8>, l: usize) { | ||||
|     let diff = d.len() % l; | ||||
|     if diff != 0 { | ||||
|         d.extend(iter::repeat(0).take(l - diff)) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| struct Packet { | ||||
|     nonce: Vec<u8>, | ||||
|     data: Vec<u8>, | ||||
| } | ||||
| 
 | ||||
| impl Packet { | ||||
|     fn encrypt(data: &[u8]) -> Packet { | ||||
|         let mut data: Vec<u8> = data.to_vec(); | ||||
|         let mut rng = thread_rng(); | ||||
|         let mut nonce = vec![0u8; 12]; | ||||
|         rng.fill(nonce.as_mut_slice()); | ||||
|         let mut cipher = ChaCha20::new(KEY.into(), nonce.as_slice().into()); | ||||
|         cipher.seek(KEY.len() + 32); | ||||
|         cipher.apply_keystream(&mut data); | ||||
|         Packet { nonce, data } | ||||
|     } | ||||
| 
 | ||||
|     fn get_tag(&self) -> Vec<u8> { | ||||
|         let mut sign_data = vec![]; | ||||
|         sign_data.extend(pad_copy(&self.nonce, 16).iter()); | ||||
|         sign_data.extend(pad_copy(&self.data, 16).iter()); | ||||
|         sign_data.extend((self.nonce.len() as u64).to_le_bytes().iter()); | ||||
|         sign_data.extend((self.data.len() as u64).to_le_bytes().iter()); | ||||
|         let mut cipher = ChaCha20::new(KEY.into(), self.nonce.as_slice().into()); | ||||
|         let mut poly_key = *KEY; | ||||
|         cipher.apply_keystream(&mut poly_key); | ||||
|         let signer = Poly1305::new(&poly_key.into()); | ||||
|         signer.compute_unpadded(&sign_data).into_iter().collect() | ||||
|     } | ||||
| 
 | ||||
|     fn bytes(&self) -> Vec<u8> { | ||||
|         let mut data = vec![]; | ||||
|         data.extend(pad_copy(&self.nonce, 16).iter()); | ||||
|         data.extend(pad_copy(&self.data, 16).iter()); | ||||
|         data.extend((self.nonce.len() as u64).to_le_bytes().iter()); | ||||
|         data.extend((self.data.len() as u64).to_le_bytes().iter()); | ||||
|         data.extend(self.get_tag().iter()); | ||||
|         data | ||||
|     } | ||||
| 
 | ||||
|     fn decrypt(&self) -> Result<Vec<u8>> { | ||||
|         let mut data = self.data.clone(); | ||||
|         let mut sign_data = data.clone(); | ||||
|         pad(&mut sign_data, 16); | ||||
|         let mut nonce = self.nonce.clone(); | ||||
|         pad(&mut nonce, 16); | ||||
|         let sign_data = nonce | ||||
|             .iter() | ||||
|             .chain(sign_data.iter()) | ||||
|             .chain((self.nonce.len() as u64).to_le_bytes().iter()) | ||||
|             .chain((self.data.len() as u64).to_le_bytes().iter()) | ||||
|             .copied() | ||||
|             .collect::<Vec<u8>>(); | ||||
|         let mut poly_key = *KEY; | ||||
|         let mut cipher = ChaCha20::new(KEY.into(), self.nonce.as_slice().into()); | ||||
|         cipher.apply_keystream(&mut poly_key); | ||||
|         let signer = Poly1305::new(&poly_key.into()); | ||||
|         let signature: Vec<u8> = signer.compute_unpadded(&sign_data).into_iter().collect(); | ||||
| 
 | ||||
|         if signature != self.get_tag() { | ||||
|             bail!("Invalid signature!"); | ||||
|         }; | ||||
|         cipher.seek(poly_key.len() + 32); | ||||
|         cipher.apply_keystream(&mut data); | ||||
|         Ok(data) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl TryFrom<&[u8]> for Packet { | ||||
|     type Error = anyhow::Error; | ||||
|     fn try_from(data: &[u8]) -> Result<Self> { | ||||
|         let (mut nonce, data) = data.split_at(16); | ||||
|         let (mut data, tag) = data.split_at(data.len() - 16); | ||||
|         let nonce_len = u64::from_le_bytes(data[data.len() - 16..][..8].try_into()?) as usize; | ||||
|         let data_len = u64::from_le_bytes(data[data.len() - 8..].try_into()?) as usize; | ||||
|         data = &data[..data_len]; | ||||
|         nonce = &nonce[..nonce_len]; | ||||
|         let pkt = Packet { | ||||
|             nonce: nonce.into(), | ||||
|             data: data.into(), | ||||
|         }; | ||||
|         if pkt.get_tag() != tag { | ||||
|             bail!("Invalid signature!"); | ||||
|         } | ||||
|         Ok(pkt) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Clone)] | ||||
| pub enum ServerEntry { | ||||
|     Alive(Server), | ||||
|     Dead { addr: SocketAddr, reason: String }, | ||||
| } | ||||
| 
 | ||||
| impl Display for ServerEntry { | ||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||
|         match self { | ||||
|             ServerEntry::Alive(srv) => write!( | ||||
|                 f, | ||||
|                 "[{}] {} ({} {}/{} Players on {}) version {} [{}] RTT: {:?}", | ||||
|                 srv.addr, | ||||
|                 srv.name, | ||||
|                 srv.mode, | ||||
|                 srv.cur_players, | ||||
|                 srv.max_players, | ||||
|                 srv.map, | ||||
|                 srv.version, | ||||
|                 srv.flags, | ||||
|                 srv.rtt | ||||
|             ), | ||||
|             ServerEntry::Dead { addr, reason } => write!(f, "[{}] (error: {})", addr, reason), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn encrypt(data: &[u8]) -> Vec<u8> { | ||||
|     Packet::encrypt(data).bytes() | ||||
| } | ||||
| 
 | ||||
| fn decrypt(data: &[u8]) -> Result<Vec<u8>> { | ||||
|     Packet::try_from(data)?.decrypt() | ||||
| } | ||||
| 
 | ||||
| async fn recv_from_timeout( | ||||
|     sock: &UdpSocket, | ||||
|     buf: &mut [u8], | ||||
|     timeout: f64, | ||||
| ) -> Result<(usize, SocketAddr)> { | ||||
|     Ok(time::timeout(Duration::from_secs_f64(timeout), sock.recv_from(buf)).await??) | ||||
| } | ||||
| 
 | ||||
| async fn query_server<'a>(addr: SocketAddr) -> Result<Server> { | ||||
|     let mut buf = [0; 32 * 1024]; | ||||
|     let socket = UdpSocket::bind("0.0.0.0:0").await?; | ||||
|     socket.connect(addr).await?; | ||||
|     let msg = encrypt(INFO_PACKET); | ||||
|     let t_start = Instant::now(); | ||||
|     socket.send(&msg).await?; | ||||
|     let size = recv_from_timeout(&socket, &mut buf, 5.0).await?.0; | ||||
|     let rtt = t_start.elapsed(); | ||||
|     let data = decrypt(&buf[..size])?; | ||||
|     if !data.starts_with(&[0xba, 0xce]) { | ||||
|         // Server Info
 | ||||
|         bail!("Invalid response"); | ||||
|     } | ||||
|     let mut cur = Cursor::new(&data); | ||||
|     let info: Server = cur.read_le_args((rtt, addr))?; | ||||
|     if info.port != addr.port() { | ||||
|         eprint!("[WARN] Port differs for {}: {}", addr, info.port); | ||||
|     } | ||||
|     if cur.position() != (data.len() as u64) { | ||||
|         bail!("Leftover data"); | ||||
|     } | ||||
|     Ok(info) | ||||
| } | ||||
| 
 | ||||
| async fn get_servers(master_addr: &str) -> Result<(Duration, Vec<ServerEntry>)> { | ||||
|     let master_addr: SocketAddr = master_addr.to_socket_addrs()?.next().unwrap(); | ||||
|     let mut rtt = std::time::Duration::from_secs_f32(0.0); | ||||
|     let mut servers = vec![]; | ||||
|     let mut buf = [0; 32 * 1024]; | ||||
|     let master = UdpSocket::bind("0.0.0.0:0").await?; | ||||
|     master.connect(master_addr).await?; | ||||
|     for n in 0..(256 / 32) { | ||||
|         let data = format!("Brw={},{}\0", n * 32, (n + 1) * 32); | ||||
|         let data = &encrypt(data.as_bytes()); | ||||
|         let t_start = Instant::now(); | ||||
|         master.send(data).await?; | ||||
|         let size = master.recv(&mut buf).await?; | ||||
|         rtt += t_start.elapsed(); | ||||
|         let data = decrypt(&buf[..size])?; | ||||
|         if data.starts_with(b"\0\0\0\0}") { | ||||
|             for chunk in data[5..].chunks(6) { | ||||
|                 if chunk.iter().all(|v| *v == 0) { | ||||
|                     break; | ||||
|                 } | ||||
|                 let port = u16::from_le_bytes(chunk[chunk.len() - 2..].try_into()?); | ||||
|                 let addr = SocketAddr::from(([chunk[0], chunk[1], chunk[2], chunk[3]], port)); | ||||
|                 let server = match query_server(addr).await { | ||||
|                     Ok(server) => ServerEntry::Alive(server), | ||||
|                     Err(err) => ServerEntry::Dead { | ||||
|                         addr, | ||||
|                         reason: err.to_string(), | ||||
|                     }, | ||||
|                 }; | ||||
|                 servers.push(server); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     rtt = Duration::from_secs_f64(rtt.as_secs_f64() / ((256 / 32) as f64)); | ||||
|     Ok((rtt, servers)) | ||||
| } | ||||
| 
 | ||||
| fn indent_hexdump(data: &[u8], indentation: usize, label: &str) -> String { | ||||
|     let mut out = String::new(); | ||||
|     let indent = " ".repeat(indentation); | ||||
|     out.push_str(&indent); | ||||
|     out.push_str(label); | ||||
|     out.push('\n'); | ||||
|     for line in rhexdump::hexdump(data).lines() { | ||||
|         out.push_str(&indent); | ||||
|         out.push_str(line); | ||||
|         out.push('\n'); | ||||
|     } | ||||
|     out.trim_end().to_owned() | ||||
| } | ||||
| 
 | ||||
| #[derive(Default, Debug)] | ||||
| struct State { | ||||
|     client: BTreeMap<usize, BTreeMap<u8, usize>>, | ||||
|     server: BTreeMap<usize, BTreeMap<u8, usize>>, | ||||
| } | ||||
| 
 | ||||
| impl State { | ||||
|     fn update_client(&mut self, data: &[u8]) { | ||||
|         data.iter().enumerate().for_each(|(pos, b)| { | ||||
|             *self.client.entry(pos).or_default().entry(*b).or_default() += 1; | ||||
|         }); | ||||
|     } | ||||
|     fn update_server(&mut self, data: &[u8]) { | ||||
|         data.iter().enumerate().for_each(|(pos, b)| { | ||||
|             *self.server.entry(pos).or_default().entry(*b).or_default() += 1; | ||||
|         }); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, PartialEq, Eq, Copy, Clone)] | ||||
| enum Direction { | ||||
|     Client, | ||||
|     Server, | ||||
|     Both, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| enum CmdResult { | ||||
|     Exit, | ||||
|     Packet { | ||||
|         data: Vec<u8>, | ||||
|         direction: Direction, | ||||
|     }, | ||||
|     Fuzz { | ||||
|         direction: Direction, | ||||
|         start: usize, | ||||
|         end: usize, | ||||
|         chance: (u32, u32), | ||||
|     }, | ||||
|     NoFuzz, | ||||
|     Log(bool), | ||||
| } | ||||
| 
 | ||||
| async fn handle_line( | ||||
|     line: &str, | ||||
|     state: &State, | ||||
|     stdout: &mut SharedWriter, | ||||
| ) -> Result<Option<CmdResult>> { | ||||
|     use CmdResult::*; | ||||
|     let cmd: Vec<&str> = line.trim().split_ascii_whitespace().collect(); | ||||
|     match cmd[..] { | ||||
|         ["log", "off"] => Ok(Some(Log(false))), | ||||
|         ["log", "on"] => Ok(Some(Log(true))), | ||||
|         ["state", pos] => { | ||||
|             let pos = pos.parse()?; | ||||
|             writeln!(stdout, "Client: {:?}", state.client.get(&pos))?; | ||||
|             writeln!(stdout, "Server: {:?}", state.server.get(&pos))?; | ||||
|             Ok(None) | ||||
|         } | ||||
|         [dir @ ("client" | "server"), ref args @ ..] => { | ||||
|             let mut data: Vec<u8> = vec![]; | ||||
|             for args in args.iter() { | ||||
|                 let args = hex::decode(args)?; | ||||
|                 data.extend(args); | ||||
|             } | ||||
|             Ok(Some(CmdResult::Packet { | ||||
|                 data, | ||||
|                 direction: match dir { | ||||
|                     "client" => Direction::Client, | ||||
|                     "server" => Direction::Server, | ||||
|                     _ => unreachable!(), | ||||
|                 }, | ||||
|             })) | ||||
|         } | ||||
|         ["fuzz", dir @ ("client" | "server" | "both"), start, end, chance_num, chance_den] => { | ||||
|             let direction = match dir { | ||||
|                 "client" => Direction::Client, | ||||
|                 "server" => Direction::Server, | ||||
|                 "both" => Direction::Both, | ||||
|                 _ => unreachable!(), | ||||
|             }; | ||||
|             let start = start.parse()?; | ||||
|             let end = end.parse()?; | ||||
|             if start > end { | ||||
|                 bail!("Fuzz start>end"); | ||||
|             } | ||||
|             let res = CmdResult::Fuzz { | ||||
|                 direction, | ||||
|                 start, | ||||
|                 end, | ||||
|                 chance: (chance_num.parse()?, chance_den.parse()?), | ||||
|             }; | ||||
|             Ok(Some(res)) | ||||
|         } | ||||
|         ["fuzz", "off"] => Ok(Some(CmdResult::NoFuzz)), | ||||
|         ["exit"] => Ok(Some(CmdResult::Exit)), | ||||
|         [""] => Ok(None), | ||||
|         _ => bail!("Unknown command: {:?}", line), | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn run_proxy( | ||||
|     remote_addr: &SocketAddr, | ||||
|     local_addr: &SocketAddr, | ||||
|     logfile: &Option<PathBuf>, | ||||
| ) -> Result<()> { | ||||
|     let mut print_log = false; | ||||
|     let mut state = State::default(); | ||||
|     let mut logfile = match logfile { | ||||
|         Some(path) => Some(std::fs::File::create(path)?), | ||||
|         None => None, | ||||
|     }; | ||||
|     let mut fuzz = None; | ||||
|     let mut rng = thread_rng(); | ||||
|     let mut client_addr: Option<SocketAddr> = None; | ||||
|     let local = UdpSocket::bind(local_addr).await?; | ||||
|     let remote = UdpSocket::bind("0.0.0.0:0").await?; | ||||
|     remote.connect(remote_addr).await?; | ||||
|     let mut local_buf = vec![0; 32 * 1024]; | ||||
|     let mut remote_buf = vec![0; 32 * 1024]; | ||||
|     println!("Proxy listening on {}", local_addr); | ||||
|     let (mut rl, mut stdout) = Readline::new(format!("{}> ", remote_addr)).unwrap(); | ||||
|     loop { | ||||
|         tokio::select! { | ||||
|             line = rl.readline().fuse() => { | ||||
|                 match line { | ||||
|                     Ok(line) => { | ||||
|                         let line=line.trim(); | ||||
|                         rl.add_history_entry(line.to_owned()); | ||||
|                         match  handle_line(line, &state, &mut stdout).await { | ||||
|                             Ok(Some(result)) => { | ||||
|                                 match result { | ||||
|                                         CmdResult::Packet{data,direction} => { | ||||
|                                             let data=encrypt(&data); | ||||
|                                             match direction { | ||||
|                                                 Direction::Client => { | ||||
|                                                     if client_addr.is_some() { | ||||
|                                                         local | ||||
|                                                             .send_to(&data, client_addr.unwrap()) | ||||
|                                                             .await?; | ||||
|                                                     } else { | ||||
|                                                         writeln!(stdout,"Error: No client address")?; | ||||
|                                                     } | ||||
|                                                 }, | ||||
|                                                 Direction::Server => { | ||||
|                                                     remote.send(&data).await?; | ||||
|                                                 } | ||||
|                                                 Direction::Both => unreachable!() | ||||
|                                             }; | ||||
|                                         } | ||||
|                                         CmdResult::Log(log) => { | ||||
|                                             print_log=log; | ||||
|                                         } | ||||
|                                         CmdResult::Exit => break Ok(()), | ||||
|                                         CmdResult::NoFuzz => { | ||||
|                                             fuzz=None; | ||||
|                                         } | ||||
|                                         CmdResult::Fuzz { .. } => { | ||||
|                                             fuzz=Some(result) | ||||
|                                         }, | ||||
|                                     } | ||||
|                             }, | ||||
|                             Ok(None) => (), | ||||
|                             Err(msg) => { | ||||
|                                 writeln!(stdout, "Error: {}", msg)?; | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     Err(ReadlineError::Eof) =>{ writeln!(stdout, "Exiting...")?; break Ok(()) }, | ||||
|                     Err(ReadlineError::Interrupted) => { | ||||
|                         writeln!(stdout, "^C")?; | ||||
|                         break Ok(()); | ||||
|                     }, | ||||
|                     Err(err) => { | ||||
|                         writeln!(stdout, "Received err: {:?}", err)?; | ||||
|                         writeln!(stdout, "Exiting...")?; | ||||
|                         break Ok(()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             local_res = local.recv_from(&mut local_buf) => { | ||||
|                 let (size, addr) = local_res?; | ||||
|                 client_addr.get_or_insert(addr); | ||||
|                 let mut data = Packet::try_from(&local_buf[..size])?.decrypt()?; | ||||
|                 state.update_client(&data); | ||||
|                 if print_log { | ||||
|                     writeln!(stdout,"{}", indent_hexdump(&data, 0, &format!("OUT: {}", addr)))?; | ||||
|                 } | ||||
|                 if let Some(lf) = logfile.as_mut() { | ||||
|                     writeln!(lf, ">{:?} {} {}", addr, data.len(), hex::encode(&data))?; | ||||
|                 }; | ||||
|                 if let Some(CmdResult::Fuzz{direction,start,end,chance}) = fuzz { | ||||
|                     if (direction==Direction::Server || direction==Direction::Both) && rng.gen_ratio(chance.0,chance.1) { | ||||
|                         rng.fill(&mut data[start..end]); | ||||
|                     } | ||||
|                 } | ||||
|                 remote.send(&encrypt(&data)).await?; | ||||
|             } | ||||
|             remote_res = remote.recv_from(&mut remote_buf) => { | ||||
|                 let (size, addr) = remote_res?; | ||||
|                 let mut data = Packet::try_from(&remote_buf[..size])?.decrypt()?; | ||||
|                 state.update_server(&data); | ||||
|                 if print_log { | ||||
|                     writeln!(stdout,"\r{}", indent_hexdump(&data, 5, &format!("IN: {}", addr)))?; | ||||
|                 } | ||||
|                 if let Some(lf) = logfile.as_mut() { | ||||
|                     writeln!(lf, "<{:?} {} {}", addr, data.len(), hex::encode(&data))?; | ||||
|                 }; | ||||
|                 if client_addr.is_some() { | ||||
|                     if let Some(CmdResult::Fuzz{direction,start,end,chance}) = &fuzz { | ||||
|                         if (*direction==Direction::Client || *direction==Direction::Both) && rng.gen_ratio(chance.0,chance.1) { | ||||
|                             rng.fill(&mut data[*start..*end]); | ||||
|                         } | ||||
|                     } | ||||
|                     local | ||||
|                         .send_to(&encrypt(&data), client_addr.unwrap()) | ||||
|                         .await?; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn send_master_cmd(sock: &UdpSocket, cmd: &str) -> Result<Vec<u8>> { | ||||
|     let mut buf = [0; 32 * 1024]; | ||||
|     let mut data: Vec<u8> = cmd.as_bytes().to_vec(); | ||||
|     data.push(0); | ||||
|     let data = &encrypt(&data); | ||||
|     sock.send(data).await?; | ||||
|     let size = recv_from_timeout(sock, &mut buf, 5.0).await?.0; | ||||
|     decrypt(&buf[..size]) | ||||
| } | ||||
| 
 | ||||
| async fn run_master_shell(master_addr: &str) -> Result<()> { | ||||
|     let master = UdpSocket::bind("0.0.0.0:0").await?; | ||||
|     master.connect(master_addr).await?; | ||||
|     let (mut rl, mut stdout) = Readline::new(format!("{}> ", master_addr)).unwrap(); | ||||
|     loop { | ||||
|         tokio::select! { | ||||
|             line = rl.readline().fuse() => { | ||||
|                 match line { | ||||
|                     Ok(line) => { | ||||
|                         let line=line.trim(); | ||||
|                         rl.add_history_entry(line.to_owned()); | ||||
|                         writeln!(stdout,"[CMD] {line}")?; | ||||
|                         match send_master_cmd(&master,line).await { | ||||
|                             Ok(data) => writeln!(stdout,"{}",hexdump(&data))?, | ||||
|                             Err(e) => writeln!(stdout,"Error: {e}")? | ||||
|                         } | ||||
|                     } | ||||
|                     Err(ReadlineError::Eof) =>{ writeln!(stdout, "Exiting...")?; break Ok(()) }, | ||||
|                     Err(ReadlineError::Interrupted) => { | ||||
|                         writeln!(stdout, "^C")?; | ||||
|                         break Ok(()); | ||||
|                     }, | ||||
|                     Err(err) => { | ||||
|                         writeln!(stdout, "Receive error: {err}")?; | ||||
|                         break Err(err.into()); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Parser, Debug)] | ||||
| #[clap(author, version, about, long_about = None)] | ||||
| struct Args { | ||||
|     /// Server to connect to (if unspecified will query the master server)
 | ||||
|     server: Option<SocketAddr>, | ||||
|     /// Only list servers without starting proxy
 | ||||
|     #[clap(short, long, action)] | ||||
|     list: bool, | ||||
|     /// Local Address to bind to
 | ||||
|     #[clap(short,long, default_value_t = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 28086))] | ||||
|     addr: SocketAddr, | ||||
|     /// Master server to query for running games
 | ||||
|     #[clap(short, long, default_value = "scrapland.mercurysteam.com:5000")] | ||||
|     master: String, | ||||
|     /// Path of file to log decrypted packets to
 | ||||
|     #[clap(short = 'f', long)] | ||||
|     logfile: Option<PathBuf>, | ||||
| } | ||||
| 
 | ||||
| #[tokio::main] | ||||
| async fn main() -> Result<()> { | ||||
|     let args = Args::parse(); | ||||
|     if args.list && args.server.is_some() { | ||||
|         let addr = args.server.unwrap(); | ||||
|         let server = match query_server(addr).await { | ||||
|             Ok(server) => ServerEntry::Alive(server), | ||||
|             Err(msg) => ServerEntry::Dead { | ||||
|                 addr, | ||||
|                 reason: msg.to_string(), | ||||
|             }, | ||||
|         }; | ||||
|         println!("{}", server); | ||||
|         return Ok(()); | ||||
|     } | ||||
|     if let Some(server) = args.server { | ||||
|         run_proxy(&server, &args.addr, &args.logfile).await?; | ||||
|         return Ok(()); | ||||
|     } | ||||
|     loop { | ||||
|         let (rtt, servers) = get_servers(&args.master).await?; | ||||
|         println!("Master RTT: {:?}", rtt); | ||||
|         if args.list { | ||||
|             for server in servers { | ||||
|                 println!("{}", server); | ||||
|             } | ||||
|             return Ok(()); | ||||
|         } | ||||
|         let selection = Select::with_theme(&ColorfulTheme::default()) | ||||
|             .items(&servers) | ||||
|             .with_prompt("Select server (press Esc to drop into master server command shell)") | ||||
|             .interact_opt()? | ||||
|             .map(|v| &servers[v]); | ||||
|         match selection { | ||||
|             Some(ServerEntry::Dead { addr, reason }) => { | ||||
|                 eprintln!("{:?} returned an error: {}", addr, reason) | ||||
|             } | ||||
|             Some(ServerEntry::Alive(srv)) => { | ||||
|                 return run_proxy(&srv.addr, &args.addr, &args.logfile).await; | ||||
|             } | ||||
|             None => { | ||||
|                 return run_master_shell(&args.master).await; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										151
									
								
								tools/remaster/scrap_net/src/parser.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								tools/remaster/scrap_net/src/parser.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,151 @@ | |||
| use std::collections::HashMap; | ||||
| use std::error::Error; | ||||
| 
 | ||||
| use crate::hex_ii::hex_ii_dump; | ||||
| use crate::ServerFlags; | ||||
| use binrw::BinReaderExt; | ||||
| use binrw::{binread, BinRead, NullString}; | ||||
| 
 | ||||
| /* | ||||
| 00000000: 7f 4c 00 00 06 ba ce 01 01 06 63 61 63 6f 74 61 | .L........cacota | ||||
| 00000010: 10 5b 42 4a 5a 5d 20 45 61 72 74 68 6e 75 6b 65 | .[BJZ].Earthnuke | ||||
| 00000020: 72 06 53 50 6f 6c 69 31 37 00 08 50 5f 50 6f 6c | r.SPoli17..P_Pol | ||||
| 00000030: 69 63 65 06 4d 50 4f 4c 49 31 00 00 00 0d 30 2c | ice.MPOLI1....0, | ||||
| 00000040: 30 2c 30 2c 31 2c 30 2c 30 2c 31 00 00 00 00    | 0,0,1,0,0,1.... | ||||
| 
 | ||||
| 00000000: 7f 49 00 00 06 ba ce 01 01 06 63 61 63 6f 74 61 | .I........cacota | ||||
| 00000010: 0e 55 6e 6e 61 6d 65 64 20 50 6c 61 79 65 72 07 | .Unnamed.Player. | ||||
| 00000020: 53 42 65 74 74 79 31 50 00 07 50 5f 42 65 74 74 | SBetty1P..P_Bett | ||||
| 00000030: 79 07 4d 42 65 74 74 79 31 00 00 00 0b 31 2c 31 | y.MBetty1....1,1 | ||||
| 00000040: 2c 30 2c 31 2c 33 2c 30 00 00 00 00             | ,0,1,3,0.... | ||||
| */ | ||||
| 
 | ||||
| #[derive(Debug, Clone, BinRead)] | ||||
| #[br(big)] | ||||
| #[br(magic = b"\xba\xce")] | ||||
| struct ServerInfoJoin { | ||||
|     #[br(map = |v: (u8,u8)| format!("{}.{}",v.0,v.1))] | ||||
|     version: String, | ||||
| } | ||||
| 
 | ||||
| struct Data { | ||||
|     player_id: u32, | ||||
|     num_vals: u32, | ||||
|     pos: [f32; 3], | ||||
|     player_index: u32, | ||||
|     rtt: u32, | ||||
| } | ||||
| 
 | ||||
| #[binread] | ||||
| #[br(big)] | ||||
| #[derive(Debug, Clone)] | ||||
| enum PacketData { | ||||
|     #[br(magic = b"\x7f")] | ||||
|     PlayerJoin { | ||||
|         data_len: u8, | ||||
|         _1: u8, | ||||
|         cur_players: u8, | ||||
|         max_players: u8, | ||||
|         info: ServerInfoJoin, | ||||
|         #[br(temp)] | ||||
|         pw_len: u8, | ||||
|         #[br(count = pw_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         password: String, | ||||
|         #[br(temp)] | ||||
|         player_name_len: u8, | ||||
|         #[br(count = player_name_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         player_name: String, | ||||
|         #[br(temp)] | ||||
|         ship_model_len: u8, | ||||
|         #[br(count = ship_model_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         ship_model: String, | ||||
|         #[br(little)] | ||||
|         max_health: u16, | ||||
|         #[br(temp)] | ||||
|         pilot_model_len: u8, | ||||
|         #[br(count = pilot_model_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         pilot_model: String, | ||||
|         #[br(temp)] | ||||
|         engine_model_r_len: u8, | ||||
|         #[br(count = engine_model_r_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         engine_model_r: String, | ||||
|         #[br(temp)] | ||||
|         engine_model_l_len: u8, | ||||
|         #[br(count = engine_model_r_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         engine_model_l: String, | ||||
|         _2: u16, | ||||
|         #[br(temp)] | ||||
|         loadout_len: u8, | ||||
|         #[br(count = loadout_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         loadout: String, | ||||
|         team_number: u16, | ||||
|         padding: [u8; 2], | ||||
|     }, | ||||
|     #[br(magic = b"\x80\x15")] | ||||
|     MapInfo { | ||||
|         #[br(temp)] | ||||
|         map_len: u32, | ||||
|         #[br(count = map_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         map: String, | ||||
|         #[br(temp)] | ||||
|         mode_len: u8, | ||||
|         #[br(count = mode_len, map = |bytes: Vec<u8>| String::from_utf8_lossy(&bytes).into_owned())] | ||||
|         mode: String, | ||||
|         _2: u16, | ||||
|         item_count: u8, | ||||
|         // _3: u32,
 | ||||
|         // #[br(count = item_count)]
 | ||||
|         // items: Vec<[u8;0x11]>
 | ||||
|     }, | ||||
|     #[br(magic = b"\xba\xce")] | ||||
|     ServerInfo { | ||||
|         #[br(map = |v: (u8,u8)| format!("{}.{}",v.1,v.0))] | ||||
|         version: String, | ||||
|         port: u16, | ||||
|         max_players: u16, | ||||
|         cur_players: u16, | ||||
|         #[br(map = u8::into)] | ||||
|         flags: ServerFlags, | ||||
|         #[br(pad_size_to(0x20), map=|s: NullString| s.to_string())] | ||||
|         name: String, | ||||
|         #[br(pad_size_to(0x10), map=|s: NullString| s.to_string())] | ||||
|         mode: String, | ||||
|         #[br(pad_size_to(0x20), map=|s: NullString| s.to_string())] | ||||
|         map: String, | ||||
|         _pad: u8, | ||||
|     }, | ||||
| } | ||||
| 
 | ||||
| fn parse(data: &[u8]) -> Result<(PacketData, Vec<u8>), Box<dyn Error>> { | ||||
|     use std::io::Cursor; | ||||
|     let mut rdr = Cursor::new(data); | ||||
|     let pkt: PacketData = rdr.read_le()?; | ||||
|     let rest = data[rdr.position() as usize..].to_vec(); | ||||
|     println!("{}", rhexdump::hexdump(data)); | ||||
|     Ok((pkt, rest)) | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn test_parser() { | ||||
|     let log = include_str!("../test_.log").lines(); | ||||
|     let mut hm = HashMap::new(); | ||||
|     for line in log { | ||||
|         let data = line.split_ascii_whitespace().nth(1).unwrap(); | ||||
|         let data = hex::decode(data).unwrap(); | ||||
|         *hm.entry(data[0..1].to_vec()).or_insert(0usize) += 1; | ||||
|         match parse(&data) { | ||||
|             Ok((pkt, rest)) => { | ||||
|                 println!("{:#x?}", pkt); | ||||
|             } | ||||
|             Err(e) => (), | ||||
|         } | ||||
|     } | ||||
|     let mut hm: Vec<(_, _)> = hm.iter().collect(); | ||||
|     hm.sort_by_key(|(_, v)| *v); | ||||
|     for (k, v) in hm { | ||||
|         let k = k.iter().map(|v| format!("{:02x}", v)).collect::<String>(); | ||||
|         println!("{} {}", k, v); | ||||
|     } | ||||
|     // println!("{:#x?}",parse("8015000000094c6576656c732f465a08466c616748756e7400000100000000000000000000000000000000000004105feb0006003e1125f3bc1300000019007e9dfa0404d5f9003f00000000000000000000"));
 | ||||
|     // println!("{:#x?}",parse("8015000000094c6576656c732f465a08466c616748756e7400002000000000000000000000000000000000000004105feb0006003e1125f3bc1300000019007e9dfa0404d5f9003f000000000000000000001f020b0376a8e2475b6e5b467c1e99461e020903982d14c5ec79cb45b2ee96471d020e03b29dbc46caa433464a28a0c71c020603aa80514658b8ab458db025c71b020803ce492f4658b8ab4514d320c71a02070344532f4658b8ab4587cf16c7190205031b3a0d4658b8ab459eaf25c7180206030ac34c4669e1fd469891ca47170208032e8c2a4669e1fd465500cd4716020703a4952a4669e1fd461b02d247150205037b7c084669e1fd460f92ca4714020603da6b7ec714aa3746b77c5a4713020803c87c83c714aa3746305a5f47120207039a7b83c714aa3746bd5d694711020503bfbe87c714aa3746a67d5a4710020803c5c719474ad5d445a7b3d2c60f0206037c5522474ad5d4459a6edcc60e02070323ca19474ad5d4458dacbec60d020503d84311474ad5d445bb6cdcc60c020603a9b16b47d52d974602dd15470b020803f2236347d52d97467bba1a470a02070350266347d52d974608be24470902050305a05a47d52d9746f1dd1547080206031f4066c6384b9c46955bd345070208037e3b84c6384b9c466147fa4506020703c33684c6384b9c46e431254605020503574395c6384b9c461063d34504020603ba349bc77a60294640f387c103020803957b9fc77a602946658f994402020703677a9fc77a60294680006d45010205038cbda3c77a602946807880c1"));
 | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue