_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

JFS jfs_readdir UTF-8 expansion OOB-write: five years public, never merged

2026-05-02 · linuxkerneljfsfilesystemoob

after the NTFS3 dir-emit read i went looking for the same shape elsewhere in the tree: bound checks that treat UTF-16 code-unit counts as byte counts. JFS turned up fast. fs/jfs/jfs_dtree.c has exactly that confusion at :2916. the check reads d->namlen, which is an on-disk UTF-16 code-unit count, and treats it as a byte count. the write that follows runs through jfs_strfromUCS_le() → nls_table->uni2char(), and there each UTF-16 code unit can fan out to as many as 6 bytes of UTF-8 (3 in practice for BMP CJK codepoints under the utf8 NLS). so the check waves through an entry whose real write blows past the __get_free_page(GFP_KERNEL) buffer at :2890 by hundreds of bytes, into the adjacent buddy page.

heap OOB write. the bytes come from the on-disk image and so does the magnitude, both attacker-chosen. you trigger it with a hostile JFS image mounted iocharset=utf8, then a getdents() over the crafted directory. any normal ls will do it. what caught me off guard is that Pali Rohár had already posted the right fix in 2021 and it still wasn't merged.

i'm not the one who found this

on 2021-08-08 Pali Rohár posted [RFC,15/20] jfs: Fix buffer overflow in jfs_strfromUCS_le() to linux-fsdevel. his patch adds a maxlen parameter to jfs_strfromUCS_le, computes maxlen = PAGE_SIZE - sizeof(struct jfs_dirent) - (name_ptr - jfs_dirent->name), guards the inner loop with outlen < maxlen-1, and hands maxlen-1-outlen to uni2char in place of NLS_MAX_CHARSET_SIZE. it is the correct fix.

it was never merged. i checked upstream master while writing this: jfs_strfromUCS_le still carries the 4-arg signature, and the 2916 bound check still treats d->namlen as bytes. five years out in the open, still live on every distro that ships JFS.

what i'm adding is the receipt that the bug is still alive, plus a working byte-controlled crafter, on current Resolute 26.04 LTS production kernels and on mainline 7.1-rc1. cite this if you need a reference. cite Pali first.

root cause

/* fs/jfs/jfs_dtree.c:2916 */
if (((long) jfs_dirent + d->namlen + 1) > (dirent_buf + PAGE_SIZE)) {
    overflow = 1; break;
}
/* d->namlen is u8 = on-disk UTF-16 code-unit count */

the check reads namlen as bytes. the write at :2967 and the chained slot-copy at :2987 put down namlen × 1..6 bytes (3 per char in practice under utf8 with BMP CJK source). nothing re-checks the bound between chained-slot iterations (:2972..:2992). the lone sanity guard, d_namleft == 0, catches a malformed chain and not a byte-overflow.

the math behind the overflow

the buffer is __get_free_page(GFP_KERNEL) at jfs_dtree.c:2890. jfs_readdir appends each directory entry into that page as sizeof(struct jfs_dirent) + expanded_name_bytes. struct jfs_dirent is 14 bytes, so an entry's chain bytes begin at dirent_buf + jfs_dirent_offset + 14.

the check at :2916 reserves jfs_dirent_offset + d->namlen + 1 bytes, where d->namlen is the on-disk UTF-16 code-unit count, capped at 255. the slot-chain copy at :2987 then writes the output of jfs_strfromUCS_le() through the NLS table:

NLSUTF-16 sourceUTF-8 output per char
iso8859-1 (default)any BMP1 byte (no expansion)
utf8U+0001..U+007F1 byte (ASCII)
utf8U+0080..U+07FF2 bytes
utf8U+0800..U+FFFF (e.g. CJK)3 bytes
utf8via NLS_MAX_CHARSET_SIZEup to 6 bytes (cap)

under iocharset=utf8 with BMP CJK source chars (the U+4E00 range), each of the 255 UTF-16 chars blows up to 3 bytes. the check sees an entry that wants 256 reserved bytes and lets it through. the actual write is ~765 bytes. that leaves a ~509-byte overrun sitting in the adjacent buddy page.

