Don't String Me Along
May 1, 2003
Gary Little

Copyright © 2003 by Gary Little. All rights reserved

True confession --- this has been one of the more difficult tasks I have undertaken. Any of you that have ever read any of the articles I have posted on various newsgroups will probably find that amazing since, depending on your view point, I either wax eloquently on a subject, or else I overly state the case for why idiots should not be allowed near a keyboard. When Walter proposed I produce an article for safe string handling in the kernel using the NTSTRSAFE.H header file that is now found in the DDK, I said "Piece of cake". I believe he can now attest that this has been more like a chicken attempting to lay a square egg. There is a lot of noise but not many eggs.

I always assumed that handling strings was a fairly trivial task. Strings were created using some kind of formatting function like sprintf or hard-coded and you simply moved them here or there, using the functions provided in your language of choice, and you were done with them. If there was a bug in string handling, that ugly critter would be found and fixed fairly quickly. Problems with strings would result in files not found, or garbage in a file, or garbage on a monitor, or garbage in a window. The bug was then fixed fairly quickly and mostly up front when it was encountered. Very rarely was code delivered to customers with faulty strings.

When I first began writing NT drivers I quickly learned that dealing with strings in the kernel was anything but trivial, and if you happen to be in the DriverEntry of a SCSI miniport, almost impossible. Not only were there no standard C library functions, but there were also UNICODE strings and ANSI strings and very few functions available to manipulate them. Formatting functions like sprintf were totally non existent. There were a few RtlXxx functions in the kernel, but my projects always seemed to require something that could not be done easily with the tools available. Eventually, many of us learned, the system kernel contained nearly the full complement of ANSI-standard string handling functions, in both character and wide-character versions. So, many of us dusted off our copies of Kernighan & Ritchie and started freely copying the sort of code we'd been used to using in our undergraduate programming projects. Only now our bugs crashed the system or, worse yet, allowed crackers to gain access where they should have been shut out. Suddenly, null terminated strings became as welcome as a rooster on the breakfast table.

In the latest DDK, Microsoft included a new set of "safe" string functions functions that avoid the problems that null-terminated strings can cause. I'll describe those functions in this article. First, I'll tell you about the syntax and usage of these new functions. Then I'll show you how they solve various reliability and security problems in the kernel.

The Safe-String functions

The safe string functions are delivered in the form of a header file, and a library, both using the name NTSTRSAFE. The header file contains the prototypes for the library. Setting the NTSTRSAFE_LIB switch before including the header file will compile the safe-string functions as callable functions. If that constant is not defined, then the functions will be compiled inline.1 If they're inline, you'll be able to run your driver all the way back to Windows 98 Gold without worrying about missing imports.

The functions included in NTSTRSAFE provide equivalents for nearly all of the standard C library functions:

The functions whose names end with the letter "A" work with single-byte character strings, whereas the functions whose names end in the letter "W" work with wide (UNICODE) strings. In user-mode programming, you might include TCHAR.H and use functions like tcscpy, which compiles into either strcpy or wcscpy based on the setting of the UNICODE compile switch. With the safe string functions, however, you must specify wide (W) or narrow (A) characters explicitly.

The "Cch" forms of these functions have a length in characters, whereas the "Cb" forms have a length in bytes. Let's try a couple of examples to see how this naming convention works:

WCHAR dest[14];
RtlStringCchCopyNW(dest, L"Hello, world!", sizeof(dest)/sizeof(WCHAR));
RtlStringCbCopyNW(dest, L"Hello, world!", sizeof(dest));

Both of these function calls are equivalent: The first function copies a specified number of characters into dest, while the second copies a specified number of bytes.

One of the nice features of the new safe string functions is that they all return a standard NTSTATUS value. Unlike the standard C library functions such as strcpy, The new functions give immediate notification of success or failure by returning one of the following status codes:

