Dr. Dobb's is part of the Informa Tech Division of Informa PLC

This site is operated by a business or businesses owned by Informa PLC and all copyright resides with them. Informa PLC's registered office is 5 Howick Place, London SW1P 1WG. Registered in England and Wales. Number 8860726.


Channels ▼
RSS

A Serial Port Spy for NT


October 1999/A Serial Port Spy for NT


Under NT, serial ports are exclusive devices, and only one application can have a port open at a time. That’s normally what you’d want, but there are many cases when it would be handy to be able to spy on the serial communications of another application. For example, you might be trying to debug your own high-level driver that uses the serial port to communicate with a particular device. Or, you might be trying to uncover the serial interface used by some commercial hardware (UPS, GPS, etc.) so that you can write your own custom software to manipulate the device instead.

Although NT normally prevents two applications from both accessing the same serial port, it’s still possible to write a serial-port monitor that spies on the communications of another application. This article will provide the necessary device-driver support, as well as a simple application for displaying the resulting serial-port data.

Intercepting I/O Calls

The NT device driver serial.sys handles COM ports. During initialization, it enumerates all available COM ports and creates device objects for each one it can find. Device objects are created under the \Device subtree of the NT Object Manager’s namespace. The driver gives them names like Serialx, where x is the number of the port (1, 2, 3, etc.). To let user-mode code access the ports, serial.sys also creates symbolic links under the \?? subtree called COM1, COM2, etc. The driver does not let user-mode code open these devices multiple times.

There are two basic approaches for spying on the serial-port data of a particular application. The first is to create a DLL with your own wrapper functions for CreateFile(), WriteFile(), SetCommState(), etc. and somehow force the application to call your wrapper functions instead. You could then inject the DLL into the application’s address space (as described in Jeffrey Richter’s Advanced Windows) and modify its import table to point to your wrapper functions. The wrapper functions would then call the original Win32 functions, but also communicate via some kind of interprocess communication with a monitor application, which would display the spied-on output. The second approach is to write a filter device driver that attaches itself to a serial device, processes all requests it deals with, and transfers them to a user-mode application that provides the user interface.

I can’t advise you which way is best because both have good and bad sides. The first method works under both Win95 and NT, runs in user-mode only, and doesn’t require administrative privileges, but it is a little harder to implement. The second one is more “honest” because it uses only documented features, is easier to implement, and is much more interesting. This article uses the filter-driver approach.

Driver Source Code

The driver source code spans several files, and this month’s code archive contains complete source code for both the device driver and the user-mode monitor application.

The driver’s initialization and dispatch code resides in sermon.h (Listing 1) and sermon.cpp (Listing 2). drvclass.h and drvclass.cpp (available in the code archive) contain helper code to implement memory management and doubly linked lists. devext.h (Listing 3) and devext.cpp (Listing 4) contain the main classes that implement the device driver. sermon.rc (in this month’s code archive)contains version information for the driver. sermonex.h (in the code archive) contains structures used by both the driver and application (using conditional compilation).

Initialization

Driver initialization starts in DriverEntry() in sermon.cpp (Listing 2). It first uses CreateDevices() to try to create its own device object named \Device\SerMon, as well as the symbolic link \??\SerMon. The symbolic link is required to let user-mode code access the device object. If something fails, DriverEntry() returns a status other than STATUS_SUCCESS to the I/O manager. As soon as DriverEntry() creates the main device object, it fills the I/O manager’s supplied array of major function handlers situated in the DRIVER_OBJECT structure. The driver has to handle all types of requests because it is going to receive all the requests that can be directed at a serial-port device driver.

The driver implements two types of devices (one filters requests to the serial driver and the other provides an interface to a user-mode monitoring application), so I used C++ inheritance to capture the commonality between the two implementations. devext.h (Listing 3) and devext.cpp (Listing 4) define a parent class (CDevice) and two derived classes (CAttachedDevice and CSERMONDevice). This gave me an opportunity to implement basic IRP processing functions as macros.

sermon.h (Listing 1) contains two macros used to declare and define IRP functions that DriverEntry() stores in the driver object’s major function table:

#define DECLARE_FUNCTION(x) extern "C" NTSTATUS \
SERMON##x (IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp);
#define IMPLEMENT_FUNCTION(x) NTSTATUS \
SERMON##x (IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp) \
    { \
    return ((CDevice *) (DeviceObject->DeviceExtension))->x(Irp); \
    }

