Skip to content

复核 81fe1107:四条修复里三条的判据有牙,shutdown_ 那两处的 UAF 没有判据 #25

Closed

Description

@sercebr

在 Windows/MinGW(UVCPP_BUILD_EXPAND=ON 全量树,master 548b84d)上独立复核了 81fe1107。
做法不是读代码,而是把每条修复分别改回缺陷、重跑用例,看它红不红。

先说结论

变异 改回什么 读数 判定
M1 thread_local_cache::push() 的 return false → true 红在 memory_pool_test.cpp:310:SUPER 块释放后额度必须还回来 —— 第二次分配被拒说明块被丢弃了 有效 ✓
M2 push_to_global_pool() 的 default: 里撤掉「先取 size」 Segmentation fault(0 PASS,探针连打印都没来得及 —— 死在读 freed 块头) 有效(靠崩)✓
M3 shutdown_ 那两条分支里撤掉「先取 size」 23/23 全绿 无判据 ✗
M4 static_release_callback 还原 ::operator delete 那条分支 0xC0000374 STATUS_HEAP_CORRUPTION 有效 ✓,「堆损坏」逐字复现

M2 与 M4 逐字证实了你提交信息里写的两个现象。M1 的判据也是真的。
M3 那两处没有判据,下面单独说。

M3:探针证明分支走到了,但用例看不见

不是"分支没跑到"——我在那条分支里加了打印,test_pool_release_cache_after_shutdown 跑了多次都进了:

[M3PROBE] 走了 shutdown_ 非SUPER 分支: free 前=96 free 后读到=96 (还是原值)

两点原因,任一条都够:

  1. 那条用例用的是默认 memory_pool_config,max_total_memory == 0,而
    release_bytes() 第一句就是 if (config_.max_total_memory == 0) return; —— 传什么值进去都没人看。
  2. 更直接的是:这次读到的值就是对的(96,未被覆写),所以即便有上限,
    release_bytes(96) 也和修复后的行为一模一样 —— 我在这个分配器上量不到"值错"。

也就是说:第 2 条那三处里,只有 default: 那处会崩,shutdown_ 那两处是纯形式 UB
(消掉是对的,只是它的"错"在我这套装置上不可观测)。你写"每一条都先用失败用例或实测崩溃证出来"
——对第 1、3 条成立,对第 2 条里的 default: 成立,对 shutdown_ 那两处不成立,
你当时的证据应该来自页面堆(那台机器上我这里没有,所以我复现不出「必崩」,只能复现"读到的值没变")。

要不要一条判据? 值不当的回读不可靠(同一个 block->size 在 free 后可能被堆的连接指针覆写、
也可能原样留着,取决于尺寸与 CRT),所以它不适合当断言的对象。我的建议是二选一:

  • 在 test_pool_release_cache_after_shutdown 的注释里写明它证的是第 3 条(释放器配对),
    别让后来的人以为它同时覆盖了第 2 条;或者
  • 那条用例改用非零 max_total_memory,至少让 release_bytes() 不是空调用(虽然仍不保证能红)。

我不主张把那两行删掉:shutdown_ == true 时额度确实可能非零(shutdown() 之后
allocate() 并不被拒,它走 allocate_block() 拿新内存并重新记账),所以那两行是防御性的,
只是当前装置看不到差别。

顺带两条装置上的坑(不是仓的缺陷,但会骗人)

  1. 单目标构建不刷新测试目录的 DLL。 我头一轮的 M2/M3 读数是废的:敲的是
    cmake --build . --target test_memory_pool,它只重链了根目录的 libuvcpp.dll,
    而运行时加载的是 tests/expand/libuvcpp.dll 那份旧副本 —— 变异根本没生效,"全绿"什么都没说明。
    是全量 cmake --build . 才会跑 copy_test_dlls(它 DEPENDS ${_dll_deps},规则本身是对的)。
    M1 侥幸有效是因为它改的是头里的 inline 代码,测试 exe 自己重编了。
    判据:改完先确认变异真的进了运行时那份二进制(我是打印探针确认的)。
  2. UVCPP_USE_SYSTEM_LIBUV 默认 ON,配置时会挑 MSYS2 那份 libuv.dll.a,
    于是跑 ctest 前得把 mingw64/bin 放进 PATH,否则全表以 0xc0000135 在 0.01 秒内退出。

另外

master 548b84d 上这套配置:构建 rc=0、ctest 43/43、test_memory_pool 23/23(含你新加的两条)。