and again, nothing re-checks the bound between chained-slot iterations (:2972..:2992). the d_namleft == 0 guard at :2978 only catches a malformed chain, not a byte-overflow.

steering the write to a chosen offset

a single entry at jfs_dirent_offset = 0 overflows the page by ~509 bytes. that's enough to answer "does it fire" and nothing more. to pick where in the adjacent page the write lands, you pack the dirent buffer with earlier entries to shove jfs_dirent_offset up near PAGE_SIZE, then drop the malicious entry in last.

two layouts i validated in the lab:

# v1 crafter (broke via fix_page → kernel hang on small filler indices):
#   4 big entries  (namlen=255, head + 17 chain slots,  784 bytes each)
#  21 small filler (namlen=4,   head only,               32 bytes each)
#   1 final entry  (namlen=255, head + 17 chain,        780 bytes)
#  → final jfs_dirent_offset = 3808; bound check passes (3808+256=4064 < 4096)
#  → final write extends to 4587, 491 bytes OOB.

# v2 crafter (KASAN-confirmed, valid index assignments):
#   3 big fillers  (namlen=255, head + 17 chain)
#  11 medium       (namlen=27,  2-slot chain)
#   1 final big    (namlen=255, head + 17 chain)
#  → final jfs_dirent_offset = 3408; bound check passes (3408+256=3664 < 4096)
#  → final write ends at 4187, 91 bytes OOB.

crafter source is craft_jfs_oob.py (v2) and craft_jfs_poc.py (v1). both rewrite the leaf dtpage in place: header preserved, slot table (stbl) at slot[1..4], entries at slot[5..127]. at the 4 KB block size the page holds 128 slots, and the v2 entry layout fits inside 116 of them.

OOB[0] = 674 from the page start, which falls out of 4096 - 3408 - 14 = 674 (page size, minus jfs_dirent_offset, minus sizeof(struct jfs_dirent)). earlier handoff docs said 688. that's off by 14, one struct jfs_dirent. i re-derived it by running the crafter and reading the real OOB site back out of KASAN.

byte-controlled crafter

the default crafter just writes whatever CJK bytes happen to land there. that's enough to fire KASAN, not enough to weaponize. i spent a few evenings on the byte-controlled crafter (craft_byte_oob.py, ~430 lines): a greedy left-to-right walker over the 255-char malicious chain. at each char-start position B it looks at targets[B], targets[B+1], targets[B+2] and the distance to the next constrained offset, then settles on a char width L ∈ {1, 2, 3} bytes:

CLI:

python3 craft_byte_oob.py --in base.img --out out.img \
    --bytes "67 70 ?? ?? ?? ?? ?? 80"

here 67, 70, and 80 are forced output bytes at OOB offsets 0, 1, and 7, and ?? are don't-cares. the solver hunts for an alignment-feasible 255-char chain that emits those bytes once the NLS expands it. validated end-to-end: 13/13 unit tests pass, and image byte_test1.img built from the spec 67 70 EA A4 81 EA A4 82 lands a KASAN-confirmed OOB carrying the exact byte sequence asked for.

byte-control limits

not every byte is reachable. the alphabet of the OOB write is whatever UTF-8 can come out of BMP-source UCS-2:

the high half of any 4-byte UTF-8 sequence is out of reach because we can't feed it surrogate-pair UCS-2 input. and 0x00 is gone because jfs_strfromUCS_le stops the output dead on a NUL anywhere in the chain.

verification (KASAN, Resolute lab)

same KASAN lab kernel as the NTFS3 work - 7.0.0-kasan-kcov #15.15.kasan (Resolute 7.0.0-15 sources built with KASAN + KCOV). triggered with ls /mnt/poc from uid 1000:

BUG: KASAN: slab-use-after-free in utf32_to_utf8+0x13c/0x180
Write of size 1 at addr ffff888111b7f000 by task ls/1823
CPU: 1 UID: 1000 PID: 1823 Comm: ls Not tainted 7.0.0-kasan-kcov #15.15.kasan PREEMPT(lazy)
Call Trace:
 utf32_to_utf8+0x13c/0x180
 uni2char+0x38/0x80 [nls_utf8]
 jfs_strfromUCS_le+0xd9/0x230 [jfs]
 jfs_readdir+0x110c/0x2360 [jfs]
 iterate_dir
 __x64_sys_getdents64
The buggy address belongs to the object at ffff888111b7f000
which belongs to the cache names_cache of size 192

the production run (no KASAN, on 7.0.0-15-generic) throws a flood of BUG: Bad page map in process ls pte:8683e58583e58483 warnings. the PTE bytes in there are the literal CJK UTF-8 bytes the crafter wrote OOB. it ends in Kernel panic - not syncing: Attempted to kill init! after lookup_swap_cgroup_id+0x44. so the bug fires on production-config kernels too, not only KASAN builds.

reproducer

craft_byte_oob.py is the byte-controlled crafter. it builds a 64KB JFS image whose malicious directory entry's UTF-16 chain decodes to a byte sequence you choose in the OOB region. craft_jfs_poc.py is the simpler one and just builds a baseline image that fires the KASAN bug.

# build a JFS image, patch in the malicious dirent, mount, list
sudo apt install jfsutils
dd if=/dev/zero of=/tmp/base.img bs=1M count=16
mkfs.jfs -q /tmp/base.img
python3 craft_jfs_poc.py --in /tmp/base.img --out /tmp/oob.img
sudo mkdir -p /mnt/poc
sudo mount -o loop,iocharset=utf8 /tmp/oob.img /mnt/poc
ls /mnt/poc
# → KASAN report on KASAN build
# → "Bad page map" panic on production

the iocharset=utf8 mount option is load-bearing. under the default iocharset=iso8859-1 nothing expands, each UTF-16 unit maps to at most 1 byte of output, and the bound check happens to be right. the bug sits dormant until someone mounts with utf8.

reach

fs/jfs/super.c never sets FS_USERNS_MOUNT, so mounting a JFS image needs init_user_ns CAP_SYS_ADMIN. that's the gate. the delivery vectors that clear it without handing the attacker a sudo:

the mount-privilege gate is real. it is not a wall.

the LPE paths i tried, and where they died

the OOB write is real. turning it into LPE is a different problem, and most of the obvious paths fell over against current Resolute. i'm publishing the walk so nobody has to redo the dead ends i already hit.

what the adjacent page actually contains