STATUS_SUCCESS
Everything was copied from the source to the destination pointer without incident, and the destination string was zero terminated.
STATUS_INVALID_PARAMETER
This code implies that one or more of the input parameters were rejected. This status is returned when the destination length exceeds the constant STRSAFE_CHAR_MAX or when a destination length of zero was specified.
STATUS_BUFFER_OVERFLOW
The copy operation failed due to insufficient space in the destination buffer to contain the zero terminated string in the source buffer. When this warning occurs, the destination buffer is modified and truncated, where truncation is permitted, with the ideal result. Note that STATUS_BUFFER_OVERFLOW fails the NT_SUCCESS test but is considered a warning rather than an error. This matters if you're failing a METHOD_BUFFERED IOCTL with this code, for example: the I/O Manager will copy IoStatus.Information number of bytes back to the caller's output buffer.

I want to encourage you to use of the new safe string functions. Since NTSTRSAFE.H is self contained and provides complete function definitions and prototyping, it can be used in all Windows platforms running WDM drivers. The functions may even be included and used in NT4. The functions can be used at any IRQL as long as both memory blocks are resident. Otherwise, of course, they may only be used at less than DISPATCH_LEVEL. The header file provides replacements for the standard C library functions like strcpy, and it provides advantages over even normal kernel mode functions such as RtlCopyBytes.

Strung out on strcpy

Look at this simple example of copying a string using strcpy:

#define STRING "This is a test string."
char buf[MAX_SIZE];
strcpy(buf, STRING);

Let's assume that MAX_SIZE is some gigantic integer expressing the limit of good taste for string data, which I'm sure is bigger than 23. We can tell by inspecting the code that we won't overflow buf when we copy 22 bytes plus a null terminator. But how about this example:

VOID MyUnsafeCopyFunction(PCHAR String)
  {
  char buf[MAX_SIZE];
  strcpy(buf, String);
  }