The functions that these macros generate expect that the DeviceExtension field of the device object contains a pointer to a CDevice-derived object, and they use that pointer to invoke the appropriate virtual function. This is similar to techniques for mapping window messages onto C++ class virtual functions. For each request type, CDevice supplies a default implementation that simply completes the request and returns a non-error status code, STATUS_SUCCESS.

Finally, DriverEntry() constructs an empty linked list that will eventually contain pointers to created attached devices. DriverEntry() then returns STATUS_SUCCESS.

Communicating with the Driver

The SerMon device object exists to allow user-mode code to communicate with the filter driver. It implements four special I/O control requests, which user-mode code can access via Win32’s DeviceIoControl(). An application opens the SerMon device by calling CreateFile():

hDevice=CreateFile(_T("\\\\.\\SerMon"),
    GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE,
    FILE_SHARE_WRITE | FILE_SHARE_READ,
    NULL,
    OPEN_EXISTING,
    FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED,
    NULL);

CreateFile() returns a handle the application can then pass to DeviceIoControl(), along with a command code, pointers to input and output buffers, and so on:

BOOL res=DeviceIoControl(hDevice,
    IOCTL_SERMON_STARTMONITOR,
    (PVOID) (LPCTSTR) s,
    (s.GetLength()+1)*sizeof(WCHAR),
    &handle,sizeof(MHANDLE),
    &dw,
    NULL);

When the user-mode code calls DeviceIoControl(), control eventually transfers to CSERMONDevice::IoControl() in devext.cpp (Listing 3). This function examines the command code to decide how to handle the request. The four command codes the device handles are defined in sermonex.h.

IOCTL_SERMON_STARTMONITOR. To begin monitoring a device, pass this command code to DeviceIoControl(). The input parameter is a Unicode string identifying the device object the driver should attach to. It can be either in the form of \Device\Serialx or \??\COMx, where x is the port number. The device must not be already opened before you execute this attach operation, since it will fail with a STATUS_ACCESS_VIOLATION status code.

The output from this command is an MHANDLE, which is internally a pointer to a newly created C++ object of type CAttachedDevice. The calling application must supply this handle in future calls to DeviceIoControl() to identify the monitoring session.

IOCTL_SERMON_STOPMONITOR. Pass this code to DeviceIoControl() to end a monitoring session. You must supply the MHANDLE for the session that was returned with the IOCTL_SERMON_STARTMONITOR command.

IOCTL_SERMON_GETINFOSIZE. SerMon produces a stream of variable-length event records that the calling application can interpret and display. Pass the IOCTL_SERMON_GETINFOSIZE command (along with the MHANDLE for the session) to obtain the size of the next event in the queue. It returns a DWORD that indicates the size in bytes of the next event.

IOCTL_SERMON_GETINFO. Once you know the size of the next event in the queue, you can use the IOCTL_SERMON_GETINFO command to copy the event data to a user-mode buffer. Pass the MHANDLE for the session along with a buffer that is big enough to handle the size of the next event from the queue.

Attaching to the Serial Device

When the application passes an IOCTL_SERMON_STARTMONITOR to DeviceIoControl(), it eventually results in a call to CSERMONDevice::IoControl() in devext.cpp (Listing 4). This function passes the name of the destination device to CSERMONDevice::TryConnectToSerialDevice(), which in turn calls the global function Attach().

Attach() first tries to find and open the destination device with a call to IoGetDeviceObjectPointer(), which returns a pointer to a device object and its associated file object. I don’t need the file object, so I dereference it at the bottom of the function by calling ObDereferenceObject(). Then Attach() creates an unnamed device object and stores in its device extension field a pointer to a newly created CAttachedDevice object. Attach() then locks the CAttachedDevice object, since it is not yet ready to receive driver requests.

Next, Attach() calls IoAttachDeviceByPointer() to attach the created device to the destination device. The I/O manager fills all required fields in the device’s DEVICE_OBJECT structure, making it capable of handling and forwarding other devices’ requests. For simplicity, the device object is marked to do buffered I/O. Luckily, the serial device driver itself and most other similar drivers use this type of I/O.

