Introduction
In the last article, Steganography VI, only void
methods could be used, so the length of the hidden message was very restricted. This article enhances the application:
- All methods with a return type of
void
, bool
, int32
or string
can be used.
- A key file defines how the message is hidden.
The difference between void
and non-void
-methods is not very big. At the end of a non-void
method the stack is not empty, it contains a value of the declared type. That means this application has to read the name of the return type from the method's declaration and declare an additional local variable. At the line before the first "ret", it has to store the stack's content (which is only the return value and nothing else) into the additional variable, insert the lines with the secret bytes, and then load the value back onto the stack.
For example, take a look at this int
-method:
private int intTest(){
int a = 1;
return a;
}
The C# compiler translates it like that:
.method private hidebysig instance int32
intTest() cil managed
{
.maxstack 1
.locals init ([0] int32 a,
[1] int32 CS$00000003$00000000)
IL_0000: ldc.i4.1
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: stloc.1
IL_0004: br.s IL_0006
IL_0006: ldloc.1
IL_0007: ret
}
The compiler has created a second variable to store the return value. At the end of the method, this value is put onto the stack, that's all. So nothing is going to break, if we write some lines between IL_0006
and IL_0007
, and then clean up the stack again before loading the return value:
.method private hidebysig instance int32
intTest() cil managed
{
.maxstack 2
.locals init ([0] int32 a,
[1] int32 CS$00000003$00000000)
.locals init (int32 myvalue)
IL_0000: ldc.i4.1
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: stloc.1
IL_0004: br.s IL_0006
IL_0006: ldloc.1
.locals init (int32 returnvalue)
stloc returnvalue
ldstr "DEBUG - current value is: {0}"
ldc.i4 111
box [mscorlib]System.Int32
call void [mscorlib]System.Console::WriteLine(string,
object)
ldloc returnvalue
IL_0007: ret
}
Now ILAsm can re-compile the code. If you decompile it again, you can see that ILAsm has optimized the variable declarations:
.method private hidebysig instance int32
intTest() cil managed
{
.maxstack 2
.locals init (int32 V_0,
int32 V_1,
int32 V_2,
int32 V_3)
IL_0000: ldc.i4.1
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: stloc.1
IL_0004: br.s IL_0006
IL_0006: ldloc.1
IL_0007: stloc V_3
IL_000b: ldstr "DEBUG - current value is: {0}"
IL_0010: ldc.i4 0x6f
IL_0015: box [mscorlib]System.Int32
IL_001a: call void [mscorlib]System.Console::WriteLine(string,
object)
IL_001f: ldloc V_3
IL_0023: ret
}
ILAsm cleans up my lines, isn't that nice? No, that's not nice at all, because we cannot rely on our inserted lines to be still there after compiling and decompiling the IL code. That means, whatever we insert to hide parts of the secret message has to make sense. An additional .maxlength
-line is going to be deleted, just as a .locals init
-line with variable names that are never used. Remember that effect whenever you make up a new byte-disguise.
Using a key stream
In the last article, we always used these two line to hide an int32
:
ldc.i4 65
stloc myvalue
As you've already seen above, these lines can hide the same data:
ldstr "DEBUG - current value is: {0}"
ldc.i4 65
box [mscorlib]System.Int32
call void [mscorlib]System.Console::WriteLine(string, object)
There are hundreds of other blocks like that, so which variation shall we use? We'll use all variations, or - to keep it simple - those two variations. The user can specify a file of any format, and for each four-byte-block, the application reads one byte from this file: if the byte is even, it uses the first variation, otherwise it uses the second one.
private bool ProcessMethodHide(String[] lines, ref int indexLines,
Stream message, Stream key){
int keyValue;
for(int n=0; n<bytesPerMethod; n+=4){
isMessageComplete = GetNextMessageValue(message, out currentMessageValue);
if( (keyValue=key.ReadByte()) < 0){
key.Seek(0, SeekOrigin.Begin);
keyValue=key.ReadByte();
}
if(keyValue % 2 == 0){
writer.WriteLine("ldc.i4 "+currentMessageValue.ToString());
writer.WriteLine("stloc myvalue");
}else{
writer.WriteLine("ldstr \"DEBUG - current value is: {0}\"");
writer.WriteLine("ldc.i4 "+currentMessageValue.ToString());
writer.WriteLine("box [mscorlib]System.Int32");
writer.WriteLine("call void [mscorlib]System.Console::WriteLine(string, ");
writer.WriteLine( "object)" );
}
}
}
With the first variation, we have to look for the constant in the first line, with the second variation we have to pick it from the second line. Extracting the hidden message, we have to skip the first line unless the key byte is even:
private bool ProcessMethodExtract(String[] lines, ref int indexLines,
Stream message, Stream key){
for(int n=0; (n<bytesPerMethod)||(bytesPerMethod==0); n+=4){
if(bytesPerMethod > 0){
if( (keyValue=key.ReadByte()) < 0){
key.Seek(0, SeekOrigin.Begin);
keyValue=key.ReadByte();
}
if(keyValue % 2 == 1){
indexLines++;
}
}
indexValue = lines[indexLines].IndexOf("ldc.i4");
if(indexValue >= 0){
Now we can hide and extract data, but many re-compiled assemblies will terminate with an InvalidProgramException
. That is because the second variation puts two values onto the stack:
.maxstack 1
...
ldstr "DEBUG - current value is: {0}"
ldc.i4 0x6f
box [mscorlib]System.Int32
call void [mscorlib]System.Console::WriteLine(string,
object)
So we have to make sure that the .maxstack
-value is 2 or greater in every method. The .maxstack
-line is one of the lines we've only copied yet:
CopyBlock(lines, startIndex, endIndex);
private void CopyBlock(String[] lines, int start, int end){
String[] buffer = new String[end-start];
Array.Copy(lines, start, buffer, 0, buffer.Length);
writer.WriteLine(String.Join(writer.NewLine, buffer));
}
Now we have to find and adjust the .maxstack
-lines, otherwise we would destroy assemblies which contain methods with a maxstack
of 1. We cannot find these lines with something like Array.IndexOf(".maxstack 1")
, because the exact line is not known - just think about the line numbers, tabs and spaces ILDAsm inserts into every line. So we'll copy the method's body line-by-line:
private void CopyBlockAdjustStack(String[] lines, int start, int end){
for(int n=start; n<end; n++){
if(lines[n].IndexOf(".maxstack ")>0){
int indexStart = lines[n].IndexOf(".maxstack ");
int maxStack = int.Parse( lines[n].Substring(indexStart+10).Trim() );
if(maxStack < 2){
lines[n] = ".maxstack 2";
}
}
writer.WriteLine(lines[n]);
}
}
Handling return values
A method's return type is declared in its header, that means we have to read and store it when we enter the method:
private String GetReturnType(String line){
String returnType = null;
if(line.IndexOf(" void ") > 0){ returnType = "void"; }
else if(line.IndexOf(" bool ") > 0){ returnType = "bool"; }
else if(line.IndexOf(" int32 ") > 0){ returnType = "int32"; }
else if(line.IndexOf(" string ") > 0){ returnType = "string"; }
return returnType;
}
private bool ProcessMethodHide(String[] lines, ref int indexLines,
Stream message, Stream key){
String returnType = GetReturnType(lines[indexLines]);
if(returnType != null){
positionInitLocals = positionRet = 0;
SeekLastLocalsInit(lines, ref indexLines,
ref positionInitLocals, ref positionRet);
CopyBlockAdjustStack(lines, indexLines, positionRet);
indexLines = positionRet;
if(returnType != "void"){
writer.Write(writer.NewLine);
writer.WriteLine(".locals init ("+returnType+" returnvalue)");
writer.WriteLine("stloc returnvalue");
}
int keyValue;
for(int n=0; n<bytesPerMethod; n+=4){
}
if(returnType != "void"){
writer.WriteLine("ldloc returnvalue");
}
}
}
We only have to skip the line ldloc returnvalue
, when extracting the hidden message.
private bool ProcessMethodExtract(String[] lines, ref int indexLines,
Stream message, Stream key){
bool isMessageComplete = false;
int positionRet,
positionStartOfMethodLine;
String returnType = GetReturnType(lines[indexLines]);
int keyValue = 0;
if(returnType != null){
positionRet = SeekRet(lines, ref indexLines);
if(bytesPerMethod == 0){
indexLines = positionRet - 2;
}else{
linesPerMethod = GetLinesPerMethod(key);
indexLines = positionRet - linesPerMethod;
}
if(returnType != "void"){
indexLines--;
}
}
}
Now we can use a key file, and exploit most of the methods. If you want to make use of more methods, you only have to adjust the method GetReturnType
. Adding more variations of dummy-code is more difficult, you have to change ProcessMethodHide
, ProcessMethodExtract
and GetLinesPerMethod
- and remember to raise the .maxstack
value if needed.