(Bear with me here. A function that just copied a string into a local variable and then returned would be pretty useless. Just imagine that these are the first two lines of a thousand-line subroutine that successfully predicts the price of egg futures -- and then send me the code you've imagined so I can try it out.) Plainly, if String is longer than MAX_SIZE, we are headed for trouble when we overwrite past the end of buf. So how about this variant:

VOID MyUnsafeCopyFunction(PCHAR String)
  {
  char buf [MAX_SIZE];
  if (strlen(String) < sizeof(buf))
    strcpy(buf, String);
  }

This looks safe, because we can't overwrite the buffer with a string that's too long to fit. But think about what strlen is doing -- something like the following:

int strlen(char* s)
  {
  int i;
  while (s[i])
    ++i;
  return i;
  }

Put the argument strings near the end of a virtual memory page and forget to append a null terminator. That's a recipe for a page fault. Do it in kernel code, and we're talking blue screen and a fallen souffle.

Now consider using a safe string function to replace both strcpy and strlen.

NTSTATUS MySafeCopyFunction(PCHAR String)
  {
  char buf[MAX_SIZE];
  NTSTATUS status;
  status = RtlStringCbCopyA(buf, sizeof(buf), String);
  if (NT_SUCCESS(status))
    return status;
  . . .
  }

The first thing to note is that RtlStringCbCopyA will not overwrite the target buffer. In addition, it provides an immediate and logically testable status value. Errors can be diagnosed and handled either by testing the status code inline or, in the right kind of driver, by raising an exception. This function can, however, still walk off the end of a page looking for a null terminator. You'll remember from kernel programming 101 that you can avoid a blue screen when you're dealing with user-mode memory so long as you guard your memory access with a structured exception frame:

NTSTATUS status
__try
  {
  ProbeForRead(String, sizeof(buf), 1);
  status = RtlStringCbCopyA(buf, sizeof(buf), String);
  }
__except(EXCEPTION_EXECUTE_HANDLER)
  {
  status = GetExceptionCode();
  }
if (!NT_SUCCESS(status))
  <handle error>

The ProbeForRead call makes sure that at least sizeof(buf) bytes beginning at String lie entirely within the user-mode portion of the address space. If not, ProbeForRead raises an exception that will be caught by the exception handler. The subsequent call to RtlStringCbCopyA will expand into code equivalent to the following:

size_t cchDest = sizeof(buf);
char* pszSrc = String;
char* pszDest = buf;
while (cchDest && (*pszSrc != '\0')
  {
  *pszDest++ = *pszSrc++;
  cchDest--;
  }

We can be sure that pszSrc is strictly less than the address boundary between kernel and user mode if the loop variable cchDest is nonzero. Consequently, the worst thing that can happen to our driver is that dereferencing pszSrc will cause a trappable exception, which is the moral equivalent of Eggs Benedict compared to the situation with strcpy alone.

But what if the String value used in MySafeCopyFunction had pointed to kernel-mode memory? As you know, ProbeForRead would definitely fail when given that pointer, and a fault due to dereferencing a bad kernel pointer would yield a blue screen without possibility of parole. Well, we just have to trust any kernel-mode provider of a null-terminated string to really have put a null terminator at the end of the string. Better yet, we could define our interface to use an ANSI_STRING or UNICODE_STRING value to avoid the whole issue of where that pesky null terminator will appear.

The Strange Case of strncpy

Consider this charming little subroutine:

VOID MyStillUnsafeCopyFunction(PCHAR String)
  {
  char buf[MAX_SIZE];
  strncpy(buf, String, sizeof(buf));
  }

Now what is wrong with this code? Buf cannot be overrun, because we specified its maximum length. But there are two little-known gotchas with strncpy. If the source string is shorter than the size of buf, then the unused portion of buf is padded with zeroes. That's pleasant -- imagine reserving a 4 MB buffer, copying a 1-character string into it with this function, and then watching your disk drive go crazy fetching pages while the remaining 3.999998 MB get filled with zeroes. If the source string is longer than the size of buf, then strncpy won't add a null terminator added to the destination buffer. The next guy who tries to strlen the target buffer will tip toe through the tulips into places they wish they had never gone.

For contrast, here is the same routine using the safe string equivalent.

VOID MyInputString(PCHAR String)
  {
  char buf[MAX_SIZE];
  NTSTATUS status;
  int srcLength;
  status = RtlStringCchLengthA(String, MAX_SIZE/sizeof(char), &srcLength);
  if (NT_SUCCESS(status)) 
    status = RtlStringCchCopyNA(buf, sizeof(buf)/sizeof(char), String, srcLength);
  return status;
  }

The location and existence of the zero terminator in String must still be known, resulting in an attempt to find it. However, RtlStringCchLengthA is used to search for the zero terminator but it is delimited and will not go beyond MAX_SIZE. There will be no attempt to gallop through all of memory looking for an elusive zero terminator. RtlStringCchCopyNA is doubly delimited by both destination length and a source length. The destination string will always be zero terminated, unless its length is zero, and the destination buffer will not be padded with additional zeros if it is larger than the source string.

The formatting functions

Lest we forget, how about those formatting functions? Anyone that has attempted to build a device name from an enumerated value under NT4 can readily appreciate these functions. Remember using RtlIntegerToUnicodeString followed by RtlAppendUnicodeStringToString (or was that RtlAppendUnicodeToString, I had more trouble telling them apart than the Doublemint Twins)? Here's the modern way:

MyAddDevice(PDRIVER_OBJECT DriverObject, PDEVICE_OBJECT pdo)
  {
  WCHAR buf[MAX_SIZE];
  NTSTATUS status = STATUS_SUCCESS;
  static LONG ndevices = -1;
  LONG idevice = InterlockedIncrement(&ndevices);
  status = RtlStringCbVPrintfW(buf, sizeof(buf), L"\\Device\\MYDEVICE%d", idevice);
  . . .
  return status;
  }

The use of a returned status code gives immediate feedback about the success or failure of the format. The destination buffer is again governed by a length parameter, preventing an improperly terminated source string from causing buffer overrun problems. The destination string is also zero terminated.

Easter Eggs

Continuing our ovum analogy, there are several Fabergé functions that are "decorated" with the ending "Ex". These functions include addtional parameters that permit finer control. For example, the prototype for the RtlStringCchCopyExA  function looks like this:

NTSTRSAFEDDI RtlStringCchCopyExA(char* pszDest, size_t cchDest, const char* pszSrc, char** ppszDestEnd, size_t* pcchRemaining, unsigned long dwFlags);

This function has three more parameters than does RtlStringCchCopyA. The first additional paramenter, ppszDestEnd, points to a location where the function will store the address of  the zero terminating character in the final destination string. The second, pcchRemaining, points to a variable that the function will set to equal the number of bytes remaining in the final destination. Finally, the third extra parameter, dwFlags, specifies option flags to control the way the copy happens. The first two parameters will provide useful information for setting up the arguments to additional copy functions when you're manipulating complex strings. Since pszDestEnd points to the end of the destination string, and since pcchRemaining specifies the amount of destination buffer remaining, you can pass these two arguments as the pszDest and cchDest parameters in the next function call. 

The dwFlags argument contains several fields, laid out as in this hexadecimal template: 0000xxbb, where 0 is unused at this time, xx is a logical-or of several copy flags, and bb is a fill byte. These are the copy flags for the Ex functions:

STRSAFE_IGNORE_NULLS = 0x00000100
Treat null string pointers as empty strings. Without this flag, a NULL string pointer argument will cause a page fault, just like usual in kernel programming.
 
STRSAFE_FILL_BEHIND_NULL
Fill in the extra spaces behind the zero terminator with the bb fill byte.
 
STRSAFE_FILL_ON_FAILURE = 0x00000200
On failure, overwrite pszDest with the specified fill pattern and null terminate the buffer.
 
STRSAFE_NULL_ON_FAILURE = 0x00000400
On failure, set *pszDest = TEXT('\0'). That's an empty string for those of you who are still out there on the White House lawn.
 
STRSAFE_NO_TRUNCATION = 0x00000800
Instead of returning a truncated result, copy or append nothing to pszDest, and then zero terminate it.
 
STRSAFE_VALID_FLAGS
Formed by combinations of the flags in the following syntax:
(0x000000FF | STRSAFE_IGNORE_NULLS | STRSAFE_FILL_BEHIND_NULL | STRSAFE_FILL_ON_FAILURE | STRSAFE_NULL_ON_FAILURE | STRSAFE_NO_TRUNCATION) 

To help you construct a dwFlags parameter that includes a fill byte and the flag option to force its use, you can use these two macros:

STRSAFE_FILL_BYTE
Set the fill character and specify how to fill the buffer. Defined as:
((unsigned long)((x & 0x000000FF) | STRSAFE_FILL_BEHIND_NULL))
 
STRSAFE_FAILURE_BYTE
Set the fill character on failure, and specify how to fill the buffer. Defined as:
((unsigned long)((x & 0x000000FF) | STRSAFE_FILL_ON_FAILURE))
 

A quick example illustrates the usage of one of the Ex functions.

VOID MyInputStrings(PCHAR String, PCHAR String2)
  {
  char buf[MAX_SIZE];
  char *nextBuf;   
  NTSTATUS status;
  int srcLength, remaining;
  status = RtlStringCchLengthA(String, MAX_SIZE/sizeof(char), &srcLength);
  if (NT_SUCCESS(status))
    {
    status = RtlStringCchCopyExA(buf, sizeof(buf)/sizeof(char), &nextBuf, &remaining, STRSAFE_FAILURE_BYTE(0x0FF));
    if (NT_SUCCESS(status))
      { 
      status = RtlStringCchLengthA(String2, MAX_SIZE/sizeof(char), &srcLength);
      if (NT_SUCCESS(status))
        {
        status = RtlStringCchCopyNA(nextBuf, remaining/sizeof(char), String2, srcLength);
        } 
      }
    }

  return status;
  }

All the good things about safe strings still apply. Each function returns a status that you can use to decide whether to execute the next step. Each function uses an explicit length to protect its destination buffer. Each function sets some output variables that then become input to the next function in the series. When we're all done, if one of the functions failed, the destination buffer will contain FF's.

Kernel mode exploits

I had an epiphany while working on this article. I now realize that safely dealing with strings is no longer simply keeping your program from crashing, but at the same time preventing a mishandled string from allowing malicious abuse. In today’s networked and connected environment, strings can easily be viewed as a weakness or doorway that can be exploited and reduce your perfectly performing program to a pathetic piece of putrescence that is riddled with rat holes and gratuitous alliteration. Highly recommended reading on this subject is Michael Howard and David LeBlanc's Writing Secure Code (Microsoft Press 2002), and the online documentation for Visual Studio .NET.

First, what is meant by an exploit? Webster’s defines an exploit as "to make productive use of" or "to make use of meanly or unjustly for one's own advantage". A code exploit then refers to finding a weakness in otherwise functional code and then using that weakness as a starting point to either gain control or cause the program or system to malfunction. The question then becomes, how can a simple string allow a cracker to gain entry to an otherwise stable system?

Take a look at the prototype for a standard runtime function:

char *strcpy(char *destination, const char *source);

Simply stated, this means "copy the string pointed to by source to the string pointed to by destination, returning a pointer to a string". Since the return value, by the definition of the function, is a pointer to the destination, nothing tells the engineer that the string was copied successfully. The other significant problem is that the parameters to the functions do not include any kind of limiting or governing factors. The source string is going to be copied into the destination buffer until a terminating zero is found in the source string. Success of the function is then dependent on adequate memory being allocated for the destination pointer and the source string having a zero terminator before the destination's memory is consumed. That sets the scenario for what is called a "static buffer overrun" exploit.

Suppose we define a simple program in the following manner:

void MyFunction(const char* input)
  {
  char buf[16];
  strcpy(buf,input);
  printf("%s\n", input);
  }

void crack(void)
  {
  printf("You have been cracked!\n");
  }

void main(int argc, char* argv[]))
  {
  MyFunction(argv[1]);
  return;
  }

This example is based on an article I found in the documentation for Visual Studio .Net titled "Static Buffer Overruns." From that article I learned that repeated invocations of the program with varying input strings can lead a cracker to eventually overflow buf and replace the return address on the stack with the entry point for crack. At that point, the cracker has basically gained control of the program. Were this to happen in a device driver as a result of a user program using DeviceIoControl, the cracker would then be in kernel mode.

To see how this can happen in the kernel consider the following code fragments. Please allow some license here. The intent is to show how it can be done, not to imply that it has, or that a cracker would want to go through all the pain and hassle to accomplish the task. The idea is to illustrate how a kernel exploit might take place.

Let's suppose my driver supports a single METHOD_NEITHER control operation. My dispatch routine might read like this:

NTSTATUS DispatchControl(PDEVICE_OBJECT fdo, PIRP Irp)
  {
  char buf[16];
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PCHAR inString = (PCHAR) stack->Parameters.DeviceIoControl.Type3InputBuffer;
  strcpy(buf, inString);
  Irp->IoStatus.Status = STATUS_SUCCESS;
  IoCompleteRequest(Irp, IO_NO_INCREMENT);
  return STATUS_SUCCESS;
  }
I'm not worried about this, because I'm the only one who'll ever write an application that issues DeviceIoControl calls to my driver, right? And if I screw up during development by passing too long a string, the resulting blue screen will only affect my development machine, right? I mean, I'll fix the bug before releasing the driver, and it will be no problem, mon.

You can't think that way any more. If I can crash the system, a cracker can take over the system. Let's suppose a certain Snidely W., an unprivileged user on the corporate network, learned about my driver and it's naive behavior when handed an IOCTL. Snidely simply needs to load his cracker program into user-mode virtual memory in such a way that some address within that program is composed of entirely non-zero bytes. Now he proceeds to call my driver like this:

#define MYIOCTL CTL_CODE(FILE_DEVICE_UNKNOWN, 0, METHOD_NEITHER, FILE_ANY_ACCESS);
BYTE junk[25] = "ABCDEFGHIJKLMNOPQRST\0\0\0\0";
*(PDWORD)(junk + 20) = (PDWORD) crack;
DWORD morejunk;
DeviceIoControl(hMyDriver, MYIOCTL, junk, sizeof(junk), NULL, 0, &morejunk, NULL);

Since the parameters to DeviceIoControl appear valid on their face, the I/O Manager sends me an IRP_MJ_DEVICE_CONTROL. My dispatch routine merrily copies 16 bytes of string data into buf, 4 bytes of additional string data into the stack location containing my caller's stack frame pointer, and the address of the user-mode crack routine into the stack location containing the return address. When my routine returns, it transfers control to Snidely's crack routine in kernel mode. The sequel is left to the reader's imagination.

Note that, if Snidely W. has administrative privileges on my machine, he can just write his own kernel-mode driver and be in charge lickety-split. The DDK itself is his exploit in this case, as it were. And, if I tried to get WHQL certification for my driver, the device path verifier would probably find this hole and flunk me.

Safe String functions help prevent exploits

The safe-string functions force the destination pointer to be delimited by providing a destination buffer length. This length is used to truncate the copy if the zero terminator in the source string is not encountered. If the destination buffer is too small, it is terminated with a zero in the last legal position, and the function returns the STATUS_BUFFER_OVERFLOW warning. If length is zero, then the destination is set to a zero terminator and the warning status is again returned, but the return address on the stack is not altered.

Let's rewrite DispatchControl using safe-string functions:

NTSTATUS DispatchControl(PDEVICE_OBJECT fdo, PIRP Irp)
  {
  char buf[16];
  NTSTATUS status = STATUS_SUCCESS;
  PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
  PCHAR inString = (PCHAR) stack->Parameters.DeviceIoControl.Type3InputBuffer;
  if (Irp->RequestorMode != KernelMode)
    {
    __try
      {
      ProbeForRead(inString, sizeof(buf), 1);
     
status = RtlStringCbCopyA(buf, sizeof(buf), inString);
      }
    __except(EXCEPTION_EXECUTE_HANDLER)
      {
      status = GetExceptionCode();
      }
    }
  else
    status = RtlStringCbCopyA(buf, sizeof(buf), inString);
  Irp->IoStatus.Status = status;
  IoCompleteRequest (Irp, IO_NO_INCREMENT);
  return status;
  }

Note the positive feedback returned by the safe-string function. The success or failure of the string manipulation may be immediately determined by the contents of the returned value, and the IRP then completed appropriately. The standard C library functions or the normal kernel string functions do not provide this level of feedback. However, though that may provide testable logic to determine the completion of the IRP, the exploit has not been prevented. Most important in preventing an exploit is that buf, given an appropriate size, cannot be overrun since the in coming string is always delimited by the length of buf. Since the destination length is the size of the memory allocated on the stack for the buffer, the copy function, by definition, will return before the stack is overridden. Had the engineer been less than careful and used a destination length such as 256 … well … certain evil punishments should be left to the imagination.

Summary

The new safe string functions help protect you from the problems that can occur when you use null-terminated strings in a kernel-mode driver. One class of problems, exemplified by strcpy, involves overstoring a destination buffer. Another class of problems, exemplified by strlen, involves running off the end of valid memory looking for a null terminator. The safe string functions will protect you from the first class of problems altogether by guaranteeing you against buffer overruns. They will help you with the second class of functions by (a) allowing you to specify a maximum extent within which to look for a null terminator in a source string, and (b) making sure to append a null terminator to all destination strings.

About the author:

Gary Little has over 29 years of practical software engineering. With a double major in Psychology and Religion, his philosophy is that if he can't fix it, he can counsel it, and if all else he can always pray for it.


1 -- (Ed note added 6/20/03) Define the symbol if you want your driver to work on systems prior to XP. In this case, you also need to follow the instructions in the header file to add the static library to your SOURCES files. If your driver only needs to run in XP and later systems, leave the symbol undefined. You'll get inline function definitions with calls to some kernel routines that are exported by XP and later kernels.