If all the above operations were successful, Attach() unlocks the CAttachedDevice object making it ready to receive requests and returns a pointer to the CAttachedDevice object it created. This pointer will be the MHANDLE (typedef’ed as DWORD) that is returned to the calling application. To be able to distinguish between correct handles and junk, the driver does a little error checking by trying to read memory at a given address and comparing its contents with signature. This procedure doesn’t guarantee that the given handle really is a pointer to a class, but at least it gives a good chance of it.

CAttachedDevice

The CAttachedDevice class is declared in devext.h (Listing 3). Each CAttachedDevice object contains two lists: an event list and a request list. These lists are implemented as FIFO doubly linked lists, using the helper template class CDBLinkedList. Within CAttachedDevice, the Signature field is used to double-check that the calling application passed a valid MHANDLE (remember that the MHANDLE is just a pointer to a CAttachedDevice). The eres field is an ERESOURCE variable used to lock and unlock the entire CAttachedDevice object.

The class also contains the variable event of type KEVENT and initializes it in its constructor with a call to KeInitializeEvent(). This event is of great importance. While developing the driver, I found that if I detached the device from the serial device and unloaded the driver while some application had the port opened, then the next request sent to it caused NT to produce a BSOD (Blue Screen Of Death) in an error condition. That’s why you can’t detach the device from the destination device until all user-mode (and preferably kernel-mode, too) references to it are closed. That’s the purpose of the Num and bFirstTime fields.

CAttachedDevice::Num contains the number of times the device was opened; serial devices can’t be opened more than once, but you could use this code to spy on similar device drivers that do permit multiple opens. CAttachedDevice::bFirstTime is set to TRUE in the constructor preventing the close handler (CloseCompletion() in devext.cpp) from decrementing Num the first time, since the code will miss the initial open IRP. CloseCompletion() sets the event to signaled when Num is equal to zero and non-signaled when it’s more than zero. This enables the call to KeWaitForSingleObject() in the destructor to wait for the port to be closed.

The CAttachedDevice constructor initializes all these fields and inserts itself into the internal driver’s linked list of pointers to CAttachedDevice objects (created in DriverEntry()). This lets the driver unload cleanly.

Immediately after the attach operation, the driver is ready for monitoring. Each request forwarded by the I/O manager to the target serial driver goes instead to the attached driver, then is redirected to the corresponding virtual function in CAttachedDevice.

The driver monitors only open, close, write, read, and I/O control requests. Moreover, they ignore requests that the serial driver returns an unsuccessful status code for. In all cases, CAttachedDevice::Standard() is called to handle the incoming IRP, passing it the IRP being processed and a pointer to the completion routine for the current I/O stack location.

CAttached::Standard() copies the current stack location into the next and sets the completion routine for the IRP, using the macro IoSetCompletionRoutine(). After bit-wise copying, the next stack location will contain the same value for its CompletionRoutine field as the current. If there is more than one driver layered above the destination driver, then it will cause multiple calls of the same function. The results are unpredictable. That’s why it’s much safer to always specify a completion routine. In cases where the driver doesn’t need one (i.e., for flush and cleanup requests), it registers DefaultCompletion() as the completion routine; DefaultCompletion() does nothing besides returning STATUS_SUCCESS to the I/O manager.

The main processing can be found in ReadCompletion(), WriteCompletion(), OpenCompletion(), CloseCompletion(), and IOCompletion(). If the request was successful, they construct a new object of type IOReq, fill all necessary data within it, and then call CAttachedDevice::New(). CAttachedDevice::New() first locks the CAttachedDevice object (in order to eliminate internal lists inconsistencies), and then appends the last created IOReq object to the end of the events list.

The events list stores up information about serial port I/O requests so that the calling application can retrieve them later. However, requests from the calling application (IOCTL_SERMON_GETINFOSIZE and IOCTL_SERMON_GETINFO) may also be stored in a linked list — the request list. If the event list is empty when the user-mode application sends an IOCTL_SERMON_GETINFOSIZE or IOCTL_SERMON_GETINFO, then the request is marked pending and put at the end of the request list. When CAttachedDevice::New() appends a new IOReq structure to the event list, it also checks the request list. If there is a request already pending, then CAttachedDevice::New() goes ahead and processes it by calling either ProcessSize() or ProcessNext(). These functions retrieve the top IOReq from the event list, complete the request, and remove it from the request list. (ProcessNext() also removes the topmost event from the event list.)

