Click here to Skip to main content
15,887,027 members
Articles / Security

How to Transform Binary Files into Powershell Script(s) in Order to Copy them Silently on a Server

Rate me:
Please Sign up or sign in to vote.
4.73/5 (8 votes)
2 Mar 2024CPOL9 min read 10.3K   12   11
A tool which creates one or more Powershell scripts which in turn recreate one or more binary files
Lately, I have seen a rise in limitations on what actions a user can do on remote desktop services (e.g., Microsoft MSTSC and Citrix Workspace) consisting of forbidding the copy and paste of files through clipboard allowing only plain text for security reasons, sometimes making developer user experience troublesome. I would like to raise the awareness that these measures can be bypassed "easily", even if the plain text copy/paste through clipboard gets disabled.

Introduction

BinaryToPowershellScript is a .NET Core console application which converts one or more binary file(s) into a Powershell script(s) which if executed on a server, recreates exactly the same set of scripted files there.

Background

The main idea is to convert a binary file into a script which recreates the same binary file. I chose Powershell because it's a recent technology which I know and because with its version Powershell Core, it is available for all major platforms (Windows, Linux and Mac), but again everything could be re-implemented in pure bash (Linux) or a bat file (Windows, highly likely in this case encryption won't be available). The conversion from binary to script is done mainly transcoding binary into text and I came up with three possibilities:

  • Use Base64 encoding: This encoding is the standard way for encoding binary files in JSON files/apis. Basically, it subdivides the binary file in group of 6 bits (so a little less than a byte) and maps these 64 possible values into 64 ASCII characters (so 1 byte). This encoding has the weakness of increasing the size of 33% (8 bits / 6 bits), but binary to text conversion is impossible to be done without increasing size. Another caveat is that this encoding could be detected as a counter measure to this kind of scripts, even though this detection could also cause troubles to other legitimate scripts.
  • Use a Hex Text format: This encoding converts each byte of the binary file into a 2 digits ASCII representation of it (YZ, so 2 bytes). This encoding is wasteful since it multiples the original size by a factor of 2. Historically, bytes have always been represented in hex editors in hexadecimal format (for Windows, you can try the good HxD).
  • Use a Decimal format: This encoding converts each byte of the binary file into up 4 digits ASCII representation of it (0-255, from 2 to 4 bytes). It is the most wasteful format, but the output PowerShell script has the simplest code so it should work almost on every instance/configuration of powershell.

Using the Code

You can find the source code on my GitHub page, there is a comprehensive README on how to use the console application with examples of command lines and generated script(s).

I want to concentrate now on the code, basically, we have just one file Program.cs which contains the following code:

C#
using System;
using System.Collections;
using System.ComponentModel;
using System.ComponentModel.Design;
using System.IO;
using System.IO.Compression;
using System.Reflection.Metadata.Ecma335;
using System.Security.Cryptography;
using System.Text;
using CommandLine;

