文章总结: 文章指出MFA无法完全保障登录安全,攻击者可通过AiTM反向代理技术绕过认证,窃取会话Cookie实现免密登录。核心结论是会话侧检测投入不足,建议企业建立ITDR体系,重点关注L3-L6层会话资产防护,部署针对重放攻击的检测机制,并完善phishlet配置管理以应对定制化钓鱼攻击。
综合评分: 85
文章分类: WEB安全,应用安全,安全建设,漏洞分析,实战经验
登录认证安全(上)MFA能确保登录一定安全?未必
原创
千里
千里
东方隐侠安全团队
2026年8月28日 20:01
江苏
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
最近在Reddit论坛上看到r/AskNetsec有个提问帖:
标题:How do you detect a compromised identity when the login itself looks legitimate?(2026-08-21发布于r/AskNetsec)(事故复盘:攻击者使用合法凭证、从合法设备登录,登录本身触发的告警数量为零。)
地址:
How do you detect a compromised identity when the login itself looks legitimate?
byu/Aggavathing-Diver825 inAskNetsec
看到国外同行做事故复盘时抛出这个直击灵魂的问题,我便多留心琢磨了一会儿。凭证完全正确,MFA多因素认证是用户本人触发的,且登录请求来自合规设备,在常规安全策略下,一般会直接放行,告警系统自然也一片寂静。该部署的防御工事样样不落,审计日志拉出来更是干干净净,但攻击者偏偏就是长驱直入了。
做企业安全的都习惯了从失败里找攻击,比如失败登录、恶意进程、异常流量,整套告警体系建立在”攻击必然伴随异常”这个假设上。
而这个帖子里面,我们必须承认,现有的假设失效其实是常常会发生的事情。
这是因为受限于技术成熟度、方案成本、实施复杂度和对业务的实际影响,企业的安全建设很难做到极致,因此”有兜底的安全”往往是大多数安全建设方的“退一步”的现实选择。
以身份校验为例,MFA(多因子认证)是公认的低成本身份识别机制,基本能确认当前用账号密码登录的主体,是真实且得到授权的人,这比接入生物特征识别要简单实现的多。
于是,看似成体系的凭证校验思路,形成了企业安全运营的方法论,比如:
- 针对暴力破解检测,需确保密码正确,无失败记录;
- 针对异地登录检测,需确认出口IP在常用网段;
- 针对设备信任检测,确认当前认证发起设备属于受管的可信设备;
- 针对进程检测,确认访问行为全部来自合法的进程,比如是来源于Chrome浏览器进程,而不是命令行或其他异常应用进程。
但现实总是不如人愿的,Gartner曾在几年前得出结论约80%的数据泄露是由于凭证泄露或滥用,也是基于这个判断,Gartner在2022年3月正式建立ITDR(Identity Threat Detection and Response)品类。
因此这篇文章,就沿着这个攻防矛盾往下走:先把针对”登录完全合法”的攻击路径逐条拆开,看攻击者到底是如何绕过身份认证的阻挡;再看现有检测为什么对它们集体失明;最后落到ITDR,真正能够兜底的信号怎么建、先建哪个。
PART 01
0x01攻击与保护对象
如果我们不知道敌人的攻击靶心是什么,防守精力就会分散,容易出现事倍功半的情况。
因此,当我们谈论攻击路径之前,首先要明确企业究竟在保护什么,或者说需要分析攻击者到底要窃取什么。
| 层级 | 载体 | 有效时长 | 能否绕过MFA |
| — | — | — | — |
| L1 密码 | 数据库哈希 / 键盘记录 / 钓鱼 | 长期 | 否,MFA仍在认证链上 |
| L2 OTP种子 | TOTP共享密钥(动态验证码的种子,绑定验证器时双方各存一份) | 长期 | 能,可自行生成合法验证码 |
| L3 会话Cookie | 浏览器Cookie数据库 | 数小时~数天 | 能,认证环节整体跳过 |
| L4 OAuth Token | access / refresh token | 1小时~90天 | 能,直接走API通道 |
| L5 签名密钥 | IdP(身份提供方)私钥 | 长期 | 能,可伪造任意身份的令牌 |
| L6 设备身份秘密 | PRT(主刷新令牌)/ 设备证书私钥 | 长期 | 能,冒充受信设备通过设备过滤 |
【名词解释】IdP(Identity Provider,身份提供方)就是大家公司统一登录用的认证系统,比如Azure AD(Entra ID)、Okta、Keycloak、LDAP这类。全公司几百个应用(邮箱、CRM、代码仓库)都不自己管账号密码,而是登录时跳转去问IdP:”这人是谁?”IdP验完密码和MFA,发一张电子通行证回来,这张通行证就是JWT令牌。
当前相关安全产品层出不穷,但主要投入在认证侧(MFA、Passkey、自适应认证)和授权侧(IGA、PAM、CIEM),会话侧的检测投入最少。而当前身份攻击的主要发生地恰恰是会话段。防御建设集中在L1(MFA普及),地下市场(后面会讲)的商品供给集中在L3/L4。Healsecurity对泄露infostealer日志的统计:约117万条日志同时包含登录凭证与活体会话Cookie,可直接通过会话重放进入账户,此时MFA就可以直接绕过了。
因为MFA只在认证时生效,通过”你持有第二因子”证明确实是账号本人操作,但攻击者偷走L3~L6中任何一层后,根本不会再触发认证机制,MFA自然失效。
那基于第一部分的梳理,和攻防重点失衡的简单分析,其实我们大概已经明白,为什么很多企业也耗费大量人力物力来做安全建设,但是始终收效甚微。因为一直“未知攻”,“知攻”不是简单了解攻击者的工具、手法,这是求下弃上的体现。
在接下来的部分,我们开始拆解攻击者的攻击路径,分析攻击的靶心和原理是什么。
PART 02
0x02路径一:AiTM反向代理(T1557)
传统钓鱼技术利用的网站页面一般都是克隆而来,把目标站点的HTML拷下来,架个假页面,用户提交密码后攻击者拿密码去真站进行使用。在AI近几年发展进程中,让AI做这件事情可以做得更加逼真。
但是,这个技术路线有三处硬伤:
- 页面静态(不会跟进目标改版)
- MFA环节无法绕过(假页面收不到真挑战)
- 地址栏TLS锁报错(域名不对,证书肯定报错)。
而这里分享的AiTM(Adversary-in-the-Middle,)反向代理则规避了这些风险,因为它根本不做页面,只做管道,他的流程是这样的(攻击者的设备就是流程里的Evilginx代理):
受害者浏览器 ──TLS①──> Evilginx 代理 ──TLS②──> 真实 IdP
(受害者的锁是真锁) (代理就是一个正常客户端)
- TLS①:受害者与login.target.com.evil.com握手,Evilginx持有Let’s Encrypt给这个子域签发的真证书,因此在浏览器查看地址栏的锁图标是真的,点开看证书也符合站点,只是Common Name是攻击者的域
- TLS②:代理再以普通HTTP客户端身份向login.target.com发起请求,对真站而言这就是一个正常的浏览器访客
此时,受害者看到的每一个字节,包括所有HTML、CSS、JS、验证码图片、MFA推送的倒计时动画,都由真实服务器签发、经代理原样转发。唯一不同之处是页面里所有链接和表单的action被改写指向代理域。
看起来很像网络层面的中间人攻击。
那么,AiTM是怎么做到这一切呢?
正常登录target.com时,请求与响应是这样的:
① POST /auth/login HTTP/1.1
Host: login.target.com
Cookie: (空)
[email protected]&passwd=******
② HTTP/1.1 302 Found
Set-Cookie: sess=7f3a9c...; Domain=.target.com; Secure; HttpOnly
Location: /dashboard
③ GET /dashboard HTTP/1.1
Host: target.com
Cookie: sess=7f3a9c... ← 浏览器自动附带,无感知
第 ② 步是关键:服务器用Set-Cookie头请求浏览器”请帮我保存这个值”。浏览器收到后要执行一套写入规则(RFC 6265 domain-match):只有当前页面的主机属于Domain属性声明的域(target.com或它的子域)时,才允许写入target.com的Cookies里面。
写入成功后,此后每一个发往target.com的请求,浏览器都会自动从存储里取出值塞进Cookie头。服务器靠这个值认人,即”你登录过了”这个状态,从头到尾存在浏览器里,不存在服务器里(服务器只存映射表)。这句话是后面所有攻击的支点:会话的载体在客户端。
再看三个属性各自防的是什么,后面要用:
| 属性 | 防的威胁 |
| — | — |
| Secure | 明文HTTP传输中被网络窃听者截获 |
| HttpOnly | 页面里的恶意JS用document.cookie读取 |
| Domain=.target.com | 别的域借响应把自己的Cookie种到target.com罐子里 |
现在把AiTM插进来。受害者访问的是https://target.com.evil.com/auth,注意这个域名的注册域(eTLD+1)是evil.com,target.com只是它中间的一段装饰,专门混淆用的。
假设中间的恶意代理是个直接的透明管道,真站的响应原样转发:
HTTP/1.1 302 Found
Set-Cookie: sess=7f3a9c...; Domain=.target.com; ...
Location: /dashboard
注意前面说的浏览器执行写入规则,因为当前主机是target.com.evil.com,它的注册域是evil.com;而Cookie声明Domain=.target.com,当前主机不属于target.com域,写入是不会顺利把Cookie读到 .target.com里面的,真实会话Cookie到了门口,只会直接丢弃。
接下来受害者浏览器请求 /dashboard(不带头),因为并没有保存真实站点的Cookie,请求里面也不会有Cookie,此时真站就会认为未登录,重定向回登录页;再请求,再弹回……最后出现了重定向死循环。
受害者看到的是”网站抽风了”,第一反应是关页面骂官网,攻击也只能到此为止。顺利的话,受害者刚刚在恶意界面已经输入账号密码,攻击者可以偷到了POST body里的密码,但其实并没有顺利完成登录,如果有MFA的拦截,仍旧无法顺利登录。
而现在很多IdP的登录流程也需要用Cookie维持状态的,比如微软登录从第一步到MFA屏要陆续签发好几个中间Cookie(设备指纹、流程状态标记),每一步请求都要带上一步发的Cookie才能进入下一步。因此恶意的中间管道在第一步之后流程就断了,连MFA页面都渲染不出来。
所以,攻击者想让整场戏演完,代理就不能只做透明管道。它必须自己接管”替受害者保管Cookie”这件事。于是代理在服务器内存里为每个受害者维护一个会话上下文(SessionContext):
SessionContext {
session_id: <代理分配的随机标识>
target_cookies: Map<(name, domain, path), Cookie> // 代管的目标域 Cookie
captured_credentials: List<{field, value, timestamp}> // phishlet 声明的凭证字段
}
响应流经代理时,对每一个Set-Cookie头做三件事:
1.解析出name/value和全部属性
2.按RFC 6265的存储算法更新进target_cookies(同名覆盖、Max-Age=0删除)
3.从转发流里移除这个头,不再发给受害者浏览器
注意,这个动作要覆盖登录流程的每一次响应,而不是只处理最终认证成功那一次。前面说了,现代IdP的登录流是多步状态机,每一步都依赖上一步签发的中间Cookie,代理漏存任何一步,下一步请求就会被真站拒绝,流程照样中断。也就是说,从受害者输入邮箱的第一步开始,代理就已经在全量代管了。
到这一步流程能走通了,但还剩最后一个问题:受害者手里没有任何”我已登录”的凭证,他点任何链接都只是”继续请求代理”,代理凭什么知道该用哪套Cookie发给他?而且钓鱼邮件是群发的,并发的不止一个受害者。
解法是代理以自己的域给受害者也发一个Cookie:这个Cookie的域是evil.com,浏览器照单全收。它没有任何业务含义,唯一作用是充当”受害者 ↔ 代理”这段关系的回执编号,代理平台内部维护映射表:_phish=a1b2c3 → sess=7f3a9c… 形成这样的真假域名Cookie之间的键值对。
这就是双Cookie罐,代理自己一个罐(存回执),代管的真站一个罐(存战利品),两边各自按各自域的规则合法写入,浏览器的同源策略全程没有被违反。
这时候,受害者侧流畅地完成”已登录”体验,页面正常、MFA正常、一切信息都正常,但其实受害者浏览器里target.com域的Cookie存储自始至终是空的,一个真Cookie都没传过来。等到登录完成、受害者关掉浏览器后,攻击者从代理导出全套真Cookie,注入自己浏览器的target.com域,然后不经过代理直接访问真站,此时服务器验会话有效,放行。
重放为什么能成功?因为会话Cookie是Bearer语义,就是说服务器只验证”这个值对不对”,从不验证”拿着值的人是不是当初认证成功的那个客户端”。Token本身不编码持有者的任何可证明特征,所以从协议层面成立的重放条件只要拿到值就可以达成。HTTP请求头里面的Secure防窃听、HttpOnly防JS读取,对当前这种攻击模式是不覆盖的,因为值是在传输层被拦截和替换,窃听者和JS都不存在。不过,前面只说了代理”要做什么”(代管Cookie、维持流程),
还没说代理”凭什么知道怎么做”。它至少要回答四个问题:
- 该中继哪些子域?登录流程不是单主机跑完的。微软一次登录要经过login.microsoftonline.com、login.live.com、auth.gfx.ms好几个域,哪些域上的Cookie要代管,哪些域只是发静态资源的、透传就行?不知道这个,代理要么漏掉关键Cookie流程断掉,要么把CDN的Cookie也瞎存一通
- 页面里哪些链接要改写?响应体里所有指向真站的URL必须换成代理域,否则受害者一点”下一步”就跳回真站了,而真站域下没有Cookie,他会看到一个”未登录”的页面,戏当场穿帮
- 该截获哪些字段? POST body流经代理时,哪个字段是用户名、哪个是密码?而且微软的登录是分屏的:第一步提交loginfmt(邮箱),第二步提交passwd(密码),字段名、顺序、分几步,每个站点都不一样
- 怎么知道登录完成了?流程走到哪个URL才算认证结束、可以冻结这套Cookie供攻击者导出?不知道终点,代理不知道何时收工
这四个问题没有一个能靠代理引擎自己猜,答案全部因目标站点而异,所以必须外置成一份配置文件,攻击者拿到一个新的钓鱼目标时按这份配置驱动代理工作。
这份配置就叫phishlet(钓鱼套件,fish + booklet的合成词)。可以这么理解它和代理的关系:代理引擎是通用机器,phishlet是每个目标的”适配规则”,Evilginx引擎本身不知道微软怎么登录,插上微软的phishlet它才会演微软的登录流程。
一份phishlet(YAML格式)大致长这样,四个问题对应四个配置段:
name: example
proxy_hosts: # ← 问题 1:中继范围
- phish_sub: 'login' # 钓鱼侧子域: login.example.com.evil.com
orig_sub: 'login' # 对应真站子域: login.example.com
domain: 'example.com'
session: true # 该主机的 Set-Cookie 需要代管
is_landing: true
- phish_sub: 'cdn'
orig_sub: 'static'
domain: 'example.com'
session: false # 静态资源: 纯透传, 不代管
sub_filters: # ← 问题 2:内容改写规则
- hostname: 'example.com'
sub: 'login'
match: 'example.com' # 响应体中命中的字符串
replace: 'example.com.evil.com'
credentials: # ← 问题 3:凭证提取
username:
key: 'loginfmt' # POST body 中的字段名
search: '(.*)'
type: 'post'
password:
key: 'passwd'
search: '(.*)'
type: 'post'
auth_urls: # ← 问题 4:完成标志
- '/auth/success' # 命中该路径 → 会话标记为可导出
login:
domain: 'example.com'
path: '/login'
配置驱动的运行时行为,每个字段都对得上前文的机制:
- proxy_hosts + session标志:启动时引擎为每个子域向Let’s Encrypt申请证书(ACME HTTP-01挑战自动通过,因为攻击者确实控制着evil.com的DNS)、把子域DNS指向代理服务器,自动化实现”地址栏的锁变为正常”。session: true/false 决定该主机上的Set-Cookie走不走双Cookie存储,上一节说的”逐主机声明”,代价是每条Cookie都要存储并跟随刷新,CDN的Cookie没必要代管
- sub_filters:响应经解压后按正则替换再返回。要注意这一步务必要缜密替换才行,比如目标站点改版新加一个写死的绝对URL就会漏改,受害者点击后直接跳回真站看到”未登录”。因此,在攻击过程中,钓鱼链中断的常见原因不是被检测,而是漏改
- credentials:POST body流经代理时按key匹配字段名,把值存进会话上下文,然后原样继续转发。”提取后不断流”的原因是因为这种中继机制可以让流程走完,最后获取的是认证完成的会话而不只是过程中用户输入的密码。分屏登录配置多条credentials就是为了跨两次POST各截一段
- auth_urls:响应URL命中列表时,引擎判定认证结束,把此刻的Cookie全集打时间戳冻结,这个快照就是攻击者最终导出的产物
既然底层原理是规则配置,那么phishlet不是写好就永远有效的,一旦目标站点改版(加字段、换子域、上bot检测)配置即失效,要有人持续跟进适配。
于是,就这样形成了产业链。社区仓库维护免费phishlet,商业卖家用订阅制卖持续更新的私有配置。
对于防御方来说,如果你发现社区仓库有人维护了和你相关的phishlet,这意味着有人在持续做针对你的适配工程,而这项工程每次开工都要向CA申请你品牌关键词的子域证书,证书透明度日志(crt.sh、certstream)监控包含自家品牌串的新签发域名,可以作为防御方的监控点。这也是”未知攻、焉知防”的真实体现。
PART 03
0x03路径二:Infostealer(T1555/T1539)
AiTM攻击的是登录流程本身,代理、phishlet、MFA全套戏码,都是为了在认证过程中截胡。而路径二所描述的infostealer,它的攻击重点不在登录流程,所觊觎的是受害者在成功完成登录认证后的票据。
顺便解释一下标题里为什么挂两个ATT&CK编号。
- T1555(Credentials from Password Stores)下的子技术T1555.003专门指”从浏览器密码存储窃取”;
- T1539(Steal Web Session Cookie)单独指会话Cookie窃取。
MITRE把这两个技战法分开,是因为两者的防御动作不同。密码泄露的响应是改密码,会话泄露的响应是吊销会话,Infostealer可以同时做到两者。
首先分析一下正常状态下,浏览器把密码和Cookie存在哪。以Windows上的Chrome为例(Edge/Brave/Opera/Vivaldi全系Chromium内核,存储结构同源,路径换一下而已)。三个关键文件:
C:\Users\<user>\AppData\Local\Google\Chrome\User Data\
├── Local State ← JSON,存加密主密钥
└── Default\ ← 第一个 Profile(Profile 1、Profile 2... 同构)
├── Login Data ← SQLite,保存的密码
└── Network\Cookies ← SQLite,全部 Cookie
两个SQLite库的核心表结构:
-- Login Data: logins 表
origin_url TEXT -- 密码对应的站点
username_value TEXT -- 用户名,明文
password_value BLOB -- 密码,密文(encrypted_value)
-- Cookies: cookies 表
host_key TEXT -- Cookie 所属域
name TEXT -- Cookie 名
encrypted_value BLOB -- Cookie 值,密文
expires_utc INTEGER -- 过期时间
is_httponly INTEGER -- HttpOnly 标志(存库时一并记录)
注意两点。第一,passwordvalue和encryptedvalue是BLOB密文,但usernamevalue、hostkey、is_httponly全是明文。这也就是说,谁拿到这个文件,谁就知道你把哪些账号存在了浏览器里,哪怕一时解不开密文。第二,这些就是普通磁盘文件,任何有该目录读权限的进程都能打开。
既然是普通文件,为什么不加密了事,而要搞一整套”主密钥 + 每条加密”的结构?
我们来分析一下Chrome的凭证加密链路,一共分为三层。
第一层:主密钥的保管。Chrome首次运行时生成一把32字节随机主密钥(AES-256密钥),用操作系统的机制加密后写进Local State:
// Local State (节选)
{
"os_crypt": {
"encrypted_key": "RFBBUEl..." // base64,解码后前 5 字节是 "DPAPI",其后是 DPAPI blob
}
}
Windows上这个blob由DPAPI(Data Protection API)的CryptProtectData生成。DPAPI的特性是绑定用户,加密时派生自当前用户的SID和登录凭证,解密时系统自动校验调用者身份,确保同一用户解得开,换一个用户(哪怕是管理员)也解不开,拿到文件离线导走更解不开。macOS上对应的是Keychain里的Chrome Safe Storage条目,密钥再经PBKDF2-HMAC-SHA1(迭代1007次,盐固定saltysalt)派生出AES-128-CBC密钥。
第二层:逐条加密。Login Data和Cookies里每一行的BLOB结构:
"v10" || nonce(12字节) || AES-256-GCM(明文) || tag(16字节)
v10是版本前缀;AES-GCM是认证加密(AEAD),密文被篡改一个比特解密时都会失败。注意Windows用AES-256-GCM,macOS则是AES-128-CBC,所以同一款stealer要分别实现两套解密。
第三层:属性隔离。就是0x02讲过的Secure/HttpOnly,但那一节已经证明过,它们防的是传输层和页面内读取,对”直接读文件”没有任何作用。
总的来说,分为三层防护:
| 防护层 | 防的是什么 | 防不住什么 |
| — | — | — |
| DPAPI/Keychain | 保管主密钥(其他OS用户、离线磁盘盗取) | 同一用户身份运行的进程 |
| AES-GCM | 逐条加密(直接拖文件后的离线暴力解) | 拿到主密钥后的顺序解密 |
| Secure/HttpOnly | 传输窃听、页面JS读取 | 磁盘层读取 |
这个模型有弱点吗?答案是有的。
DPAPI的验证对象是”用户身份”,不是”进程身份”。这样就会导致任何以该用户身份运行的进程调用CryptUnprotectData,系统都会正常返回主密钥,系统无法区分调用者是Chrome还是恶意软件,它们在这个层面是同一个主体。浏览器磁盘加密的信任根是操作系统的用户边界,而恶意软件也在这个边界内。
所以stealer拿钥匙的流程,就是把Chrome的解密流程原样跑一遍:
| 采集目标 | 价值 |
| — | — |
| 浏览器密码 + Cookie | 本文主角 |
| 加密钱包扩展(MetaMask等) | 扩展目录里存的是加密vault,配合键盘记录抓解密密码 |
| Telegram会话(tdata目录整体搬走) | 拿到即已登录态,还能收发消息——包括其他服务发到Telegram的2FA码 |
| Discord token | 直接接管账号 |
| SSH客户端配置 / WinSCP / FileZilla站点记录 | 带密码的连接配置,运维人员机器上是高价值目标 |
| 密码管理器(KeePass等) | 尝试从内存抓主密码 |
| 系统信息 + 截图 + 已保存的RDP凭证 | 给log定价用的”产品说明” |
- 读Local State → base64解码 → 剥掉 “DPAPI” 前缀 → 得到DPAPI blob
- CryptUnprotectData(blob) → 32字节AES主密钥
- 打开Cookies / Login Data → 遍历表 → 每行BLOB剥前缀取nonce/密文
- AES-256-GCM解密(密钥=第2步所得,nonce=行内自带) → 明文Cookie / 密码
四步里没有任何一步在”破解”什么,可以看到每一步都是合法API的正常调用。这就是本条攻击路径最牛的地方,实现过程中并没有任何攻破加密的行为,而是以合法身份使用加密。
安全建设的直觉常放在”加密强度”上(密钥多长、算法多新),而这里的问题出在授权边界。密钥的保护范围是”这台机器上的这个用户”,攻击者就在这个范围里。
上面只是解密环节,完整的stealer(Lumma、Vidar、StealC这类恶意工具)流程如下:
- 枚举浏览器与Profile。遍历各Chromium系浏览器的User Data目录(Chrome/Edge/Brave/Opera/Vivaldi,各家路径不同但结构同源),每个Profile的Login Data/Cookies都抓取下来。很多人工作号私人号分开存,等于一次打包两套身份。当然Firefox也不能放过(密码库是另一套格式,同样有成熟解析器)
- 复制数据库文件。Chrome运行时持有SQLite句柄,但以共享读方式打开,直接复制到临时目录一般都能成功;部分stealer家族为求稳妥先结束浏览器进程再拷,代价是用户会看到浏览器突然关闭
- 解析SQLite。stealer内嵌精简版SQLite读取器,不依赖目标机器装任何数据库组件
- 批量解密(第三步的链路)
- 扩大战果。stealer已经不满足于窃取浏览器密码 + Cookie,一轮扫描采集清单如下:
| 采集目标 | 价值 |
| — | — |
| 浏览器密码 + Cookie | 本文主角 |
| 加密钱包扩展(MetaMask等) | 扩展目录里存的是加密vault,配合键盘记录抓解密密码 |
| Telegram会话(tdata目录整体搬走) | 拿到即已登录态,还能收发消息——包括其他服务发到Telegram的2FA码 |
| Discord token | 直接接管账号 |
| SSH客户端配置 / WinSCP / FileZilla站点记录 | 带密码的连接配置,运维人员机器上是高价值目标 |
| 密码管理器(KeePass等) | 尝试从内存抓主密码 |
| 系统信息 + 截图 + 已保存的RDP凭证 | 给log定价用的”产品说明” |
这套流水线的产出物,在地下市场里有一个专名:log。
因此,攻防双方针对上述机制展开了激烈对抗。
在磁盘层,Chrome 127(2024年中)进行了加固,发布了App-Bound Encryption(v20),主密钥的加解密不再由用户态进程完成,改交一个以SYSTEM权限运行的系统服务,服务会校验调用方身份,从而让用户态恶意进程够不到解密接口。不过公开研究在发布数周内就演示了绕过(对服务的调用校验存在缺陷,可仿冒合法调用方),主流stealer家族在数月内跟进支持v20,Chrome后续版本持续收紧校验。
在内存层,还有更直接的办法:干脆不碰磁盘。Cookie在被浏览器使用时必然在进程内存中可解(否则浏览器自己也没法用),直接读浏览器进程内存就行,OpenProcess + ReadProcessMemory就能在内存里捞明文,绕过一切磁盘加密和文件审计。而对于防御方,这条路的检测特征也非常明确,只要发现哪个进程在读浏览器进程的内存,本身就是高价值信号,因为合法软件极少这么做。
这条路径在实际应用如何呢,看一下log的流转方式:
- 商品形态:log就是窃取的全套产出(密码表、Cookie表、钱包、Telegram、系统信息截图),按条计价,一条几美元
- 检索即服务:Russian Market这类暗网市场支持按域名搜索log,买家输入目标域名,市场返回所有包含该域Cookie的log。这一条把log市场变成了类似于企业初始访问的检索引擎
- 有效性分拣:配套的checker工具批量验证哪些Cookie还存活,活的(能直接重放进账户的)单独定价
- 买家画像:机会型买家掏加密钱包;另一类是初始访问经纪商(IAB),专搜企业域名,买到的就是Snowflake事件里那种”某企业员工的账号钥匙”,转手卖给勒索团伙或数据勒索者
- 感染渠道:伪装破解软件、游戏外挂、恶意广告(搜索引擎投 “xx download” 关键词推假官网)、以及2024年以来增长显著的一类——假AI桌面客户端,搜索”ChatGPT下载””AI助手安装包”的用户是stealer团伙眼里的优质猎物
看一下相关新闻,也能对Infostealer攻击路径的应用广度窥见一二:
- NordVPN审计口径一年约520亿条Cookie被窃,Lumma、RedLine、Vidar三家族占近80%,IBM X-Force 2026年初观测口径约90%
- Healsecurity统计约117万条log同时含活体Cookie
- RedLine基础设施2024年10月被多国联合行动取缔
- Lumma 2025年被Microsoft联合执法打击后数周内以新基础设施回归
针对这条攻击路径,能在两方面下工夫:
- 端点侧,检查”进程读浏览器内存/浏览器数据库”,合法软件极少这么干,误报率很低;
- 企业侧,定期在log市场查自家域名的凭证流通情况,查到一条就按一次已发生的泄露处理,进行吊销会话、强制改密码、排查设备保存了哪些系统的凭证,都需要统一更换。
PART 04
0x04路径三:MFA疲劳与注册劫持(T1621/T1098)
路径一和路径二分别展示了两种思路:AiTM是”攻击者在认证流程中当场截胡”,infostealer是“进程读浏览器内存/浏览器数据库”,并且实现了”钥匙早就在货架上,供行业直接买”这样的产业。
那路径三的实现是怎样的呢?
-
T1621(MFA Request Generation,MFA请求生成)是MITRE在2022年底Uber事件后新增的技术项,指的是攻击者持有有效用户名密码后,反复触发MFA挑战制造压力场景;
-
T1098(Account Manipulation,账户操纵)指的是在这个攻击路径中攻击者不会去瞄准认证流程动心思,而是直接改账户的MFA配置本身(增删认证器、改恢复方式)。
我们先了解一下MFA的工作机制是怎么样的。以Duo(Uber当年用的就是Duo)为例:
- 用户在登录页输入用户名 + 密码
- IdP验证密码通过 → 生成一次MFA挑战(challenge_id、目标设备(绑定手机上的Duo App)、过期时间(默认60秒))
- IdP向APNs/FCM推送:”Uber请求登录确认”
- 用户手机收到通知 → 点开 → 看到三个选项:批准 / 拒绝 / 拨打IT帮助台
- 用户点”批准” → App签名该challenge_id → 回传IdP
- IdP核销该挑战 → 签发会话
Duo本身的校验链路是严密的,加密、推送、批准过程环环相扣,且能确保用户本人能够切实参与其中。但是我们又常说,人是最大的漏洞,第4步的”批准”动作,本质是用户对”这次登录是我发起的”做出判断,这个判断依赖用户能正确理解通知的语境。因此在使用者层面有无问题就因人而异了。
但是Duo本身有两个小风险:
弱点一:批准动作的成本不对称。拒绝要解锁手机、打开App、看清楚内容、点拒绝;批准只要点通知上的一个键,手机不解锁都行(很多App的通知横幅上直接挂批准按钮)。设计上”确认”本应比”放行”更慎重,而Duo为了用户的使用体验,在实际交互上却让实现效果正好相反。
弱点二:挑战无频率限制的副作用。挑战的过期时间60秒,但没有任何东西阻止攻击者马上再发起一次登录。每一次登录尝试意味着一次新的挑战,就会产生一次新的推送。也许很多人认为频率限制保护的对象只是服务器资源,而事实上,用户的注意力也可以成为”拒绝服务攻击”play的一环(划重点昂)。
前面也说了,这条攻击路径也因为Uber 2022年被攻击才被业界关注,有必要好好分析一下当年的事件。
因为MITRE中T1621攻击战法就出自这个事件,我们从这个战法的依据事件,就可以比较清晰的了解,也是这条路径最完整的公开样本:
- 凭证来源:攻击者(Lapsus$ 成员,18岁,英国籍)从暗网购买了一名Uber外包承包商的企业账号密码,当然凭证本身极可能是路径二的产物(infostealer log市场流通)
- MFA在位:该账号受Duo push保护。攻击者持密码登录 → 密码正确 → 触发Duo推送
- 疲劳攻击:攻击者反复发起登录,承包商手机上累计响起100+ 次推送
- 社工收尾:攻击者直接通过WhatsApp联系承包商本人,冒充Uber IT:”这些通知是Uber安保的测试,点批准它们就会停”
- 批准:承包商点了批准。认证链顺利闭环:密码对(买的)、推送批准(本人点的)、会话签发
- 横向移动:进入内网后,攻击者在网络共享上的PowerShell历史文件里找到了管理员级PAM凭证(还有意外收获!)
- 全面失守:以PAM凭证横移,AWS、GCP、Slack(宣称已控制)、HackerOne(宣称可访问漏洞报告)尽数沦陷
整场攻击里,Duo本身一直没有被突破,100+次推送本身没有成功,攻击者是通过社会工程学进行辅助,让受害者降低了警惕性,让客户在”烦死了”的状态下,被引导建立了”这是测试,点了就清净”的心理。
总结来说,MFA疲劳在这次事件中凭借超多推送制造心理压力 + 诱导话术给受害者压力出口,出色完成了攻击。
但是Uber官方为什么没有进行告警,分析如下:
| 时间线步骤 | 事件性质 | 传统检测视角 |
| — | — | — |
| 密码验证通过 | 合法凭证(虽是购买所得) | 无失败记录,暴力破解规则不触发 |
| 100+ 次MFA挑战 | 每次 = 一次密码正确的登录尝试 | 挑战不算失败也不算成功,日志显示”进行中” |
| 用户批准 | 真实用户真实设备真实批准 | 每条规则全绿 |
| 会话签发 | 合法会话 | 正常签发记录 |
| 内网行为 | 已认证用户操作 | 与合法管理员行为无异(直至出现数据外传) |
其实100+次挑战这个行为本身就非常可疑,如果是建立告警逻辑应该直接命中的:大量ResultType 50074/50076(MFA challenged)之后跟着一次ResultType 0(成功),MFA疲劳模式的检测逻辑应该不难建立,只是当时这个攻击模式在发起之前从来没人知道,没有对应规则。
再看MFA注册劫持,也是这次事件中非常出彩的环节。攻击者注意到,认证的强度取决于”MFA绑定在谁的设备”,而MFA注册与绑定的管控逻辑相比认证环节要薄弱的多得多得多得多。
因此,出现了这个攻击场景:攻击者通过路径一/二/三获得一个已认证会话(哪怕是短暂的),他不着急进行利用,而是先“留个后门”:
- 攻击者用窃取的会话访问账号的安全设置页
- 发起”添加新认证器” → 站点发起注册仪式
- 关键问题:站点此刻要求什么验证?
- 新认证器注册成功 → 此后攻击者持有账号的合法强因子
第3步的实现中,如果参考修改密码需要先输入旧密码,问题不大,但如果换绑MFA的时候,也仅仅校验密码,并不去验证MFA,就是用”知识因子”给”持有因子”的注册做担保,就非常荒谬了。要知道,此时密码刚被偷走,拿密码当注册的关卡等于没设关卡。
分析一下实测数据(arXiv 2308.02973,十家主流网站):4家在已登录状态下添加新FIDO2认证器不需要任何额外认证(仅凭现有会话),5家只需重输密码,也就是说当时没有任何一家的MFA厂商要求已注册的认证器参与。
此时,只要能拿到成功会话的人,就能给自己重新注册MFA。配完之后,攻击者直接是账号的合法持有人。
然后按这个思路走下去,比注册新认证器更彻底的动作是接管恢复选项,比如改备用邮箱、改绑定手机号。
那么,应该怎么应对路径三呢?
一是,号码匹配是成本最低的一档。Duo、Microsoft Authenticator都已支持,用户批准前要输入登录页面显示的数字,通知横幅上直接点批准的路被堵死,但支持不等于开启,租户里需要做一遍配置才生效。改完之后,Uber那种”手机不解锁就能批准”的路径直接消失。
二是,频率限制和静默期。在IdP侧设单个用户短窗口内的MFA挑战阈值(如5次/30分钟),超限锁定挑战、通知用户、吊销活动会话,Uber事件后这成了各家IdP的推荐基线。有讲究的是阈值松紧:定松了挡不住洪泛,定严了可能被反过来当DoS武器使,比如攻击者拿一个账号反复触发,把人锁在门外,达到拒绝服务的效果。对应的检测规则(KQL,微软生态的日志查询语言,30分钟窗口):
SigninLogs
| where TimeGenerated > ago(30m)
| where ResultType in (50074, 50076)
| summarize challenges = count(),
succeeded = countif(ResultType == 0)
by UserPrincipalName
| where challenges >= 5 and succeeded >= 1
// 响应: 吊销全部会话 + 静默期 + 强制重新注册认证器
另外,注册与恢复流程也需要逻辑严密。新认证器注册要求已注册认证器确认(passkey-to-passkey步进),恢复选项修改视为高敏操作、要求步进认证加管理员审批、恢复流程纳入红队测试范围,单做一件都会在别处开新口子。
上篇已经结束啦,中下篇我正在编辑,敬请期待。
东方隐侠成果输出计划,焕新出发!https://eastsword.github.io/
长按识别图中二维码
东方隐侠团队微信
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:东方隐侠安全团队 千里
千里《登录认证安全(上)MFA能确保登录一定安全?未必》