Stopping Monitoring

When the application sends a IOCTL_SERMON_STOPMONITOR request, the driver simply deletes the CAttachedDevice object. All the work is done by its destructor.

CAttachedDevice::~CAttachedDevice() first changes the signature field so that if the application mistakenly uses the same MHANDLE again, the driver can detect it as an invalid pointer. It then waits for the previously explained event until the port is closed. Then it detaches the device and deletes it. After that, all pending IRPs are completed with STATUS_CANCELLED. Finally, execution returns to the user-mode application.

Controlling Application

This month’s code archive contains a simple controlling application (see Figure 1) that uses the SerMon driver to monitor ports. It uses MFC and implements an MDI window interface, allowing the user to monitor several ports concurrently.

When started, the program first tries to open the SerMon device driver by calling CreateFile() with a filename of \\.\SerMon. If the driver can not be opened (probably because it was not installed), the application tries to use the service control manager to install the driver. Installing device drivers using the SCM is a very effective and easy method.

When you select the start-monitor menu option, the application asks you to select a port. The application then creates a new document/view pair. The view contains two windows (containing bytes read and bytes written). Open, close, and I/O control requests are displayed in both windows.

The application then sends the IOCTL_SERMON_STARTMONITOR request to the driver and gets a handle to the monitor session. Then it creates a parallel thread, which enters the cycle of issuing two requests (IOCTL_SERMON_GETINFOSIZE and IOCTL_SERMON_GETINFO), using the OVERLAPPED structure to cause DeviceIoControl() to perform asynchronous I/O. When the thread receives data, it posts a message to the view, and it displays the data to the user.

When you select the stop-monitor menu option, or simply close the view, the application sends an IOCTL_SERMON_STOPMONITOR to the driver after displaying a warning message to the user. The warning tells the user to close the application that uses the port for reasons described earlier.

Conclusion

Creating a monitoring filter driver is an elegant solution to the problem of spying on serial ports under NT. Moreover, the technique can be used on almost any kernel-mode device driver. For example, you could also use this idea to monitor the IRP flow of a driver you are debugging. The serial port spy is just one of many applications made possible by NT’s extensible device-driver framework.

Reference

Nagar, Rajeev. Windows NT File System Internals: A Developer’s Guide. O’Reilly & Associates, September 1997.

Alex V. Bessonov is a postgraduate student of the Moscow State Engineering Physics Institute (technical university). He works as a leading software developer for a HHD Software company (http://www.hhdsoftware.com). You can contact him by e-mail at [email protected].

Get Source Code


Related Reading


More Insights






Currently we allow the following HTML tags in comments:

Single tags

These tags can be used alone and don't need an ending tag.

<br> Defines a single line break

<hr> Defines a horizontal line

Matching tags

These require an ending tag - e.g. <i>italic text</i>

<a> Defines an anchor

<b> Defines bold text

<big> Defines big text

<blockquote> Defines a long quotation

<caption> Defines a table caption

<cite> Defines a citation

<code> Defines computer code text

<em> Defines emphasized text

<fieldset> Defines a border around elements in a form

<h1> This is heading 1

<h2> This is heading 2

<h3> This is heading 3

<h4> This is heading 4

<h5> This is heading 5

<h6> This is heading 6

<i> Defines italic text

<p> Defines a paragraph

<pre> Defines preformatted text

<q> Defines a short quotation

<samp> Defines sample computer code text

<small> Defines small text

<span> Defines a section in a document

<s> Defines strikethrough text

<strike> Defines strikethrough text

<strong> Defines strong text

<sub> Defines subscripted text

<sup> Defines superscripted text

<u> Defines underlined text

Dr. Dobb's encourages readers to engage in spirited, healthy debate, including taking us to task. However, Dr. Dobb's moderates all comments posted to our site, and reserves the right to modify or remove any content that it determines to be derogatory, offensive, inflammatory, vulgar, irrelevant/off-topic, racist or obvious marketing or spam. Dr. Dobb's further reserves the right to disable the profile of any commenter participating in said activities.

 
Disqus Tips To upload an avatar photo, first complete your Disqus profile. | View the list of supported HTML tags you can use to style comments. | Please read our commenting policy.