namespace BinaryToPowershellScript
{
    public class Options
    {
        [Option('i', "inputs", Required = true, 
        HelpText = "Specifies the input file(s) to process, 
                    you can use also a wildcard pattern or 
                    specify multiple files separated by space")]
        public IEnumerable<String>? Inputs { get; set; }

        [Option('o', "outputfolder", Required = false, 
        HelpText = "Specify the output folder where all the powershell scripts 
                    will be generated")]
        public String? OutputFolder { get; set; }

        [Option('b', "base64", Required = false, 
        HelpText = "Specify the base64 file format for the powershell script(s)")]
        public bool Base64 { get; set; }

        [Option('d', "decimal", Required = false, 
        HelpText = "Specify the decimal file format for the powershell script(s)")]
        public bool Decimal { get; set; }

        [Option('c', "compress", Required = false, 
        HelpText = "Specify to compress the input file(s) with gzip compression")]
        public bool Compress { get; set; }

        [Option('h', "hash", Required = false, 
        HelpText = "Specify to add a SHA256 hash as check on 
        file(s) integrity in the powershell script(s)")]
        public bool Hash { get; set; }

        [Option('s', "single", Required = false, 
        HelpText = "Specify to create just a single script file for all input files")]
        public bool SingleFile { get; set; }

        [Option('p', "password", Required = false, 
        HelpText = "Specify the password used to encrypt data with AES")]
        public String? Password { get; set; }

        [Option('r', "recurse", Required = false, 
        HelpText = "Specify to perform recursive search on all input file(s)")]
        public bool Recurse { get; set; }
    }

    class Program
    {
        const int KEYSIZE = 256;

        public static void Main(string[] args)
        {
            Parser.Default.ParseArguments<Options>
            (args).WithParsed<Options>(o => CreateScript(o));
        }

        static string ComputeSha256Hash(byte[] bytes)
        {
            using (SHA256 sha256Hash = SHA256.Create())
            {
                return BitConverter.ToString
                (sha256Hash.ComputeHash(bytes)).Replace("-", String.Empty);
            }
        }

        public static byte[] EncryptBytes(byte[] input, string password)
        {
            var pbkdf2DerivedBytes = new Rfc2898DeriveBytes(password, 16, 2000);

            using (var AES = Aes.Create())
            {
                AES.KeySize = KEYSIZE;
                AES.Key = pbkdf2DerivedBytes.GetBytes(KEYSIZE / 8);
                AES.Mode = CipherMode.CBC;
                AES.Padding = PaddingMode.PKCS7;

                using (MemoryStream memoryStream = new MemoryStream())
                {
                    CryptoStream cryptoStream = new CryptoStream
                    (memoryStream, AES.CreateEncryptor(), CryptoStreamMode.Write);

                    memoryStream.Write(pbkdf2DerivedBytes.Salt, 0, 16);  // 16 bytes of 
                    // SALT for PBKDF2 derivation function, must not be encrypted
                    memoryStream.Write(AES.IV, 0, 16);  // IV is always 128 bits for AES, 
                                                        // must not be encrypted
                    cryptoStream.Write(input, 0, input.Length);
                    cryptoStream.FlushFinalBlock();

                    // uncomment this line to debug encryption
                    //Console.WriteLine($"Password {password} Salt 
                    //{BitConverter.ToString(pbkdf2DerivedBytes.Salt)} IV 
                    //{BitConverter.ToString(AES.IV)} Key {BitConverter.ToString(AES.Key)} 
                    //Input {BitConverter.ToString(input)} 
                    //ActualPosition {memoryStream.Length}");

                    return memoryStream.ToArray();
                }
            }
        }

        public static byte[] CopyBytesToStream(byte[] bytes, 
                      bool fromStream, Func<Stream, Stream> streamCallback)
        {
            var inputMemoryStream = new MemoryStream(bytes);
            var outputMemoryStream = new MemoryStream();

            var stream = streamCallback(fromStream ? 
                         inputMemoryStream : outputMemoryStream);

            if (fromStream)
                stream.CopyTo(outputMemoryStream);
            else
            {
                inputMemoryStream.CopyTo(stream);
                stream.Flush();
            }

            return outputMemoryStream.ToArray();
        }

        private static StringBuilder CreateScriptHeader(Options o)
        {
            var script = new StringBuilder();

            if (o.Compress || !String.IsNullOrEmpty(o.Password))
            {
                script.AppendLine(@"
function copyBytesToStream  {
    [OutputType([byte[]])]
    Param (
        [Parameter(Mandatory=$true)] [byte[]] $bytes,
        [Parameter(Mandatory=$true)] [System.Boolean] $fromStream,
        [Parameter(Mandatory=$true)] [ScriptBlock] $streamCallback)

    $InputMemoryStream = New-Object System.IO.MemoryStream @(,$bytes)
    $OutputMemoryStream = New-Object System.IO.MemoryStream

    $stream = (Invoke-Command $streamCallback -ArgumentList 
    $(if ($fromStream) { $InputMemoryStream } else { $OutputMemoryStream }))

    if ($fromStream) {
        $stream.CopyTo($OutputMemoryStream)
    }
    else {
        $InputMemoryStream.CopyTo($stream)
        $stream.Flush()
    }

    $result = $OutputMemoryStream.ToArray()

    ,$result
}

");
            }

            if (!o.Base64 && !o.Decimal)
            {
                script.AppendLine(@"
function StringToByteArray  {
    [OutputType([byte[]])]
    Param ([Parameter(Mandatory=$true)] [System.String] $hexstring)

    [byte[]] $bytes = New-Object Byte[] ($hexstring.Length/2)
    for ($i=0; $i -lt $hexstring.Length;$i+=2) {
        $bytes[$i/2] = [System.Byte]::Parse($hexstring.Substring($i,2),
                       [System.Globalization.NumberStyles]::HexNumber)
    }
    ,$bytes
    }

");
            }

            if (!String.IsNullOrEmpty(o.Password))
            {
                // uncomment these lines and put them in the decryptBytes function below 
                // (row "$Dec = $AES.CreateDecryptor()") to troubleshoot encryption
                //Write - Host ""Password $password""
                //Write - Host ""KEY: $([System.BitConverter]::ToString($AES.Key))""
                //Write - Host ""IV: $([System.BitConverter]::ToString($AES.IV))""
                //Write - Host ""EncryptedData: 
                //$([System.BitConverter]::ToString($EncryptedData))""

                // uncomment these lines and put them in the decryptBytes function below 
                // (row ",$result") to troubleshoot encryption
                //Write - Host ""DecryptedData: 
                //$([System.BitConverter]::ToString($result))""

                script.Append(@$"function decryptBytes {{
    [OutputType([byte[]])]
    Param (
        [parameter(Mandatory=$true)] [System.Byte[]] $bytes,
        [parameter(Mandatory=$true)] [System.String] $password
    ) 

    # Split IV and encrypted data
    $PBKDF2Salt = New-Object Byte[] 16
    $IV = New-Object Byte[] 16
    $EncryptedData = New-Object Byte[] ($bytes.Length-32)
    
    [System.Array]::Copy($bytes, 0, $PBKDF2Salt, 0, 16)
    [System.Array]::Copy($bytes, 16, $IV, 0, 16)
    [System.Array]::Copy($bytes, 32, $EncryptedData, 0, $bytes.Length-32)

    # Generate PBKDF2 from Salt and Password
    $PBKDF2 = New-Object System.Security.Cryptography.Rfc2898DeriveBytes
                ($password, $PBKDF2Salt, 2000)

    # Setup our decryptor
    $AES = [Security.Cryptography.Aes]::Create()
    $AES.KeySize = {KEYSIZE}
    $AES.Key = $PBKDF2.GetBytes({KEYSIZE / 8})
    $AES.IV = $IV
    $AES.Mode = [System.Security.Cryptography.CipherMode]::CBC
    $AES.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7

    $Dec = $AES.CreateDecryptor()

    [byte[]] $result = copyBytesToStream $EncryptedData $true {{ param ($EncryptedStream) 
              New-Object System.Security.Cryptography.CryptoStream
              ($EncryptedStream, $Dec, 
              [System.Security.Cryptography.CryptoStreamMode] 'Read') }} 

    ,$result
}}

");
            }

            var decryptCode = String.IsNullOrEmpty(o.Password) ? 
            String.Empty : "\t\t$bytes = $(decryptBytes $bytes $password)";

            var decompressCodeMultiRow = @"
        if ($decompress) {
            $bytes = copyBytesToStream $bytes $true { param ($EncryptedStream) 
            New-Object System.IO.Compression.DeflateStream($EncryptedStream, 
            [System.IO.Compression.CompressionMode ] 'Decompress') } 
        }
";
            var decompressCode = o.Compress ? decompressCodeMultiRow : String.Empty;


            var hashCodeMultiRow = @"
        if (![System.String]::IsNullOrEmpty($hash)) {
            $actualHash = (Get-FileHash -Path $file -Algorithm Sha256).Hash
            if ($actualHash -ne $hash) {
                Write-Error ""Integrity check failed on $file expected 
                              $hash actual $actualHash!""
            }
        }
";
    var hashCode = o.Hash ? hashCodeMultiRow : String.Empty;

            script.Append($@"function createFile  {{
    param (
        [parameter(Mandatory=$true)] [String] $file,
        [parameter(Mandatory=$true)] [byte[]] $bytes,
        [parameter(Mandatory=$false)] [String] $password,
        [Parameter(Mandatory=$false)] [String] $hash,
        [Parameter(Mandatory=$false)] [System.Boolean] $decompress=$false)
    
        $null = New-Item -ItemType Directory -Path (Split-Path $file) -Force
{decryptCode}
{decompressCode}
        if ($global:core) {{ Set-Content -Path $file -Value $bytes -AsByteStream -Force }} 
        else {{ Set-Content -Path $file -Value $bytes -Encoding Byte -Force }}

{hashCode}
        Write-Host ""Created file $file Length $($bytes.Length)""
    }}

");

            script.Append($"function createFiles  {{\n\tparam 
            ([parameter(Mandatory={(String.IsNullOrEmpty(o.Password) ? 
            "$false" : "$true")})] [String] $password)\n\n");
            script.Append("\t$setContentHelp = (help Set-Content) | 
            Out-String\n\tif ($setContentHelp.Contains(\"AsByteStream\")) 
            { $global:core = $true } else { $global:core = $false }\n\n");

            return script;
        }

        public static void CreateScript(Options o)
        {
            if (String.IsNullOrEmpty(o.OutputFolder))
                o.OutputFolder = Directory.GetCurrentDirectory();
            else
                Directory.CreateDirectory(o.OutputFolder);

            StringBuilder script = CreateScriptHeader(o);

            var outputFile = Path.Combine(o.OutputFolder, $"SingleScript.ps1");

            foreach (var input in o.Inputs)
            {
                var actualCompress = false;

                var path = Path.GetDirectoryName(input);
                foreach (var file in Directory.GetFiles(!String.IsNullOrEmpty(path) ? 
                path : ".", Path.GetFileName(input), o.Recurse ? 
                SearchOption.AllDirectories : SearchOption.TopDirectoryOnly))
                {

                    if (!o.SingleFile)
                    {
                        script = CreateScriptHeader(o);

                        outputFile = Path.Combine(o.OutputFolder, 
                        $"{Path.GetFileName(file).Replace(".", "_")}_script.ps1");
                    }

                    Console.Write($"Scripting file {file} 
                    {(!o.SingleFile ? $"into {outputFile}..." : String.Empty)}");

                    var inputBytes = File.ReadAllBytes(file);
                    var hash = ComputeSha256Hash(inputBytes);

                    if (o.Compress)
                    {
                        var compressedFileBytes = CopyBytesToStream(inputBytes, false, 
                        encryptedStream => new DeflateStream
                              (encryptedStream, CompressionMode.Compress));

                        if (compressedFileBytes.Length < inputBytes.Length)
                        {
                            inputBytes = compressedFileBytes;
                            actualCompress = true;
                        }
                        else
                            Console.Write("compression is useless, disabling it...");
                    }

                    var bytes = String.IsNullOrEmpty(o.Password) ? 
                    inputBytes : EncryptBytes(inputBytes, o.Password);

                    if (o.Base64)
                    {
                        script.Append($"\t[byte[]] $bytes = 
                        [Convert]::FromBase64String('{Convert.ToBase64String(bytes)}')");
                    }
                    else
                    {
                        script.Append(o.Decimal ? "\t[byte[]] $bytes = " : 
                        "\t[byte[]] $bytes = (StringToByteArray '");

                        foreach (var b in bytes)
                        {
                            if (o.Decimal)
                                script.Append($"{b.ToString("D")},");
                            else
                                script.Append($"{b.ToString("X2")}");
                        }

                        if (!o.Decimal)
                            script.Append("')");
                        else
                            script.Length--;
                    }

                    script.Append($"\n\tcreateFile '{file}' $bytes $password 
                    {(o.Hash ? $"'{hash}'" : "''")} 
                    {(o.Compress ? $"${actualCompress}" : "$false")}\n\n");

                    if (!o.SingleFile)
                    {
                        script.Append($"}}\n\ncreateFiles '{o.Password}'\n");

                        var outputScript = script.ToString();
                        File.WriteAllText(outputFile, outputScript);
                        Console.WriteLine($"length 
                                {Math.Round(outputScript.Length / 1024.0)}KB.");
                    }
                    else
                        Console.WriteLine("");
                }
            }

            if (o.SingleFile)
            {
                script.Append($"}}\n\ncreateFiles '{o.Password}'\n");

                var outputScript = script.ToString();
                File.WriteAllText(outputFile, outputScript);

                Console.WriteLine($"Created single script file {outputFile} 
                length {Math.Round(outputScript.Length / 1024.0)}KB.");
            }
        }
    }
}

Here follows the description of the main components of the code:

  • For parsing the command line option, I use the CommandLineParser NuGet package which IMHO is a simple and very effective library to perform parsing. Basically, you create a class Options in which you define a set of properties, each one corresponding to an option of the command line decorating it with an Option custom attribute which specifies the short and long form of the option, the help text, whether it is required or not and the target data type (string, bool, etc.). The parsing happens automatically by just one line of code where you pass as a generic the Options class created previously and you receive in turn an Action with the same Options class containing the parsed parameters, if everything is fine (if not, the help is shown together with the error):
    C#
    Parser.Default.ParseArguments<Options>(args).WithParsed<Options>
                                          (o => CreateScript(o));
  • EncryptBytes: This is a helper function used to encrypt with AES256 algorithm the input bytes with a user password specified in the -p command line option. It uses the standard .NET Aes class to perform encryption using a key derived from password with PBKDF2 key derivation function (class Rfc2898DeriveBytes). Everything is standard so I won't spend too much time explaining this code. I just stress one note: the PBKDF2 key derivation function is not the best one, better alternatives exist (e.g., Scrypt) but they are not natively implemented in .NET Core so external libraries have to be used and in this case, they would need to be embedded also in the output Powershell script(s) in order to allow it to derive again the key from password when decrypting (for now, I decided not to go this way).
  • CopyBytesToStream: This is a helper function which basically passes an array of bytes given in input in a stream in order to compress/decompress/encrypt/decrypt them.
  • CreateScriptHeader: It is a helper function which creates the initial part of the output Powershell script. Basically, it injects these functions into the output Powershell:
    • copyBytesToStream: Same code as above, only implemented also in Powershell.
    • StringToByteArray: which basically converts a hex string into a byte array.
    • decryptBytes: which decrypts the AES encrypted binary and it is only injected if a password is passed in the -p command line parameter
    • createFile: always injected, it basically writes an array of bytes to the disk, creating all the destination folders if not present and decrypting data if needed.
    • createFiles: always injected, it is the main function executed by the script which basically accepts the password as an input parameter and for every input file, it defines the decimal array of bytes or the hex/base64 string of the binary data and calls in turn the createFile function in order to recreate the file.
  • Example of single output Powershell script for multiple input files in base64 format

    Image 1

  • Example of one of multiple files output Powershell script in hex byte array format

    Image 2

  • Example of one of multiple files output Powershell script in decimal byte array format

    Image 3

Points of Interest

The script works perfectly by pasting it in the target remote desktop session without the need to save it into a file and execute it thus bypassing the Set-ExecutionPolicy restrictions (e.g., it works also in the strictest AllSigned execution policy). I'll post a video later with the demo on YouTube showing that the recreated files have the same hash of the original ones.

What to do if remote desktop sessions will forbid the copy and paste of any text as a countermeasure?

Well, if the clipboard gets disabled, as a workaround, you can try the following:

  • Create another program which runs on the client computer which basically starts the remote desktop client, focuses on its window and:
    • sends "Windows+R" to open the run window
    • enters "Powershell" text to start a new powershell window
    • finally sends the output Powershell script as keystrokes.

In Windows, the sending of keystrokes can be achieved by using VBScript SendKeys method of WScript.Shell COM object (read my other article in order to get an idea).

Ok got it, but what happens if as additional countermeasure, the above SendKeys method gets forbidden somehow?

Game over ? No way :-)

IMHO, this won't happen but anyway should be that the case, RDP protocol though proprietary is freely available as an Open Specification from Microsoft so you can try to implement a custom client or adapt an existing open source one like FreeRDP to support keyboard scripting.

Ultimate challenge: What if the server has no network, it does not accept any usb drives or devices and you must go physically there in order to access it?

Well, any physical server in the world will always have two devices:

  • An input device like a keyboard which can be used to inject any sequence of keystrokes, for example, by plugging in a programmable microcontroller like ESP32 which simulates a USB keyboard and sends the output Powershell script as keystrokes on the happening of a particular condition (this is an idea for another project). If the server has only an old PS2 keyboard, you can design or subcontract a custom hardware which acts in the same way. Or if you want a quick solution, you can invest some bucks on a programmable keyboard.
  • An output device like a monitor which can be used to extract silently data from the server by injecting and executing a custom app with the above script method. This app basically will read any binary file and transcodes it into sequences of "QR codes" (or pixel images) which in turn can be recorded and decoded by a mobile phone camera shooting at the monitor. This is nothing new since it was already done in the 90s on an old 32bit computer called Amiga with the Video Backup System which basically transcoded binary files (usually floppy disks images) into sequences of black and white pixel images which were stored on a videotape. As a bonus, one could think also of transcoding into color pixel images in order to increase the density (and therefore, the throughput) of the data, but this is again an idea for another project.

Last but not the least, I want to underline that the script language, be it either Powershell or bash or bat is not so much important (maybe on bat you have some limitations), what is important is the idea behind this implementation, e.g., to recreate a binary file through a well crafted script.

Additional note: In order to bypass the most hardened environments, I have implemented the above C# code in Powershell as well. You can find it on GitHub together with C# code. To bypass Powershell execution policies, you can simply copy the code and paste it inside a Powershell console and hit return. After having done this, you can convert any file you want just by calling the function BinaryToPowershellScript with the same above mentioned parameters.

History

  • V1.0 (9th October, 2023)
    • Initial version
  • V1.0.1(13th October, 2023)
    • Added -c option to compress file(s) through gzip compression, the compression gets automatically disabled if the compressed file size is bigger than original size.
    • Added -h option to add a SHA256 hash as a check on file(s) integrity
    • Improved hex format now it uses 2 bytes instead of 4 for each byte in the input file
    • Added Powershell implementation, it can be used when running an executable is not allowed
    • Added dynamic output file generation: now it generates the helper functions only when the related option is specified (e.g., hashing helper code is added only when you specify -h option, etc.)
    • Added output script length in KB in console output
    • Some bugfixes
  • V1.0.2 (31st January, 2023)
    • Added additional note at the end of Points of Interest section about a further implementation available also in the Powershell language.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
Italy Italy
I'm a senior .NET software engineer in the second half of my forties. Started using a computer at six years, gone through logo, basic, assembly, C/C++, java and finally to .NET and .NET core. Proficient also in databases, especially Sql Server and reporting. Let's say I have also some experience on security but mainly in the past, now things have become much more difficult and I do not have too much time to keep me updated, but sometimes I am still kicking in. Fan of videogames, technologies, motorbikes, travelling and comedy.

Email: Federico Di Marco <fededim@gmail.com>
Linkedin: LinkedIn
Github: GitHub
Stackoverflow: StackOverflow

Comments and Discussions

 
GeneralMy vote of 1 Pin
ObiWan_MCC10-Oct-23 0:44
ObiWan_MCC10-Oct-23 0:44 
GeneralRe: My vote of 1 Pin
Federico Di Marco13-Oct-23 9:31
Federico Di Marco13-Oct-23 9:31 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA10-Oct-23 0:26
professionalȘtefan-Mihai MOGA10-Oct-23 0:26 
GeneralRe: My vote of 5 Pin
Federico Di Marco21-Nov-23 7:33
Federico Di Marco21-Nov-23 7:33 
Questionreduce size Pin
ObiWan_MCC10-Oct-23 0:22
ObiWan_MCC10-Oct-23 0:22 
AnswerRe: reduce size Pin
ObiWan_MCC10-Oct-23 0:42
ObiWan_MCC10-Oct-23 0:42 
AnswerRe: reduce size Pin
Federico Di Marco13-Oct-23 9:34
Federico Di Marco13-Oct-23 9:34 
GeneralBinHex Encoding Pin
Fly Gheorghe9-Oct-23 22:31
Fly Gheorghe9-Oct-23 22:31 
GeneralRe: BinHex Encoding Pin
Federico Di Marco13-Oct-23 9:38
Federico Di Marco13-Oct-23 9:38 
GeneralMy vote of 5 Pin
LightTempler9-Oct-23 10:42
LightTempler9-Oct-23 10:42 
GeneralRe: My vote of 5 Pin
Federico Di Marco21-Nov-23 7:34
Federico Di Marco21-Nov-23 7:34 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.