//! 两条形状判据(`dotted` / `opaque`)的判定与阈值;[`super`] 的扫描器只负责搬运字符。 //! //! 判据集中在这里一份,是因为它们是**同一件事的两面**:`dotted` 先跑、`opaque` 后跑, //! 两者的字符集相互补充(`.` 只属于前者,`/` 属于两者),任何一边改了字符集,另一边的 //! 覆盖面就跟着变。 //! //! # 三条"别抹"的判据(M239 的 D8 误杀) //! //! 每条都只砍掉**真凭据不可能有的形状**,且都写成"必要条件":命中的串仍然按老规则抹, //! 只是多了几个"这一串根本不可能是一段凭据"的出口: //! //! | 误杀形状 | 出口判据 | 为什么真凭据不会从这里溜走 | //! |---|---|---| //! | `-D=<品牌>`(`minecraft.launcher.brand=UnluckyLauncher`) | `=` 是赋值边界,不属于 token 字符集 | JWT 的 `.` 分段里 `=` 只可能是末尾填充 | //! | CDN/镜像主机名(`resources.download.minecraft.net/ab/cdef`) | 首段是 DNS 主机名 / 整串没有大写 | JWT 头是 JSON,`base64url("{\"")` = `eyJ`,第三段必为大写 | //! | 目录段/类名(`/srv/…/mods/x.jar`、`org/spongepowered/…`) | 段名是"词形"([`is_path_run`]) | 随机 base64 的一段里不可能每个字母块都恰好是全小写/全大写/CamelCase | /// 判定“像 token”的最短连续长度。 pub(super) const MAX_OPAQUE_RUN: usize = 64; /// 「带点 token」(JWT 三段式)判定的最短总长。 pub(super) const DOTTED_TOKEN_MIN_TOTAL: usize = 40; /// 「带点 token」每一段的最短长度(挡住 `Service.Unavailable.today` 这类正常文案)。 pub(super) const DOTTED_TOKEN_MIN_SEGMENT: usize = 8; /// 不透明串的字符集(base64 / hex / 路径分隔符)。`.` 不在内,`: \ @` 也不在内。 pub(super) fn is_opaque_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '_' | '-') } /// 「带点 token」的字符集:不透明串的字符加 `.`、减 `=`。 /// /// `=` 是**赋值边界**而不是 token 本体。把它留在字符集里,`-D=<值>` 就会被粘成一个 /// run:键里的点号(`minecraft.launcher.brand`)会把值一起拖进段数统计,M239 实测的品牌串 /// 误杀就是从这儿来的。JWT 自己的 `=` 只可能是末尾填充,拆出去不损伤覆盖面。 pub(super) fn is_dotted_token_char(ch: char) -> bool { ch != '=' && (is_opaque_char(ch) || ch == '.') } /// 单段连写(无空白)的串是否像 JWT。 pub(super) fn is_dotted_token(run: &str) -> bool { jwt_shaped(run) && !is_url_authority(run) } /// 「带点 token」的形状:段数 ≥3、每段 ≥8 字节、总长 ≥40(老规则,一个字都没放宽), /// 外加两条必要条件——含大写、首段不是主机名。 fn jwt_shaped(run: &str) -> bool { run.len() >= DOTTED_TOKEN_MIN_TOTAL && run.split('.').count() >= 3 && run .split('.') .all(|segment| segment.len() >= DOTTED_TOKEN_MIN_SEGMENT) && run.chars().any(|ch| ch.is_ascii_uppercase()) } /// run 的第一段(到第一个 `/` 为止)是不是主机名:是的话这就是一条 URL,不是 token。 /// /// 扫描器(`super::push_dotted`)先按这条把主机名切走、只判路径那半;这里的判据留一份, /// 是为了让「一段串是不是 token」单独成立(`has_dotted_token` 与单测直接用它)。 pub(super) fn is_url_authority(run: &str) -> bool { is_dns_host(run.split('/').next().unwrap_or(run)) } /// 整串是不是一个 DNS 主机名(至少两段标签,标签只含小写字母/数字/连字符)。 /// /// JWT 是 base64url,**不含 `/`**;主机名的标签也不会大写(URL 里从没这么写过)。 pub(super) fn is_dns_host(name: &str) -> bool { let labels: Vec<&str> = name.split('.').collect(); labels.len() >= 2 && labels.iter().all(|label| is_dns_label(label)) } /// 单个 DNS 标签:小写字母/数字/连字符,首尾不是连字符(日志里的主机名不会写大写)。 fn is_dns_label(label: &str) -> bool { !label.is_empty() && label.len() <= 63 && label .chars() .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') && !label.starts_with('-') && !label.ends_with('-') } /// 长不透明串是不是一段路径/命名空间(目录、文件名、Java 包名)。 /// /// 两条:① 至少被 `/` 切开一次;② 每一段都是"名字"([`is_name_chunk`])。 /// 判据里没有任何熵估计,"词形 vs 随机"就靠第 ② 条:随机 base64 的一段里每个连续字母块 /// 都恰好是全小写/全大写/CamelCase 的概率可以忽略——所以一条 base64 密钥不会被当成路径。 /// 方向也是安全的:判定失败的串照旧被抹,只是路径不再整条消失。 pub(super) fn is_path_run(run: &str) -> bool { run.split('/').count() >= 2 && run.split('/').all(is_name_chunk) } /// 路径/包名的一段:只许字母数字与 `- _ .`,且每个连续字母块都是"词"([`is_word`])。 fn is_name_chunk(chunk: &str) -> bool { chunk .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) && chunk .split(|ch: char| !ch.is_ascii_alphabetic()) .all(is_word) } /// 连续字母块是否像词:全小写 / 全大写(缩写)/ CamelCase(`OpenGL`、`AllTheMods`、`FabricMixin…`)。 fn is_word(word: &str) -> bool { let mut has_lower = false; let mut has_upper = false; for ch in word.chars() { if ch.is_ascii_lowercase() { has_lower = true; } else if ch.is_ascii_uppercase() { has_upper = true; } else { return false; } } if !has_lower || !has_upper { return true; } let chars: Vec = word.chars().collect(); let mut idx = 0usize; while idx < chars.len() { if !chars[idx].is_ascii_uppercase() { return false; } let mut end = idx + 1; while end < chars.len() && chars[end].is_ascii_uppercase() { end += 1; } let lowered = end < chars.len() && chars[end].is_ascii_lowercase(); if lowered && end - idx != 1 { return false; // `ABCDef`:缩写后面接小写,不是词 } idx = end; while idx < chars.len() && chars[idx].is_ascii_lowercase() { idx += 1; } } true }