
简介这份资源围绕 Windows 平台本地账户口令提取这一经典安全研究课题提供了一套可编译、可复现的工程源码与配套工具。它面向具备 C/C 基础、对 Windows 内存结构与 Native API 感兴趣的逆向分析学习者和安全研究人员重点解决如何从 winlogon 进程内存空间中定位指定域与用户名的明文登录信息并解码出账户口令的问题支持 Windows NT4 SP6 及 Windows 2000 环境。压缩包共 5 个文件、约 106KB包含两份可执行程序主程序与进程列表辅助工具、一份 CPP 源码、一份 HTML 说明文档以及一张运行效果截图源码中对 PEB 遍历、ReadProcessMemory 读取、内存块扫描、RtlRunDecodeUnicodeString 解码等环节均有中文注释Win2K 下还给出以 hash-byte 暴力枚举配合可见字符判定的解码思路。已有 796 人学习浏览适合作为理解进程内存布局、调试特权申请与未公开 API 调用的实践参考也可用于实验环境中的口令存储机制验证。1. findpass 是什么三类能查和三类查不了的账号场景findpass这个关键词很多人第一反应是「能不能查到某个账号的密码」。我在运维里用它做的事更窄只在自有或已获授权的环境里把配置文件、SQLite 库、环境变量里记着的用户名与密码条目找出来做一次台账核对。用户名密码查询真正难的不是搜不到而是搜到之后分不清这条记录属于哪个服务、密码是明文还是哈希、上次轮换是什么时候。它适合接手老服务器要盘账号的运维、跑着十几个自建服务的独立开发者以及做安全自查要确认弱口令范围的负责人。不适合拿去碰别人的系统也不做撞库。一句话概括它的价值把「我记得好像有这个账号」变成「这条记录在哪、什么形态、要不要改」。下面按数据源、脚本、参数、排错、批量核对一路拆开都是能照着敲的东西。2. 拆开 findpass 的检索链路从数据源定位到字段匹配的四步检索链路这一层如果没想清楚后面的脚本写得再漂亮也是白搭。我见过太多人上来就grep -r password /跑了二十分钟硬盘灯狂闪最后捞出来一堆日志里的请求体、缓存里的会话串、备份包里的旧配置真正要的那条反而埋在中间。findpass 这类工具的核心不是「搜」而是「限定在哪些容器里、按什么字段、以什么匹配强度搜」。2.1 先定数据源配置文件、SQLite、环境变量三类载体账号记录在自建服务里通常只有三个落点。第一类是配置文件application.yml、config.ini、.env、settings.json这些字段名五花八门username/user/login/account/db_user都可能代表同一个意思。第二类是嵌入式或轻量数据库SQLite 最常见账号表一般叫users、accounts、admin、sys_user。第三类是进程环境变量容器化部署里尤其多DB_PASSWORD、ADMIN_PWD、REDIS_AUTH这类键名。我一般会按下面的优先级扫从高到低扫到能回答业务问题就停不做无意义的全盘遍历优先级载体典型位置检索字段注意点1环境变量服务启动脚本、容器定义*_PASSWORD、*_PWD、*_USER值可能是运行时注入静态文件里为空2应用配置项目根目录、conf/user、username、password、secret区分环境dev和prod不能混3SQLite项目目录、data/users、accounts表先看表结构别盲查列名4日志与备份logs/、backup/同上只作交叉验证不作为主数据源为什么把日志排最后因为日志里的账号信息往往是脱敏不彻底的残留格式不统一而且一个请求可能打印三遍去重成本比收益高。真要核对配置文件加数据库这两处已经能覆盖八成场景。2.2 字段匹配的三种模式精确、前缀、模糊匹配强度决定你查得准不准。精确匹配适合你已经知道完整用户名只想知道它在哪些文件里出现前缀匹配适合同一套命名规范下的批量账号比如svc_开头的服务账号模糊匹配适合只记得半截名字、需要捞候选的情况。三种模式的代价完全不同。精确匹配误报最低模糊匹配误报最高——admin这个词能命中admin_url、is_admin、ADMIN_TOKEN全是噪音。我在脚本里默认用精确匹配模糊模式必须显式打开并且在输出里标注匹配模式避免回头自己都分不清哪条是猜的。另一个容易被忽略的是大小写。Linux 配置文件里DB_USER和db_user可能同时存在前者是环境变量风格后者是 yaml 风格。正则里统一加re.IGNORECASE但输出时保留原始大小写方便你回原文件定位。2.3 最小检索脚本一次跑通从文件到结果下面这段是 findpass 这类工具的最小骨架能扫目录、能匹配键值、默认脱敏。先跑通再谈扩展。# findpass_scan.py # 在指定目录内检索「用户名/密码」键值对默认只输出脱敏结果 import argparse import json import re from pathlib import Path # 键名匹配覆盖 user/username/login/account 与 pass/password/pwd/secret USER_KEY re.compile(r(?i)^\s*[\]?(user(name)?|login|account)[\]?\s*[:]\s*[\]?([^\s\,}])) PASS_KEY re.compile(r(?i)^\s*[\]?(pass(word)?|pwd|secret|token)[\]?\s*[:]\s*[\]?([^\s\,}])) # 脱敏长度保留、首尾各留一个字符中间替换为 * def mask(v: str) - str: if len(v) 2: return * * len(v) return v[0] * * (len(v) - 2) v[-1] def scan_file(path: Path, mask_on: bool): hits [] try: text path.read_text(encodingutf-8, errorsignore) except OSError: return hits for lineno, line in enumerate(text.splitlines(), 1): # 跳过注释行减少噪音 if line.strip().startswith((#, //, ;)): continue mu USER_KEY.search(line) mp PASS_KEY.search(line) if not mu and not mp: continue hits.append({ file: str(path), line: lineno, user: mu.group(3) if mu else None, pass: mask(mp.group(3)) if (mp and mask_on) else (mp.group(3) if mp else None), }) return hits def main(): ap argparse.ArgumentParser() ap.add_argument(--root, requiredTrue, help检索根目录仅限已授权路径) ap.add_argument(--ext, default.yml,.yaml,.ini,.env,.json,.conf, help逗号分隔的扩展名白名单) ap.add_argument(--no-mask, actionstore_true, help关闭脱敏仅在离线核对时使用) ap.add_argument(--out, defaultfindpass_result.json) args ap.parse_args() exts tuple(e.strip() for e in args.ext.split(,)) results [] for p in Path(args.root).rglob(*): if p.is_file() and p.suffix in exts: results.extend(scan_file(p, mask_onnot args.no_mask)) Path(args.out).write_text(json.dumps(results, ensure_asciiFalse, indent2), encodingutf-8) print(fhits{len(results)} - {args.out}) if __name__ __main__: main()逻辑上分三段键名正则负责识别「这一行是不是账号记录」脱敏函数负责把值遮住目录遍历负责控制扫描范围。三个参数里--root是安全边界我一般只指向具体项目目录绝不写/--ext白名单是为了避开.log、.bak、.pyc这类噪音文件--no-mask是后悔药开关只在完全离线的核对场景下打开打开前先把结果目录权限收一下。正则里group(3)取的是值group(1)是键名本身改规则时别取错。如果你扫的是 yaml缩进层级可能让键名前面带空格^\s*就是为这个加的如果扫的是 JSON 单行压缩格式这个正则会漏需要改成json.loads后递归遍历——这是下一章要处理的事。3. 环境准备与最小可跑让查询脚本在本地跑起来的五个动作脚本能跑和能用在生产里是两回事。这一章讲的是怎么在自己机器上把 findpass 的运行环境搭起来并且保证第一次跑出来的结果你自己敢信。我用的是 Python 3.9 以上标准库足够不需要装第三方包这样在隔离环境里也能跑。3.1 依赖与目录约定依赖只有标准库argparse、re、json、pathlib、sqlite3全是自带的。不要为了一个键值检索装一堆解析库依赖越多在客户现场装不上的概率越高。目录我一般固定成这个结构方便后面复用# 工作目录约定 findpass/ ├── findpass_scan.py # 文件扫描器 ├── findpass_db.py # SQLite 查询器 ├── rules/ │ └── keys.txt # 键名白名单一行一个 ├── input/ # 待查目录的软链或副本 └── out/ # 结果输出权限 700input/我倾向放副本而不是直接扫生产目录一是避免误改二是副本可以脱敏后再带走。out/权限设成 700因为即使脱敏了键名和行号组合起来仍然能反推出不少信息。rules/keys.txt是我踩过坑之后加的。不同项目的字段命名差异极大硬编码正则改起来麻烦不如把键名抽出来做成外部文件# rules/keys.txt —— 一行一个键名脚本读取后拼成正则 user username login account password passwd pwd secret脚本里读取这个文件拼成re.compile(|.join(keys), re.I)加新字段不用动代码。键名清单建议跟着项目走每个项目一份别搞成全局大而全的清单误报会失控。3.2 配置文件的解析与脱敏输出纯正则对付 ini 和 env 够用但对付嵌套 JSON 和 YAML 就吃力了尤其是单行压缩的 JSON。我的做法是分两条路结构化文件走解析器纯文本走正则。下面这段处理 JSON 的递归遍历# findpass_json.py # 递归遍历 JSON输出键路径与值密码类键一律脱敏 import json from pathlib import Path SENSITIVE (pass, pwd, secret, token, key) def walk(node, prefix): if isinstance(node, dict): for k, v in node.items(): path f{prefix}.{k} if prefix else k # 命中敏感键名时脱敏其余原样输出 if any(s in k.lower() for s in SENSITIVE) and isinstance(v, str): yield path, v[0] * * max(len(v) - 2, 0) (v[-1] if len(v) 1 else ) else: yield from walk(v, path) elif isinstance(node, list): for i, v in enumerate(node): yield from walk(v, f{prefix}[{i}]) def parse(path: Path): data json.loads(path.read_text(encodingutf-8)) return list(walk(data))递归遍历的好处是能拿到完整键路径db.master.password这种结构一眼就知道属于哪个连接比正则输出一行password: xxx有用得多。脱敏逻辑跟上一章保持一致首尾各留一位中间打星号。max(len(v) - 2, 0)是为了防止长度为 1 时算出负数导致切片异常这个边界我第一版漏了跑空密码时直接抛异常。3.3 用一条命令验证链路是否通搭完环境别急着扫生产先造一个最小样本验证链路。我在input/下放三个文件一个 env、一个 yaml、一个 sqlite各自塞一条假账号然后跑# 造样本 跑扫描 看结果条数 mkdir -p input out printf DB_USERsvc_demo\nDB_PASSWORDdemo_pwd_123\n input/app.env python findpass_scan.py --root ./input --ext .env --out out/r1.json python -c import json;djson.load(open(out/r1.json));print(len(d), d)预期输出是 2 条命中用户名的值是svc_demo密码的值是d*********3。如果条数是 0先看--ext是否匹配、再看行首是不是被注释符挡掉了——这两个原因占了首次失败的大头。如果条数是 4说明同一行同时匹配了 user 和 pass 两条规则检查正则是不是把值也当成了键。链路通了的标志不是「有输出」而是「输出的条数、位置、脱敏形态三项都符合预期」。我一般会故意在样本里放一行注释掉的账号确认它被跳过再放一行admin_url确认它不被误判成账号。这两个反向用例过了才敢往真实目录上跑。4. 参数与字段映射用户名、密码哈希与加密存储怎么读查出来只是第一步读得懂才是关键。同一个password字段值可能是明文、可能是 bcrypt 哈希、可能是 AES 加密后的 base64三种形态的处理方式完全不同。这一章讲怎么在脚本层面把这三态区分开以及输出字段怎么设计才不至于误导自己。4.1 明文、哈希、加密三态的识别识别靠特征不靠猜。我一般在脚本里加一个classify()函数按下面的顺序判断形态典型特征长度范围能否还原明文可打印字符含常见词不定直接可读bcrypt$2a$、$2b$、$2y$开头60不可逆SHA 系纯十六进制32 / 40 / 64不可逆可彩虹表比对MD5 盐值拼接十六进制 固定后缀不定需知道盐规则对称加密base64 字符集常带填充4 的倍数需密钥占位符${DB_PWD}、__ENV__短值在别处这个表最实用的地方是「占位符」那一行。容器化项目里配置文件里的密码十有八九是${DB_PASSWORD}这种占位真正的值在启动脚本或编排文件里。第一次扫出来一堆${...}别慌那不是没查到是查到了引用关系顺着键名去环境变量里找就对了。# classify.py —— 按特征判断密码字段的存储形态 import re def classify(v: str) - str: if not v: return empty if re.fullmatch(r\$\{[^}]\}|__[A-Z_]__, v): return placeholder # 占位符值在别处 if re.match(r^\$2[aby]\$, v): return bcrypt if re.fullmatch(r[0-9a-fA-F]{32}, v): return md5_or_hash32 if re.fullmatch(r[0-9a-fA-F]{64}, v): return sha256 if re.fullmatch(r[A-Za-z0-9/]{0,2}, v) and len(v) % 4 0 and len(v) 16: return maybe_base64 # 可能是对称加密结果 return plain_or_unknown判断顺序很重要先判占位符再判带前缀的 bcrypt最后才判纯十六进制。如果把 base64 判断放在前面一个 32 位纯十六进制串既符合十六进制也符合 base64 字符集会被误判。maybe_base64我故意加了maybe因为 base64 只是编码不是加密光看字符集分不清是加密结果还是普通编码得结合字段名和密钥管理方式再确认。4.2 输出字段与脱敏策略的参数表输出结构我固定成这几个字段不多不少字段含义是否脱敏说明file来源文件绝对路径否方便回原文件核对line行号否JSON 类无行号填键路径key原始键名否保留大小写user用户名值否用户名一般不敏感pass_masked脱敏后的密码值是首尾各留一位pass_type形态分类否取 4.1 的分类结果source数据源类型否file/db/envuser不脱敏是有意的核对台账时你就是要看用户名对不对遮住反而没法用。pass_masked只留首尾是为了让你能判断「这条跟我印象里的是不是同一条」又不至于把值泄露到结果文件里。真需要完整值做比对走--no-mask并且结果文件当场删。参数上还有一个容易忽略的编码。老项目的配置文件可能是 GBKread_text不指定编码会抛UnicodeDecodeError。我的处理是errorsignore加一次失败重试 GBK宁可漏掉几个乱码字符也不要整个文件读失败。4.3 批量核对时的去重与冲突处理扫完一个目录同一个账号可能在.env、docker-compose.yml、config.py里各出现一次值还不一样。这时候要做的是去重加冲突标记而不是简单合并。去重键我用(user, pass_masked)组合因为同一个用户名在不同环境用不同密码是正常的不能只按用户名去重。冲突判定则是「同一用户名、不同密码值」这类记录单独打conflict标记输出到另一个文件。# dedup.py —— 按用户名聚合并标记密码冲突 from collections import defaultdict def dedup(records): by_user defaultdict(list) for r in records: if r.get(user): by_user[r[user]].append(r) merged, conflicts [], [] for user, items in by_user.items(): values {i[pass_masked] for i in items if i.get(pass_masked)} if len(values) 1: # 同一用户多个不同密码全部保留并打标 for i in items: i[conflict] True conflicts.append(i) else: merged.append(items[0]) return merged, conflicts冲突记录不是错误是信号。它通常意味着三件事之一环境没隔离干净、密码轮换没同步、或者是不同服务的同名账号。我一般会把冲突清单单独发回给服务负责人确认而不是自己拍板取哪个。5. 避坑与排查查不到、查错人、权限报错的五条记录这一章全是血泪经验每条都按「现象、原因、解决」写能对号入座最好。5.1 五条踩坑记录第一条扫出来零条命中。现象是脚本跑完hits0但文件里明明有password。原因通常是扩展名白名单没覆盖比如文件叫config.properties默认--ext里没有.properties或者文件编码是 UTF-16read_text读出来全是乱码正则匹配不上。解决办法是先file命令看一眼编码再补扩展名UTF-16 的文件用encodingutf-16单独走一遍。第二条命中一堆admin_url、is_admin之类的噪音。现象是结果里一半是误报。原因是正则只匹配了键名的前缀admin后面的_url没被挡住。解决办法是把键名匹配从「包含」改成「完整键名」正则结尾加\s*[:]强制要求键名后面紧跟分隔符admin_url里的admin后面是下划线不是冒号自然被排除。第三条权限报错Permission denied但明明是 root。现象是扫到某个子目录时整个脚本崩掉。原因是rglob遇到无权限目录会抛PermissionErrorroot 也躲不开某些特殊挂载点。解决办法是把遍历改成os.walk加onerror回调遇到错误目录打印跳过继续而不是让整个任务中断。第四条扫出来的密码跟实际登录对不上。现象是配置文件里的值和数据库里的值不一致。原因是配置文件里存的是占位符或旧值运行时被环境变量覆盖了。解决办法是加一步「运行时校验」在服务进程里查/proc/pid/environ拿真实环境变量跟配置文件比对差异项单独列出来。这一步只能在授权机器上做别越界。第五条结果文件被同事顺手拷走。现象是脱敏了但依然出事。原因是键名、路径、行号组合起来信息量比想象中大加上--no-mask跑过一次之后忘了删原文件。解决办法是结果目录固定 700脚本退出前提示清理--no-mask模式强制要求输入YES二次确认。5.2 一条固定的排查顺序出问题别乱试按这个顺序走一遍八成能定位先确认输入--root路径存在、文件数非零、扩展名覆盖。再确认读取单文件read_text能否成功、编码对不对。再确认匹配拿一行已知的账号记录单独测正则看group取到的是不是预期。再确认输出结果文件是否写入、JSON 是否合法、条数与预期是否一致。最后才怀疑业务是不是占了占位符、是不是环境覆盖、是不是账号本身就不存在。这个顺序的价值在于把「环境问题」和「数据问题」分开。大部分「查不到」死在第一步和第二步而不是正则写错。6. 进阶把单次查询变成可复核的批量核对流程单次查询解决的是「这条记录在哪」批量核对解决的是「这一批记录还对不对」。两者之间差的是可复核性结果能不能被别人按同样步骤复现能不能隔一周再跑一次对比差异。6.1 结果落库与二次校验我把每次扫描结果写进一个本地 SQLite表结构固定成scan_id、file、key、user、pass_masked、pass_type、scanned_at。这样第二次跑的时候可以直接做 diff-- 对比最近两次扫描找出新增、消失、值变化的账号记录 WITH last AS ( SELECT * FROM scan_result WHERE scan_id (SELECT MAX(scan_id) FROM scan_result) ), prev AS ( SELECT * FROM scan_result WHERE scan_id (SELECT MAX(scan_id) FROM scan_result WHERE scan_id (SELECT MAX(scan_id) FROM scan_result)) ) SELECT new AS kind, l.user, l.file FROM last l LEFT JOIN prev p ON l.user p.user AND l.file p.file WHERE p.user IS NULL UNION ALL SELECT gone, p.user, p.file FROM prev p LEFT JOIN last l ON l.user p.user AND l.file p.file WHERE l.user IS NULL UNION ALL SELECT changed, l.user, l.file FROM last l JOIN prev p ON l.user p.user AND l.file p.file WHERE l.pass_masked p.pass_masked;三类结果对应三种动作new说明这段时间新增了账号要确认是不是自己人开的gone说明记录被删了要确认是清理还是误删changed是最需要关注的密码变了但没人通知往往是轮换没走流程或者更糟的情况。这个 SQL 我每周固定跑一次跑完把changed清单发出去比任何巡检报告都直接。6.2 我自己的收尾习惯有一个习惯我后来强制自己每次都走不管多急--no-mask跑完的第一件事是删结果文件删完再ls确认一遍。这个动作看起来多余但我吃过亏——有一次在客户现场临时打开完整输出对比历史值对比完接了个电话回来直接关机走人文件留在桌面。后来复盘那次没出事纯属运气。所以现在我的脚本里--no-mask结束时会打印一行醒目提示并且不自动退出强制你按一次回车按之前你得先想好文件什么时候删。慢这几秒比事后写说明强。如果你也在维护一批老服务的账号台账findpass 这套东西的价值不在「查」本身而在把散落的、形态各异的记录收敛成一张能 diff 的表。先在自己机器上拿三个假账号跑通第一章的脚本再往真实目录推别跳步。希望帮到你。本文还有配套的精品资源点击获取