spacer.png, 0 kB
spacer.png, 0 kB
Home arrow All Articles arrow General Security Articles arrow Steganography: Hiding Data in Images
Steganography: Hiding Data in Images Print E-mail

Abstract

Steganography is the art and science of hiding information by embedding messages within other messages. Steganography works by replacing useless or unused data in regular computer files (such as graphics, sound, text, HTML, etc.) with different, invisible information. This hidden information can be plain text, cipher text, or even images. In this article, I develop a small ASP.NET web application that sends an image, a text message, and a password to a .NET web service, which in turn hides the data in the image using the password and returns an encoded image. I use Microsoft Direct Internet Message Encapsulation (DIME) support (using Microsoft WSE 2.0) for the process of sending image files between the web application and the web service.

 

Introduction

As a kid, did you ever hide messages on paper using "invisible ink" (lemon juice and vinegar) and then read the messages by exposing the paper to heat over a candle? In the world of grown-ups, this happens to be researched as a science termed steganography.

Steganography, or "covered writing" as it literally means in Greek, is all about concealing data in a medium so as to ensure safe delivery only to the intended recipient. Ancient Greek historical records show steganographic practices such as carving messages in wooden message tablets and covering them in wax, as well as tattooing messages onto the shaven scalps of messengers, letting the hair grow back (to cover the message), and then shaving the scalp again to read the message at the recipient's location.

As defined at Webopedia,

(ste-g&n-ogr&-fe) (n.) The art and science of hiding information by embedding messages within other, seemingly harmless messages. Steganography works by replacing bits of useless or unused data in regular computer files (such as graphics, sound, text, HTML, or even floppy disks) with bits of different, invisible information. This hidden information can be plain text, cipher text, or even images.

Steganography sometimes is used when encryption is not permitted. Or, more commonly, steganography is used to supplement encryption. An encrypted file may still hide information using steganography, so even if the encrypted file is deciphered, the hidden message is not seen.

Just as steganography can be used constructively for encryption and security, it can also be used destructively by hackers in steganographic virus attacks. Therefore, a basic understanding of the process involved in steganography can help evaluate the potentials and threats of this science.

In this article, I show examples of implementing steganography as the encoding of data (a text string) in an image using a password. By the time you have finished reading this article, you will have seen a basic steganography process involving the following entities:

  • A bitmap image as the message carrier (equivalent to the scalp of the Greek message carrier)
  • A string password as the message encryptor (equivalent to the hair of the Greek message carrier)
  • A string text as the message (equivalent to the message tattooed on the scalp of the Greek message carrier)

All these entities will be integrated into a single application using the .NET platform.

System Requirements

To work with the solution in this sample, you need the following:

  • A web server running on Windows 2000/XP
  • .NET Framework version 1.1
  • Visual Studio .NET 2003
  • Microsoft Web Services Extensions (WSE) 2.0

You should also be familiar with the basic concepts associated with the WSE 2.0 DIME formats; binary-level operations such as bit-shift operators; and bitmap image concepts such as pixels, RGB color arguments, etc. If you need an overview of these technologies, refer to the "Related Links" section at the end of this article for relevant material.

The Sample Code