Activity

  1. Antruly commented on Sep 21, 2026

    @Antruly
    Owner

    谢谢做这套变异复核 —— 逐条对着源码核了一遍,下面按你的四条分别回。

    机制 #1 逐字属实

    release_bytes() 的第一句就是 if (config_.max_total_memory == 0) return;
    (src/expand/uvcpp_memory_pool.cpp:319),而那条用例用的是默认 config。你说的完全对。

    「shutdown_ 那两处没有判据」—— 对,而且比你说到的更彻底

    比「用例看不见」还要空一层:shutdown_ 分支里 SUPER 那半边全仓零覆盖。

    • 旧用例 test_pool_release_cache_after_shutdown 用的是 64 字节 TINY 块,只走非 SUPER 那一支;
    • 而 SUPER 块根本不进线程缓存(thread_local_cache::push() 对 idx >= 7 返回 false,
      块当场交给 push_to_global_pool()),所以它只能由「shutdown() 之后直接 deallocate()
      一个大块」走到 —— 全仓此前没有任何用例走过这条路。

    我按你的建议 (a) 在旧用例的注释里写明了它证的是释放器配对、不是那处 UAF,(b) 没有采纳
    (改用非零 max_total_memory 只是让 release_bytes() 不是空调用,仍然不产生判据,还可能把
    那条用例原本证的东西搅浑)。代替它的是新加一条用例,见下。

    「纯形式 UB」这一点我不同意 —— 在这台机器上是硬崩

    你那边读到「值仍是 96」,我这边也一样;但读数不可观测不等于没有判据,它是分配器相关的。
    MSVC + 全量页堆下它是可观测的,而且很干脆。实测(build-h2,shutdown_ 两处一起还原成
    release_bytes(block->size) 写在 free 之后):

    装置 读数
    修复后(含新用例) 裸跑 10/10 绿;页面堆绿
    M3 变异体 + 新用例 裸跑 10/10 崩(rc=139);页面堆抓到 0xC0000005
    M3 变异体、把新用例摘掉 20 次里崩 11 次(约一半)

    cdb 把崩点分别定位到 push_to_global_pool+0x7e(SUPER 支)与 +0x3b(非 SUPER 支),
    调用点分别在 test_memory_pool+0x2a48 与 +0x265d —— 两条分支各崩各的,模型自洽:
    旧的先崩掉约 6/10,剩下的 4/10 全被新的兜住,合起来 10/10。

    新用例就是你建议的那条路的补集,shutdown() 之后直接放一个 512 KiB 的块:

    static void test_pool_super_release_after_shutdown()
    {
        const size_t kSize = 512 * 1024;            // > 256 KiB ⇒ SUPER
        uvcpp::uvcpp_memory_pool pool;
        pool.init();
        pool.init_thread_cache();
    
        void* p = pool.allocate(kSize);
        TEST_ASSERT(p != nullptr, "前置:SUPER 块必须分配成功");
        std::memset(p, 0x5A, kSize);
    
        pool.shutdown();
        pool.deallocate(p);                         // shutdown_ 为真 ⇒ SUPER 那一支
    
        TEST_PASS();
    }

    大块比小块更容易把这条读变成硬崩:512 KiB free 之后 CRT 往往真的把页还给系统,同一个读当场
    变成访问违例,不像 64 字节块那样约一半的次数能蒙混过去。

    你指出的那条,我认

    81fe110 的提交信息写「每一条都先用失败用例或实测崩溃证出来才动代码」。对第 1、3 条成立,
    对第 2 条里的 default: 成立,对 shutdown_ 那两处不成立 —— 那条提交记录的验收写的是
    「页面堆门禁 94/94 绿、抓 0 个」,那是修复后的绿读数,不是对着变异体跑出来的对照读数。
    判据必须有对照组,这处我当时没做。已经在新用例的注释里把这段写清楚了。

    两条装置坑

    第 1 条(单目标构建不刷新测试目录那份 DLL)我这边也踩过,结论一样:copy_test_dlls 是
    ALL 目标,只有默认全量构建才会刷新那四份副本,--target xxx 不会 —— 判据要拿
    PE .text 段的哈希比,不能只看产物存在。第 2 条(MSYS2 PATH)记下了,谢谢。

    修复在 64fbe08(1.2.11-dev)。

  2. Antruly commented on Oct 18, 2026

    @Antruly
    Owner

    这条可以关了 —— M3 缺判据那一格已经补上。

    • 用例:tests/expand/memory_pool_test.cpp:375 test_pool_super_release_after_shutdown()(64fbe08)。它走的就是你指出的那半边:init() → allocate(512 KiB)(> 256 KiB ⇒ SUPER)→ shutdown() → deallocate(),于是进 shutdown_ 为真时 SUPER 那一支;上面那条老用例只有 64 字节 TINY 块,够不到这里。
    • 为什么用大块:512 KiB 的块 free 之后 CRT 往往真的把页还给系统,release_bytes(block->size) 写在 free 之后那一读当场就是访问违例;64 字节块约有一半次数能蒙混过去(你那次 23/23 全绿就是这个原因)。
    • 注册名是 test_memory_pool(ctest -R test_memory_pool),本机现在绿。按你原来的做法把 M3 改回去,这条应当红 —— 如果你还想复核一次,现在有判据可用了。

    谢谢这套变异复核:M3 那两处此前确实全仓零覆盖,光读代码是看不出来的。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions