JFS jfs_readdir UTF-8 expansion OOB-write: five years public, never merged
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:
| NLS | UTF-16 source | UTF-8 output per char |
|---|---|---|
iso8859-1 (default) | any BMP | 1 byte (no expansion) |
utf8 | U+0001..U+007F | 1 byte (ASCII) |
utf8 | U+0080..U+07FF | 2 bytes |
utf8 | U+0800..U+FFFF (e.g. CJK) | 3 bytes |
utf8 | via NLS_MAX_CHARSET_SIZE | up 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:
- target byte 0x01..0x7F → L=1 (ASCII char)
- target byte 0xC2..0xDF → L=2 (2-byte UTF-8 leader)
- target byte 0xE0..0xEF → L=3 (3-byte UTF-8 leader)
- 0x80..0xBF at decision point: feasible only as the continuation byte of a 2- or 3-byte sequence chosen at a prior position
- unconstrained byte: look ahead for forced continuations at B+1 / B+2; otherwise pick L from distance to next target
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:
- producible: 0x01..0x7F (ASCII), 0xC2..0xDF + 0x80..0xBF (2-byte BMP-low), 0xE0..0xEF + 2× 0x80..0xBF (3-byte BMP-high incl. CJK).
- not producible: 0x00, 0xC0, 0xC1, 0xF0..0xFF.
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:
- USB auto-mount via udisks2 on the default desktop distros (Ubuntu, Fedora, Debian). udisks2 calls a polkit-active method that mounts an attacker-supplied filesystem with attacker-chosen options for the unprivileged user. as of Resolute 26.04 LTS the polkit policy allows this for
activesessions, with JFS among the supported types. - Cloud / multi-tenant hosts that loop-mount customer JFS images to process, scan, or index them.
- Container build hosts that mount images inside a builder context and then run readdir-class operations over them.
- PXE / imaging rigs that auto-mount filesystems off attached drives during diagnostics.
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 strategy | adjacent slab class | KASAN |
|---|---|---|
none (just ls) | names_cache (256-byte) | yes; OOB into freed slot |
fork() × ~1024 | PTE leaf pages | no KASAN; BUG: Bad page map flood, panic in lookup_swap_cgroup_id |
clone(CLONE_VM) × 512 | maple_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 / kernel | status |
|---|---|
| Ubuntu Resolute 7.0.0-15 | vulnerable (live verified) |
| Arch Linux 7.0.3.arch1 | vulnerable (source verified) |
| Mainline 7.1-rc1 | vulnerable (source verified) |
downloads
- craft_jfs_poc.py: v1 crafter, single-entry KASAN trigger.
- craft_byte_oob.py: byte-controlled crafter with the greedy UTF-8 alignment solver. CLI:
--bytes "67 70 ?? ?? ?? ?? ?? 80". - kasan-output.txt: the full KASAN slab-use-after-free report from the Resolute lab.
jfs_probekernel module: the diagnostic module i built to read slab adjacency during weaponization. CAP_SYS_ADMIN-gated. companion post, full source.
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