findpass:配置文件与SQLite账号密码检索及脱敏核对实践

发布时间:2026/11/6 5:41:42
findpass:配置文件与SQLite账号密码检索及脱敏核对实践

简介:这份资源围绕 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(encoding='utf-8', errors='ignore') 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', required=True, help='检索根目录,仅限已授权路径') ap.add_argument('--ext', default='.yml,.yaml,.ini,.env,.json,.conf', help='逗号分隔的扩展名白名单') ap.add_argument('--no-mask', action='store_true', help='关闭脱敏,仅在离线核对时使用') ap.add_argument('--out', default='findpass_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_on=not args.no_mask)) Path(args.out).write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8') print(f'hits={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/ # 结果输出,权限 700

input/我倾向放副本而不是直接扫生产目录,一是避免误改,二是副本可以脱敏后再带走。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(encoding='utf-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_USER=svc_demo\nDB_PASSWORD=demo_pwd_123\n' > input/app.env python findpass_scan.py --root ./input --ext .env --out out/r1.json python -c "import json;d=json.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/env

user不脱敏是有意的:核对台账时你就是要看用户名对不对,遮住反而没法用。pass_masked只留首尾,是为了让你能判断「这条跟我印象里的是不是同一条」,又不至于把值泄露到结果文件里。真需要完整值做比对,走--no-mask并且结果文件当场删。

参数上还有一个容易忽略的:编码。老项目的配置文件可能是 GBK,read_text不指定编码会抛UnicodeDecodeError。我的处理是errors='ignore'加一次失败重试 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 五条踩坑记录

第一条,扫出来零条命中。现象是脚本跑完hits=0,但文件里明明有password=。原因通常是扩展名白名单没覆盖,比如文件叫config.properties,默认--ext里没有.properties;或者文件编码是 UTF-16,read_text读出来全是乱码,正则匹配不上。解决办法是先file命令看一眼编码,再补扩展名,UTF-16 的文件用encoding='utf-16'单独走一遍。

第二条,命中一堆admin_url、is_admin之类的噪音。现象是结果里一半是误报。原因是正则只匹配了键名的前缀,admin后面的_url没被挡住。解决办法是把键名匹配从「包含」改成「完整键名」,正则结尾加\s*[:=]强制要求键名后面紧跟分隔符,admin_url=里的admin后面是下划线不是冒号,自然被排除。

第三条,权限报错Permission denied,但明明是 root。现象是扫到某个子目录时整个脚本崩掉。原因是rglob遇到无权限目录会抛PermissionError,root 也躲不开某些特殊挂载点。解决办法是把遍历改成os.walk加onerror回调,遇到错误目录打印跳过继续,而不是让整个任务中断。

第四条,扫出来的密码跟实际登录对不上。现象是配置文件里的值和数据库里的值不一致。原因是配置文件里存的是占位符或旧值,运行时被环境变量覆盖了。解决办法是加一步「运行时校验」:在服务进程里查/proc/<pid>/environ拿真实环境变量,跟配置文件比对,差异项单独列出来。这一步只能在授权机器上做,别越界。

第五条,结果文件被同事顺手拷走。现象是脱敏了但依然出事。原因是键名、路径、行号组合起来信息量比想象中大,加上--no-mask跑过一次之后忘了删原文件。解决办法是结果目录固定 700,脚本退出前提示清理,--no-mask模式强制要求输入YES二次确认。

5.2 一条固定的排查顺序

出问题别乱试,按这个顺序走一遍,八成能定位:

  1. 先确认输入:--root路径存在、文件数非零、扩展名覆盖。
  2. 再确认读取:单文件read_text能否成功、编码对不对。
  3. 再确认匹配:拿一行已知的账号记录单独测正则,看group取到的是不是预期。
  4. 再确认输出:结果文件是否写入、JSON 是否合法、条数与预期是否一致。
  5. 最后才怀疑业务:是不是占了占位符、是不是环境覆盖、是不是账号本身就不存在。

这个顺序的价值在于把「环境问题」和「数据问题」分开。大部分「查不到」死在第一步和第二步,而不是正则写错。

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 的表。先在自己机器上拿三个假账号跑通第一章的脚本,再往真实目录推,别跳步。希望帮到你。

本文还有配套的精品资源,点击获取