键盘相邻性:从asdxzxz看人肉随机数的安全陷阱与检测方法

📅 发布时间:2026/10/20 20:44:33
键盘相邻性:从asdxzxz看人肉随机数的安全陷阱与检测方法
我最近调一个内部联调环境需要给本地网关配一个临时访问密钥。不想翻密钥管理系统也不想用一个太正经的值——毕竟只是沙箱联调丢了就丢了对吧。于是我随手在键盘上敲了一串字符当占位符asdxzxz敲完自己还觉得挺满意八个纯字母没有单词形状没有数字看上去比test123之类的默认值“随机”多了。结果过了两天我在写一个键盘路径分析脚本时顺手把这串字符丢了进去画出来的轨迹让我愣了一下。A→S→D→X→Z→X→Z。这根本不是一串“随机”的字符而是一段有肌肉记忆参与的手部运动。这篇文章就从这串字符展开。我会把它当成一个引子聊清楚三件事怎么量化键盘输入的“相邻性”人肉随机和普通随机函数在生成安全值的时候有哪些坑以及我在做代码审查时是怎样过滤掉这类“看似随机、实则手癖”的字符串的。1. 先别急着聊安全asdxzxz 是怎么被“人肉生成”出来的1.1 从临时密钥到顺手占位符这件事的背景很简单。某次联调我需要一个临时 token用途是让两个本地服务在调试时互认身份。这个 token 的生命周期很短按计划当天结束就该删掉所以我完全没走正规签发流程直接在键盘上“随便敲了一段”。问题是人脑没有办法在完全没有规律的约束下输出随机字符。你以为自己是在乱打实际上手指会优先落在最舒服的位置上。asdxzxz就是很典型的一个结果一只手放在键盘左半区手指从 A 开始往右推到 D 之后顺着往下到 X再从 X 往左到 Z然后又弹回 X又蹭回 Z。这个动作用文字描述就是“画了个折线”。如果记录手指在键盘上的坐标会发现它根本不是随机跳跃而是沿着物理键位做了一次局部移动。1.2 轨迹还原我把 QWERTY 键盘左半部分的键位抽象成一个简单的坐标矩阵大概长这样行位置键位上排Q W E R T中排A S D F G下排Z X C V B如果只拉出asdxzxz涉及的键A 在左中排的起点S 在 A 右边一格D 在 S 右边一格X 在 D 的斜下方差不多是下排第二个位置Z 在 X 左边一格X 又回到原位Z 又跑到左边也就是说绝大多数移动的距离只有 1 格最多的一步也就是 D→X 的斜向移动。整条路径完全落在左手食指和中指的活动半径里。这种现象在手机端其实更常见——大家应该见过很多人划屏解锁的九宫格密码走的路线永远是 L 形、Z 形或者来回折返因为人的手指天然倾向于在同一个小区域内来回运动。桌面键盘虽然键位多但人类的运动习惯不会变只是把触摸屏上的折线搬到了物理键位上而已。所以从这个角度看asdxzxz既是一个随机的占位符也是一个非常典型的“键盘肌肉记忆指纹”。它记录了当时输入者的手部活动习惯这个指纹在安全分析里是有意义的。2. 量化“键盘相邻性”看起来随机不代表熵够高2.1 用坐标和距离说话我上面说的“键盘轨迹”不能只停留在感觉层面。既然要把它当审计点就得有个可量化的指标。我在脚本里做了三件事先定义 QWERTY 键位坐标然后计算相邻字符之间的曼哈顿距离最后统计整段字符串的平均步长。import string qwerty_rows [ qwertyuiop, asdfghjkl, zxcvbnm ] key_pos {} for row_idx, row in enumerate(qwerty_rows): for col_idx, ch in enumerate(row): key_pos[ch] (row_idx, col_idx) def manhattan(a: str, b: str) - int: r1, c1 key_pos[a] r2, c2 key_pos[b] return abs(r1 - r2) abs(c1 - c2) def analyze_pattern(s: str): s s.lower() distances [] for i in range(len(s) - 1): d manhattan(s[i], s[i 1]) distances.append(d) avg sum(distances) / len(distances) if distances else 0 print(f字符串: {s}) print(f相邻距离列表: {distances}) print(f平均步长: {avg:.2f}) print(f步长1的占比: {sum(1 for d in distances if d 1) / len(distances):.0%}) analyze_pattern(asdxzxz)跑出来的结果很有意思每一步的曼哈顿距离依次是 1、1、2、1、1、1平均步长约 1.17步长小于等于 1 的占比接近 85%2.2 字符串熵的再计算如果按均匀分布来算一个 8 位小写字母字符串的理论熵大约是 38 bit对于简单的临时值来说不算太差。但这个计算成立的前提是每一位字符都是独立、等概率地从 26 个字母里选出来的。asdxzxz显然不满足这个前提。它根本不是 26 个字母里的独立抽样而是“按键位置”上的连续移动。真正起作用的约束不是字符种类而是方向和距离。连续输入的字符之间高度相关每一步只允许从当前键位的物理邻域里做选择。顺着这个思路继续推导会发现这个看起来像是 26^8 量级的字符串实际的有效组合会被压缩到很小的范围。手部运动路径的规律性越强可用的随机性就越弱。这也是为什么很多规则引擎在计算口令强度时会把“顺序键盘序列”直接判为弱模式。因为攻击者的字典里早就收录了qwerty、asdf、zxcv这类片段。我这里的asdxzxz只是换了个组合方式本质和“键盘序列”是同一类模式。2.3 为什么步长短是弱熵信号步长越短说明输入时的物理位移越小。如果一个随机字符串由独立字符构成字符之间应该均匀分布在整个键盘区域那么相邻距离的平均值不会这么低也不会出现“一步之内必然落在旁边键”的情况。反过来当字符串里每一步都落在相邻键上它对应的就是一个物理上容易执行、视觉上容易被记忆、行为上容易被预测的模式。任何人只要看到你手指落点的大致区域就很容易通过肩膀视角重建整个序列。即便不是你亲自敲别人用录像设备记录键盘热区也能还原出一个概率很高的候选集合。所以在审计一个字符串是否适合做密钥、token 或口令时我习惯把“键盘相邻性”作为第一道前置检查而不是等到暴力破解环节再去操心。3. 人肉随机之外普通随机函数的坑同样不小3.1 从 asdxzxz 引出的“随机性来源”问题asdxzxz这个问题让我重新审视了一遍项目中生成随机值的代码。结果发现人肉随机只是问题之一代码里的随机性来源如果不注意问题更隐蔽。很多刚起步的项目会用random模块生成临时密钥import random import string def make_token(length16): chars string.ascii_lowercase string.digits return .join(random.choice(chars) for _ in range(length))这在功能上能跑但random模块生成的是伪随机数随机性来自一个确定性的算法。如果攻击者能拿到一定数量的连续输出或者能推测出种子状态后续输出是可以被算出来的。尤其当应用启动后没有主动加入足够的系统熵某些环境里生成的序列会表现出明显的周期性和可预测性。后来我在代码审查里给团队定的要求很简单凡是和密钥、令牌、口令沾边的值一律不要用通用随机函数改用操作系统提供的随机源。在 Python 里就是secrets模块import secrets # 生成一个二进制安全的临时令牌 token secrets.token_hex(16) # 如果想直接得到一个 URL 安全的字符串 url_token secrets.token_urlsafe(32)3.2 固定占位符的“惯性传染”再说回asdxzxz。虽然它最初只是一个本地沙箱的临时值但坏习惯是会传染的。开发过程中很多人会在代码里先写一个固定的字符串占位。比如本机联调用的SECRET_KEY asdxzxz请求头的 mock token 固定为asdxzxz数据库连接串里先用一个固定值占位如果这个值只是写在某个人的本地配置里问题不大。但只要它被提交到代码仓库或者被复制到测试环境的配置文件里就会跟着代码一起流转。最典型的情况是配置里写的是占位符但因为能跑通大家就默认它是真实密钥测试环境沿用后日志、监控、数据库记录里到处都会出现这个字符串等到想统一替换成正式密钥时根本搜不全所有出现过的位置我用asdxzxz就吃过这个亏。它后来出现在某内部工具的三个模块里其中一处还是明文写死在连接逻辑里的。真正解决时花的时间比一开始直接走正规签发流程要长得多。3.3 占位符和真实密钥的边界模糊另一种风险是把占位符做得“过于像真的”。asdxzxz这类字符串没有语义混在真实的 token 里很难一眼分辨。日志告警时如果看到一个类似asdxzxz的值你不会立刻意识到它只是个占位符于是排查方向会跑偏。后来我在项目规范里加了一条占位符必须带有明确的标识前缀比如PLACEHOLDER_、MOCKTOKEN_并且在静态检查流程里禁止让这些前缀出现在除测试目录以外的代码路径里。这一条在后续几次安全审查看起来极其有效至少我再也没有在正式环境的代码里看到裸奔的临时字符串了。4. 滤除“键盘手癖”让生成和检查都变得可执行4.1 给生成器装上模式检测器既然找到了问题就得有对应的检测手段。我在一个内部小工具里加了这样一段逻辑每次生成随机字符串以后先做一次键盘相邻性检测如果检测通过就保留检测失败就丢弃重来。import secrets import string keyboard_adjacent_limit 1 # 相邻距离小于等于这个值就判定为可疑 def keyboard_distance(s: str) - list[int]: s s.lower() return [manhattan(s[i], s[i 1]) for i in range(len(s) - 1)] def is_keyboard_pattern(s: str) - bool: distances keyboard_distance(s) if not distances: return False short_steps sum(1 for d in distances if d keyboard_adjacent_limit) return short_steps / len(distances) 0.6 def safe_random_token(length: int 24) - str: alphabet string.ascii_letters string.digits for _ in range(20): # 最多重试20次 candidate .join(secrets.choice(alphabet) for _ in range(length)) if not is_keyboard_pattern(candidate): return candidate raise RuntimeError(连续生成的候选值都命中键盘相邻模式请检查随机源)注意这里不能只检查一次就完事因为在极短时间内暴力重取secrets每次返回的概率分布虽然独立但连续多次命中相邻模式的概率也不是零。给一个重试上限会让工具更健壮。4.2 代码审查里该查什么我后来把这类检查写进了自己的开发检查单。每次涉及密钥、token、IV、salt 这类变量我会多看一眼三件事第一来源。有没有用不安全的随机函数如果是直接替换成secrets或操作系统提供的等效接口。第二形式。字符串是否存在连续的键盘相邻模式是否存在明显的递进序列abc、qwe、asd是否存在重复的“往返”运动比如这里的xzxz第三生命周期。是否真的需要写死如果不需要写死就该走配置下发如果一定阶段内需要占位那占位符必须满足前面说的显式前缀规则并登记在一个过期的 TODO 清单上。4.3 项目命名和其他场景的启示asdxzxz给我的启发并不只停在密钥生成上。项目名、仓库名、模块名如果也采用“随手乱敲”的方式同样会埋下隐患。有的团队新建 Git 仓库时随手叫test123、asdf结果半年后仓库里全是同名项目。有的团队给内部服务起名字时靠手癖敲出难以拼读的组合导致后续在监控面板、日志追踪、命令行工具里频繁拼错。相比之下我现在的做法是项目内部代号用有语义但不会泄露业务信息的名字临时分支用自动生成的编号而不是手敲字符所有临时密钥统一走签发接口不留手工敲键盘的空间这个转变很小但效果立竿见影。至少最近半年我再也没遇到过“搜一个字符串搜出十几个不同的使用点”的情况。5. 我后来是怎么改进这套流程的5.1 给占位符建立生命周期写代码这些年我没有少踩“临时值后期转正”的坑。asdxzxz让我意识到问题不出在临时值本身而出在临时值没有明确的过期机制。我现在的做法是给所有占位符加一个遗忘检测点。比如本地配置里的 mock token 在每次启动时打印一个 WARNING提醒这是临时值CI 流程里扫描代码中出现的明显占位模式命中后直接失败隔一段时间做一次密钥轮换确认所有占位符都被清理干净这套机制跑起来不需要特别复杂静态扫描的规则也不过几十行但每次都能在问题发酵前拦住一两个漏网之鱼。5.2 把键盘路径检测推广到权限口令我还把前面那段曼哈顿距离代码整合到了内部口令强度快检工具里。业务方提交一个新口令时工具会同时做三份检查长度和字符多样性是否命中常见弱口令库是否命中键盘相邻模式第三项是后来加的。因为很多团队提出来的口令虽然不常见但构造逻辑还是“从键盘某个位置开始连续滑行”这类口令在暴力字典里看不出问题却依然经不起实际攻击者的推演。5.3 想清楚“谁在用自己的手生成安全值”最后分享一个我总结出的判断标准只要某个安全值最终会经过人手无论是生成、复制还是输入就必须假定它带有物理规律。手心手背都是肉手指永远会优先跑到最省力的位置。所以我在真正需要高随机性的场景里会尽量减少人为干预。不手敲、不复制粘贴、不凭记忆修改全部走自动生成和自动下发。只有当工具不可及时才手工处理而且手工生成的值一定不会直接投入使用而是再挂上一层哈希或编码转换把键盘手癖的痕迹尽量抹掉。用asdxzxz当占位符这件事本身不是什么大事故但它让我把一个很朴素的道理重新想明白了你以为你在随机其实你的手指一直在画同一幅画。与其事后排查不如从一开始就别让手参与生成安全值。