The sample download for this article contains a VS .NET solution named SteganographyWebForm , which in turn contains two projects:

  • The SteganographyWebForm project is an ASP.NET web application (with C# code-behind) that is responsible for providing the user interface to the user:
  • Provides the message to be encoded and password (for encoding)
  • Provides the password for extracting data from the encoded image (for decoding)
  • Used to view the encoded/decoded image
  • Used to actually trigger the encoding/decoding processes by submitting the information to a web service as a DIME attachment
  • The SteganographyWS project is a web service application (in C#) that is responsible for actually providing the following services:
  • Receives the message, a password, and an image to be encoded as a DIME attachment (during the encoding process)
  • Returns the encoded image as a DIME attachment to the ASP.NET application (during the encoding process)
  • Receives the password and image to be decoded (during the decoding process)
  • Returns the encoded message to the ASP.NET application (during the decoding process)

To install the sample application, you should first create two IIS virtual directories called SteganographyWebForm and SteganographyWS that point to the respective folders in which you have installed the projects just mentioned. Ensure that the ASPNET account has read/write access in Windows Security as well as in the IIS virtual directory properties, so that file access rights are available for manipulating the image files.

Ensure that you have Microsoft WSE 2.0 installed. See the "Related Links" section at the end of this article for a link to the download.

Open the solution file, set SteganographyWebForm as the startup project, and set the web form named WebForm1.aspx as the default page for the project. Then build the solution and run it.

Sample Application Overview

Before stepping into the code, let's quickly walk through some screen shots of the final application. The whole application is rendered in a single yet simple user interface, which moves through various states for the processes described in the sections that follow.

Default State

This is the initial state of the web page (see figure 1) that is displayed by the application to the user. It consists of the following features/controls for the two distinct functionalities:

For encoding data:

  • An area that displays the standard image, which contains no hidden message
  • A text box for specifying the password (up to eight characters) for encrypting the secret message
  • A text box for inputting the actual secret message
  • A button that invokes the process of encoding the secret message into the image (that contains no hidden message) using the password specified

For decoding data:

  • An area that displays the encoded image, which contains the hidden message (blank during the first time, since data is yet to be encoded)
  • A text box for specifying the password (up to eight characters) for decrypting the secret message
  • A button that invokes the process of decoding the secret message from the encoded image (which contains the hidden message) using the password specified
  • A read-only text box for displaying the decoded secret message

Figure 1. The application showing the default state

Data Encoding State

The user enters the text to be encoded and the password to be used for the encoding process, and clicks the Encode Data button as shown in figure 2.

Figure 2. The application showing the data encoding state

Data Encoded State

After the details for the encoding purpose have been provided and the user clicks the Encode Data button, the data is encoded into a new image and displayed in the lower portion of the screen as shown in figure 3.

Figure 3. The application showing the data encoded state

Note: The image with the encoded data seems very similar to the original image. However, please bear in mind that I have modified the original image by injecting a limited amount of message data (or, in steganographic terms, "noise") into the carrier image. If the amount of "noise" is too high, then the encoded image quality may become poor and visible deteriorations may be observed.

Data Decoding State

The user extracts the hidden message in the image by specifying the correct password and clicking the Decode Data button as shown in figure 4.

Figure 4. The application showing the data decoding state

Data Decoded State

The decoded hidden message (see figure 5) is extracted from the image file (containing the hidden message and displayed in the lower portion of the screen) and shown in the read-only text box.

Figure 5. The application showing the data decoded state

Invalid Password State

The user tries to extract the hidden message in the image by specifying an incorrect password and clicking the Decode Data button. Figure 6 shows the incorrect and useless decoded hidden message that will be extracted from the image file and shown in the read-only text box.

Figure 6. The application showing the invalid password state

Coding the Application

The default web form displayed when the application is run is the WebForm1.aspx page. The code-behind class for this page uses two read-only private variables, CarrierImagePath and EncodedImagePath , as follows. These variables store the hard-coded paths of the carrier image (the image without the hidden message) and the encoded image, respectively.

private readonly string   CarrierImagePath ; 
private readonly string EncodedImagePath ;

In the Page_Load event for the code-behind class, I check for the existence of an encoded image (as a cleanup run from a previous execution), and if it's there, I ensure that it's deleted.

private void Page_Load(object sender, System.EventArgs e)
{
if(!IsPostBack)
{
imgWithoutMsg.ImageUrl = CarrierImagePath;
if(File.Exists(EncodedImagePath))
{
File.Delete(EncodedImagePath);
}
}
}
}

Next, I'll move on to the details of the click event of the btnEncode button. Here, I instantiate an instance of the SteganographyWse class, which is exposed by the SteganographyWS web service (which has been referenced by the ASP.NET application during project setup). I then load the carrier file as an image bitmap and extract a MemoryStream from the image object. This image stream and a unique identifier to identify it are used to build a DimeAttachment object, which is added to the Attachments collection of the Steganographer.RequestSoapContext object.

As a next step, I call the GetEncodedBitmap() method of the Steganographer class by passing the unique identifier of the DimeAttachment object (the carrier image), the password, and the actual text that needs to be encoded into the carrier image.

The GetEncodedBitmap() method processes the preceding information (described in later sections) and returns a Bitmap object (which is the encoded image). Prior to saving the bitmap file, I do a quick check to delete any previously encoded images, and then I save this new encoded bitmap file.

private void btnEncode_Click(object sender, System.EventArgs e)
{
Bitmap BitmapWithoutMessage = null;
Bitmap BitmapWithMessage = null;
MemoryStream BitmapStreamWithoutMessage = new MemoryStream();
DimeAttachment DimeBitmap = new DimeAttachment();
string ImageGUID;

SteganographyWS.SteganographyWse Steganographer = new
SteganographyWS.SteganographyWse();

if(File.Exists(Server.MapPath(CarrierImagePath)))
//check if carrier image files exist
{
BitmapWithoutMessage = (Bitmap)
Bitmap.FromFile(Server.MapPath(CarrierImagePath));
BitmapWithoutMessage.Save(BitmapStreamWithoutMessage,
ImageFormat.Bmp);

imgWithMsg.ImageUrl = EncodedImagePath;

DimeBitmap.ContentType= "image/bmp";
DimeBitmap.TypeFormat = TypeFormat.MediaType;
DimeBitmap.Stream = BitmapStreamWithoutMessage;
DimeBitmap.Id = "uri:" + Guid.NewGuid().ToString();
Steganographer.RequestSoapContext.Attachments.Add(DimeBitmap);


ImageGUID = Steganographer.GetEncodedBitmap(DimeBitmap.Id,
txtEncodePwd.Text,txtEncodeMsg.Text);

BitmapWithMessage = (Bitmap) Bitmap.FromStream(
Steganographer.ResponseSoapContext.
Attachments[ImageGUID].Stream);

if(File.Exists(Server.MapPath(EncodedImagePath)))
//check if any previously encoded files exist; if so,
//delete prior to save
{
File.Delete(Server.MapPath(EncodedImagePath));
}
BitmapWithMessage.Save(Server.MapPath(EncodedImagePath));
}

//Garbage Collection
BitmapStreamWithoutMessage.Close();
if(BitmapWithoutMessage!= null)BitmapWithoutMessage.Dispose();
if(BitmapWithMessage!= null)BitmapWithMessage.Dispose();
if(Steganographer!= null)Steganographer.Dispose();
}

Let's move on to examine the details of the click event of the btnDecode button. The functionality is pretty similar to the btnEncode event handler shown previously. First, I instantiate an instance of the Steganographer class, which is exposed by the SteganographyWS web service. I then load the encoded image carrier file as an image Bitmap and extract a MemoryStream from the image object. This image stream and a unique identifier to identify it are used to build a DimeAttachment object, which is added to the Attachments collection of the Steganographer.RequestSoapContext object.

I then call the GetEncodedMessage() method of the Steganographer class by passing the unique identifier of the DimeAttachment object (the carrier image) and the password for extracting the hidden data in the encoded carrier image.

The GetEncodedMessage() method processes the preceding information and returns a string, which is the hidden message and is displayed in the read-only text box on the screen.

private void btnDecode_Click(object sender, System.EventArgs e)
{
Bitmap BitmapWithMessage = null;
MemoryStream BitmapStreamWithMessage = new MemoryStream();
DimeAttachment DimeBitmap = new DimeAttachment();

SteganographyWS.SteganographyWse Steganographer = new
SteganographyWS.SteganographyWse();

if(File.Exists(Server.MapPath(EncodedImagePath)))
//check if encoded image files exist
{
BitmapWithMessage = (Bitmap)
Bitmap.FromFile(Server.MapPath(EncodedImagePath));
BitmapWithMessage.Save(BitmapStreamWithMessage,
ImageFormat.Bmp);

DimeBitmap.ContentType= "image/bmp";
DimeBitmap.TypeFormat = TypeFormat.MediaType;
DimeBitmap.Stream = BitmapStreamWithMessage;
DimeBitmap.Id = "uri:" + Guid.NewGuid().ToString();

Steganographer.RequestSoapContext.Attachments.Add(DimeBitmap);

txtDecodeMsg.Text = Steganographer.GetEncodedMessage(
DimeBitmap.Id,txtDecodePwd.Text);
}

//Garbage Collection
BitmapStreamWithMessage.Close();
if(BitmapWithMessage!= null)BitmapWithMessage.Dispose();
if(Steganographer!= null)Steganographer.Dispose();
}

Encoding and Decoding the Image

This section focuses on the implementation of the GetEncodedBitmap() and GetEncodedMessage() methods of the SteganographyWse web service class.

The GetEncodedBitmap() method accepts a bitmap identifier (the unique ID for the DIME attachment carrier image), a string password, and a string message as parameters, and returns another unique image identifier (the unique ID for the DIME attachment encoded image).

This method uses the bitmap identifier to extract the carrier image from the DIME attachment collection of the RequestContext object into a MemoryStream . The MemoryStream is then passed (by reference), along with the password and the text to be hidden to the private EncodeBitmap() method (described later).

public string GetEncodedBitmap( string BitmapName , string Password , 
string Message)
{
SoapContext RequestContext = RequestSoapContext.Current;
SoapContext ResponseContext = ResponseSoapContext.Current;

MemoryStream BitmapStream = (MemoryStream
RequestContext.Attachments[BitmapName].Stream;

EncodeBitmap(ref BitmapStream,Password,Message);

DimeAttachment DimeImage = new DimeAttachment("image/bmp",
TypeFormat.MediaType,BitmapStream);
DimeImage.Id = "uri:" + Guid.NewGuid().ToString();

ResponseContext.Attachments.Add(DimeImage);

return DimeImage.Id ;
}

The GetEncodedMessage() method accepts a bitmap identifier (the unique ID for the DIME attachment encoded image) and a string password as parameters, and returns a string (which is the hidden text in the encoded image).

The method uses the bitmap identifier to extract the encoded image from the DIME attachment collection of the RequestContext object into a memory stream. This memory stream is then passed (by reference), along with the password to the private DecodeBitmap() method (described later). The password is used to encrypt the hidden message during the process of inclusion into the carrier image. The same password is used during the decryption process to extract the hidden message from the carrier image.

public string GetEncodedMessage(string BitmapName , string Password)
{
SoapContext RequestContext = RequestSoapContext.Current;
SoapContext ResponseContext = ResponseSoapContext.Current;

MemoryStream BitmapStream = (MemoryStream
RequestContext.Attachments[BitmapName].Stream;

return DecodeBitmap(ref BitmapStream,Password);
}

Now let's see the actual implementations of the EncodeBitmap() and DecodeBitmap() methods. These methods are responsible for the actual steganographic process. The EncodeBitmap() method takes a BitmapStream , a password, and the text to be hidden as parameters, and uses an algorithm (described later) to inject the data into the bitmap stream using the password specified as the encryption key. Similarly, the DecodeBitmap() method takes a BitmapStream and a password, and uses the same algorithm (in the reverse mode) to extract the message from the BitmapStream using the password as the decryption key.

The functionality of these methods is pretty contradictory (one encodes, while the other decodes) yet similar, and the code for both of these methods is shown in the next section. They are self-explanatory, but relevant code comments about the encryption/decryption algorithms are included.

Before jumping into the code, I've included a quick explanation of the algorithm I used for encoding/decoding data in the pseudo-code in the following section.

The Encryption Algorithm

The algorithm is basic to demonstrate the concept. Any other algorithm may be substituted as long as basic information such as the length of the hidden data, the offsets of the hidden data in the message stream, and the actual data encryption key are encoded in the image. Another point to be considered is that this example specifically uses a bitmap image format; hence, the encoding algorithm is tied to the data format of the bitmap image format. However, these concepts can easily be applied to other graphic formats (JPEG, GIF, etc.) based on their respective internal data format specifications.

Encoding Algorithm

The encoding algorithm involves these steps:

  1. Get the length of the data stream to be hidden in the carrier image.
  2. Split the data length into three (R, G, and B components of a pixel) using bit-shift operators, and set the first pixel of the carrier image to a custom pixel (made up of the R, G, and B components). Thus, the length of the hidden data is stored in the first pixel of the encoded image.
  3. Get a random value from the password stream (in this example, I have taken the third byte from the password stream byte array), which will be used as a hash key later on.
  4. From the second pixel of the first pixel row (I used the first pixel to store the length):
  1. Take a byte from the message stream and a byte from the password stream.
  2. XOR the message byte with the hash key.
  3. Find an offset based on the value of the password byte.
  4. Set the R component of the current pixel (based on the offset obtained earlier) to the XOR-ed value.

Note: The R component is used at random; you may use the G or B components too. Just make sure that you use the same component you used for encoding during the decoding process too. (But you already knew that, that didn't you?)

  1. Repeat the process in a loop:
  2. If the end-of-stream is reached for the password stream, then restart from the beginning of the stream.
  3. If the pixel row position obtained as the offset exceeds the width of the carrier image, then shift the same offset to the next pixel row.
  4. If the end-of-stream is reached for the message stream, then save the file and exit.
private void EncodeBitmap(ref MemoryStream BitmapStream, string Password
string Message)
{
Bitmap CarrierBMP = new Bitmap(BitmapStream);
Color PixelColour = new Color();

//Unicode Data Streams
Stream MessageStream = new MemoryStream(
UnicodeEncoding.Unicode.GetBytes(Message));
Stream PasswordStream = new MemoryStream(
UnicodeEncoding.Unicode.GetBytes(Password));

int MessageLength = (int) MessageStream.Length;
int PasswordLength = (int) PasswordStream.Length;
int RemainingLength = 0;

//Encode message length into the first pixel here

//Shift by 2 bits
int Red = MessageLength >> 2;
//Get back the remaining data
RemainingLength = MessageLength - Red;
//Shift by 1 bit
int Green = RemainingLength >> 1;
//Get back the remaining data
RemainingLength = RemainingLength - Green;
int Blue = RemainingLength;

//Get pixel color with integrated message length
PixelColour = Color.FromArgb(Red,Green,Blue);
//Set message length in first pixel
CarrierBMP.SetPixel(0,0,PixelColour);

//get hash key from key for XOR-ing data later
PasswordStream.Seek(2,SeekOrigin.Begin);
int HashKey = PasswordStream.ReadByte();
PasswordStream.Seek(0,SeekOrigin.Begin);

Point PixelPosition = new Point(1,0);

//Get pixel positions based on password key bytes
//and XOR hash key values to the pixel at that position
MessageStream.Seek(0,SeekOrigin.Begin);

for(int Mindex= 0 ;Mindex < MessageLength-1 ; Mindex++)
{
int Position = PasswordStream.ReadByte();
if(Position == -1)
//End of Password Stream reached so reset to beginning
{
PasswordStream.Seek(0,SeekOrigin.Begin);
Position = PasswordStream.ReadByte();
}
//unicode encoding may have 0 values in additional bytes, in
//which case set value to 1
if (Position == 0) Position = (byte)1;

if ((PixelPosition.X + Position) > CarrierBMP.Width)
{
PixelPosition.X = 0;
PixelPosition.Y += 1;
}
else
{
PixelPosition.X += Position;
}
//Get the existing Pixel value at this position
PixelColour = CarrierBMP.GetPixel(PixelPosition.X,
PixelPosition.Y);

//Modify the byte values in the message by XOR-ing the hash key
int ModifiedMessage = MessageStream.ReadByte() ^ HashKey;

//incorporate this new value into the pixel position by
//changing appropriate color components.
PixelColour = Color.FromArgb(ModifiedMessage,PixelColour.G,
PixelColour.B);

CarrierBMP.SetPixel(PixelPosition.X,PixelPosition.Y,
PixelColour);
}
BitmapStream.Flush();
CarrierBMP.Save(BitmapStream,ImageFormat.Bmp);
CarrierBMP = null;
}

Decoding Algorithm

The decoding algorithm involves these steps:

  1. Get the length of the data stream hidden in the encoded image by splitting the R, G, and B components of the first pixel using bit-shift operators.
  2. Get a random value from the password stream (in this example, I have taken the third byte from the password stream byte array), which will be used as a hash key later on.
  3. From the second pixel of the first pixel row:
  1. Take a byte from the message stream and a byte from the password stream.
  2. Find an offset based on the value of the password byte.
  3. Get the R component of the current pixel (based on the offset obtained earlier) and XOR the hash key to obtain the hidden message byte.
  4. Repeat the process in a loop:
  5. If the end-of-stream is reached for the password stream, then restart from the beginning of the stream.
  6. If the pixel row position obtained as the offset exceeds the width of the carrier image, then shift the same offset to the next pixel row.
  7. If the end-of-stream is reached for the message stream, then return the byte array of encoded data and exit.
private string DecodeBitmap(ref MemoryStream BitmapStream, string Password)
{
Bitmap CarrierBMP = (Bitmap) Bitmap.FromStream(BitmapStream);

Color PixelColour = new Color();

Stream MessageStream = new MemoryStream();
Stream PasswordStream = new MemoryStream(
UnicodeEncoding.Unicode.GetBytes(Password));

int MessageLength = 0;
int PasswordLength = (int) PasswordStream.Length;

//Set encoded message length from the first pixel
PixelColour = CarrierBMP.GetPixel(0,0);
MessageLength = PixelColour.R + PixelColour.G + PixelColour.B;

MessageStream.SetLength(MessageLength);

//get hash key from key for XOR-ing data later
PasswordStream.Seek(2,SeekOrigin.Begin);
int HashKey = PasswordStream.ReadByte();
PasswordStream.Seek(0,SeekOrigin.Begin);


Point PixelPosition = new Point(1,0);

//Get pixel positions based on password key bytes
//and XOR hash key values to the pixel at that position
MessageStream.Seek(0,SeekOrigin.Begin);
for(int Mindex= 0 ;Mindex < MessageLength-1 ; Mindex++)
{
int Position = PasswordStream.ReadByte();
if(Position == -1)
//End of Password Stream reached so reset to beginning
{
PasswordStream.Seek(0,SeekOrigin.Begin);
Position = PasswordStream.ReadByte();
}
//unicode encoding may have 0 values in additional bytes, in
//which case set value to 1
if (Position == 0) Position = (byte)1;

if ((PixelPosition.X + Position) > CarrierBMP.Width)
{
PixelPosition.X = 0;
PixelPosition.Y += 1;
}
else
{
PixelPosition.X += Position;
}

//Get the existing Pixel value at this position
PixelColour = CarrierBMP.GetPixel(PixelPosition.X,
PixelPosition.Y);

//Modify the byte values in the message by XOR-ing the hash key
byte Message = (byte)(PixelColour.R ^ HashKey);

//incorporate this new value into the pixel position by
//changing appropriate color components.
MessageStream.WriteByte(Message);
}

CarrierBMP = null;//to be cleaned up

MessageStream.Seek(0,SeekOrigin.Begin);
byte[] HiddenMessage = new byte[MessageStream.Length];
MessageStream.Read(HiddenMessage,0,HiddenMessage.Length);

return UnicodeEncoding.Unicode.GetString(HiddenMessage);

}

Note: While XOR is not considered even slightly useful for a "production quality" encryption system, the Exclusive-OR (or XOR) function is a simple example of a symmetric encryption method. XOR is a bitwise (or Boolean) operator, with the following truth table:

A B A XOR B
0 0 0
0 1 1
1 0 1
1 1 0

Most programming languages provide an XOR function. To "encrypt" (for example) a byte of data, you could do this:

cipher = plainText ^ key;

The symmetric aspect of XOR comes from the fact that to recover the plain text, you can simply repeat the operation on the cipher text:

plainText = cipher ^ key;

Symmetric encryption and decryption (i.e., using the same algorithm and key to both encrypt and decrypt) is a characteristic of all single-key encryption systems. The XOR function is a commonly used as one component among many in "real-world" encryption algorithms

Further Work

This example has shown steganography in the context of a carrier image, text message, and text password. Limitations exist in terms of how much data (in other words, noise) can be hidden in the carrier file without visible deterioration. These are based on volumes of mathematical formulas and are beyond the scope of this article.

Based on the guidelines given in this article, the current application has taken safe values in terms of message size, password size, and a predefined image to show the basic concepts involved. In the real world, if you're working within predefined data size constraints, whole images can be hidden in other images and, still more, a third image may be used as the password for the encryption/decryption process. In other words, data can be hidden in images without visual aberrations/distortions. This could open up possibilities like secret message transfer and image authentication, as well as copyright implementations.

Conclusion

Having come this far through all the twist and turns of the steganography joyride, let's quickly recap and consolidate this article's achievements. I started by developing an ASP.NET application that accepts user input (text to be hidden and a password) and uses this information to encrypt/decrypt the data within an image by sending/receiving the information as a DIME attachment to a "steganography" web service. I also developed a web service that receives and sends DIME attachments containing images/hidden messages using encryption/decryption algorithms and using password ciphers provided by the user. You can use this sample as a basic building block for your own steganography implementation.

ASPToday - Ø¢© 2003-2005 Apress You may print a copy of this article for easier reading or reference, or store the downloaded HTML page on your local machine for your own use. Please check the Terms and Conditions on the ASP Today website for full details of the conditions for distributing this article.


Related Items:

 
< Prev   Next >
spacer.png, 0 kB
spacer.png, 0 kB
spacer.png, 0 kB
spacer.png, 0 kB