For example, the HotSpot JVM implement null-pointer detection by catching SIGSEGV signal. So if we manually generate a SIGSEGV from external, will that also be recognized as NullPointerException
in some circumstances ?
Summary
Yes, in some marginal cases an external kill
command may cause a bogus NullPointerException
in a Java application. This behavior is platform-dependent and difficult to reproduce, however, I managed to trigger this in practice.
Background
HotSpot JVM employs a technique called "implicit null check", where the JVM compiles an access to an object field which offset is less than a page size (4096) to a single load/store instruction without extra overhead for checking the object reference for null
. If such an instruction is executed for null
reference, the OS raises SIGSEGV
. The JVM's signal handler catches this signal and transfers control to the code that throws NullPointerException
.
Not every SIGSEGV
ends up with a NPE. HotSpot signal handler checks that
- the current thread is a Java thread;
- SIGSEGV occurs in a JIT-compiled code;
- the address being accessed is within zero page (0x0 - 0xfff);
- the fault instruction is marked as "implicit exception" and there is an exception handler assigned to this instruction.
In theory, if we craft a signal that satisfies all the conditions, HotSpot will treat it as NPE.
Practice
To increase chances of a user signal hitting the right instruction, we'll write an infinite loop that repeatedly stores to an object field. To prevent hoisting of the null check, the reference itself should be loaded from a volatile field.
public class BogusNPE {
static volatile BogusNPE X = new BogusNPE();
int n;
public static void main(String[] args) {
while (true) {
BogusNPE x0 = X, x1 = X, x2 = X, x3 = X, x4 = X, x5 = X, x6 = X, x7 = X, x8 = X, x9 = X;
x0.n = x1.n = x2.n = x3.n = x4.n = x5.n = x6.n = x7.n = x8.n = x9.n = 0;
}
}
}
Here I generated 10 stores in a row, all with an implicit null check.
Use -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
to verify that the corresponding mov
instructions are annotated with implicit exception
:
0x00007fb4a4bd440c: mov 0x70(%r10),%edx ;*getstatic X {reexecute=0 rethrow=0 return_oop=0}
; - BogusNPE::main@32 (line 8)
0x00007fb4a4bd4410: mov 0x70(%r10),%ebp ;*getstatic X {reexecute=0 rethrow=0 return_oop=0}
; - BogusNPE::main@37 (line 8)
0x00007fb4a4bd4414: mov 0x70(%r10),%eax ;*getstatic X {reexecute=0 rethrow=0 return_oop=0}
; - BogusNPE::main@42 (line 8)
0x00007fb4a4bd4418: mov %r12d,0xc(%r12,%rax,8) ; implicit exception: dispatches to 0x00007fb4a4bd4456
;*putfield n {reexecute=0 rethrow=0 return_oop=0}
; - BogusNPE::main@66 (line 9)
0x00007fb4a4bd441d: mov %r12d,0xc(%r12,%rbp,8) ; implicit exception: dispatches to 0x00007fb4a4bd4468
;*putfield n {reexecute=0 rethrow=0 return_oop=0}
; - BogusNPE::main@70 (line 9)
0x00007fb4a4bd4422: mov %r12d,0xc(%r12,%rdx,8) ; implicit exception: dispatches to 0x00007fb4a4bd447c
;*putfield n {reexecute=0 rethrow=0 return_oop=0}
; - BogusNPE::main@74 (line 9)
Run the program and get its PID:
$ jps
256 BogusNPE
280 Jps
Here pid=256, but we should send the signal not to a process, but to the particular thread. ID of the main thread is usually pid+1, that is 257.
$ sudo kill -11 257
It may take several attempts before we finally achieve the goal:
Exception in thread "main" java.lang.NullPointerException: Cannot assign field "n" because "x5" is null
at BogusNPE.main(BogusNPE.java:9)
Nuances
On x86 platform, I could trigger NPE without sudo
, but on 64-bit platforms sudo
is important. Also, it's substantial that PID of the shell where we run kill
is less than 4096. And that is why.
HotSpot checks that the fault address siginfo->si_addr
is located in zero page (otherwise load/store instruction requires an explicit null check). However, si_addr
is set only when SIGSEGV is raised by kernel, we cannot control it with kill
command. For user-generated signals, si_pid
(sending process ID) and si_uid
(user ID of sending process) are set instead.
By a lucky chance, siginfo_t
structure contains a union, where si_addr
overlaps with si_pid
and si_uid
.
63 31 0
+-----------------+
| si_addr |
+-----------------+
| si_uid | si_pid |
+-----------------+
So, to produce si_addr
value between 0 and 4096, we need to make si_uid = 0
(that is, invoke kill by user 0 or root
), and set si_pid < 4096
. On 32-bit systems, si_addr
overlaps with si_pid
only.
If the signal misses mov
instruction with an implicit null check, or if si_addr
is larger than the page size, the JVM will crash with a fatal error instead of throwing NPE.
Can JVM detect the source of SIGSEGV?
It is certainly possible to distinguish user-generated SIGSEGV from a signal caused by invalid memory access. The signal handler could just check si_code
field of siginfo_t structure:
- for a real NullPointerException,
si_code
will beSEGV_MAPERR
; - for a signal sent by
kill
,tgkill
orsigqueue
, the code will beSI_USER
,SI_TKILL
orSI_QUEUE
respectively.
However, current HotSpot implementation does not do that, and therefore it is possible to fool the JVM using the above trick.
© 2022 - 2024 — McMap. All rights reserved.
NullPointerException
on the 3rd attempt, +1 – Hueyhuff