__get_free_page(GFP_KERNEL) pulls from the UNMOVABLE migratetype freelist, so the neighbor in the buddy partition is another UNMOVABLE page. that means slab pages, kernel stacks (with CONFIG_VMAP_STACK=y the stack is vmalloc'd, so you see this one less often), page tables, fdtable. it is not msg_msg / pipe_buffer / xattr. those are GFP_KERNEL_ACCOUNT, they live in kmalloc-cg-N, and the buddy files them under MOVABLE.

three landings i saw on the Resolute lab kernel:

spray strategyadjacent slab classKASAN
none (just ls)names_cache (256-byte)yes; OOB into freed slot
fork() × ~1024PTE leaf pagesno KASAN; BUG: Bad page map flood, panic in lookup_swap_cgroup_id
clone(CLONE_VM) × 512maple_node (256-byte)yes; KASAN quarantine corrupted; kernel-panic on next openat

cred-jar (cap_effective at offset 64)

on Resolute, cred is 184 bytes, SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT, which merges it into :A-0000192: a 192-byte slot, 21 per page. cap_effective sits at offset 64 inside cred. every UTF-8-producible byte at OOB offset 64 has bit 7 set (continuation 0x80..0xBF, or leader 0xC2..0xEF), so CAP_SETUID (bit 7) ends up set in the corrupted cred. that process calls setuid(0) and it's root.

the catch is landing. with the default CJK chain, the bytes at OOB offset 64..71 only hit cred->cap_effective if the adjacent page happens to be a cred slab page. fork() spam turns it into PTE leaves (fork allocates page tables for the new mm), not cred. getting a cred spray needs a different approach: drain the UNMOVABLE pcp freelist before triggering, then make sure the next UNMOVABLE alloc after dirent_buf is a cred-jar page. i didn't finish that grooming work in the lab, and i'm not going to pretend i did.

dirty-pagetable (Kuzey CVE-2024-0582 pattern). INFEASIBLE

i searched the UTF-8-encodable PFN space exhaustively. zero PFNs in [1, 1M] are encodable for any byte-7 value in 0x01..0x80. the proof:

PTE = NX_bit | (PFN << 12) | flags
For NX=1, byte 7 = 0x80 (continuation), byte 6 must be a UTF-8 leader.

Pattern A: byte 6 ∈ 0xC2..0xDF (2-byte leader).
  → PFN bits 36..43 in that range
  → PFN ≥ 0xC2 × 2^36 = 833 billion
  → 3.3 TB physical address. unreachable.

Pattern B: byte 5 ∈ 0xE0..0xEF (3-byte leader).
  → PFN ≥ 240 GB. unreachable on most systems.

Drop NX (byte 7 ∈ 0x01..0x7F): bytes 1..6 of PFN must each be non-zero.
  → PFN ≥ 2^36 → 256 TB physical. unreachable on any RAM system.

on any normal-RAM system (≤ 256 TB), no PFN is UTF-8-encodable as a complete PTE. dirty-pagetable is closed off through this primitive.

maple_node KASAN-quarantine corruption

the clone(CLONE_VM) spray put us next to a freed maple_node slot. KASAN quarantine walks freed slot pointers on the next slab op, so an attacker-controlled list_head in a freed maple_node would open a path to a KASLR-defeated arbitrary kernel write. the bytes the crafter produces right now are non-canonical and just oops the kernel. with byte-level control they could form valid kernel pointers. the math works. the chain isn't built yet.

so: cap_effective overwrite is the cleanest LPE path on paper, dirty-pagetable is shut by the alphabet limits, and what's left is spray engineering to get cred-jar adjacency reliably. the bug itself is not in question.

suggested fix

Pali Rohár's 2021 RFC patch (link) is the right shape. add a maxlen parameter to jfs_strfromUCS_le, set maxlen = PAGE_SIZE - sizeof(struct jfs_dirent) - (name_ptr - jfs_dirent->name) in jfs_readdir, guard the inner loop with outlen < maxlen-1, and pass maxlen-1-outlen to uni2char instead of NLS_MAX_CHARSET_SIZE. the patch already exists. somebody just has to merge it.

cross-distro applicability

distro / kernelstatus
Ubuntu Resolute 7.0.0-15vulnerable (live verified)
Arch Linux 7.0.3.arch1vulnerable (source verified)
Mainline 7.1-rc1vulnerable (source verified)

downloads

build the image yourself:

sudo apt install jfsutils
dd if=/dev/zero of=/tmp/base.img bs=1M count=16
mkfs.jfs -q /tmp/base.img
python3 craft_jfs_poc.py --in /tmp/base.img --out /tmp/oob.img
sudo mount -o loop,iocharset=utf8 /tmp/oob.img /mnt/poc
ls /mnt/poc

SHA-256:

cbcf3996bbf040e56e004e3096835063bb688b00fcb458e8daf31471ccbe49d3  craft_jfs_poc.py
5fb67ab1a5e423e4aa83e613784be1cfce83eeb0f2611b21a2bd02649fda9d61  craft_byte_oob.py
0da0f7270fa79d41cbfc8d1ecc28ccb11dd8c7a1a4a14ec347267e1ddfd14fcf  kasan-output.txt

credit

Pali Rohár. original 2021-08-08 disclosure and patch.

_SiCk <sick@afflicted.sh> (https://afflicted.sh). verification still-exploitable on current kernels, byte-controlled crafter, production-kernel panic confirmation.

no warranty. no support. no apologies.

. _SiCk · afflicted.sh