📜 ⬆️ ⬇️

Protect .net applications from prying eyes

“How to protect the code of your .net application?” Is one of those questions that can be often heard in various forums.

The most common option is obfuscation. On the one hand, it is easy to use, but on the other, it does not reliably hide the source code. I will offer my own version, well suited for utilities, the use of which is assumed by the author himself (or authorized representatives), whose code is undesirable to show.

Protection will be based on encrypting assemblies with a symmetric key and dynamically decrypting them during application operation. The encryption key will be determined by the user at the deployment stage and entered as a password at startup.
')
Let's break everything into stages:
  1. Preliminary work
  2. Password entry
  3. Decryption builds
  4. Override Assembly Downloads
  5. Application launch
  6. Cherry on the cake
  7. Additional project settings

And a separate item will go:
  1. Deploying and encrypting builds


Preliminary work


Since we need to somehow decipher the application before launch, we will make a wrapper that will take on this ungrateful work.

The wrapper will be a regular console application.

Password entry


The entered password must be stored somewhere. Usually, strings are used for these purposes, but in .net they are immutable, which means that the entered password can be easily pulled out by the debugger. To avoid this, we use the special class SecureString (System.Security namespace), which stores its data in encrypted form.
Read the password
private static bool ReadPassword() { ConsoleKeyInfo consoleKey = Console.ReadKey(true); while (consoleKey.Key != ConsoleKey.Enter) { if (consoleKey.Key == ConsoleKey.Escape) { return false; } _password.AppendChar(consoleKey.KeyChar); consoleKey = Console.ReadKey(true); } return _password.Length > 0; } 


When you enter the screen will not display the input characters, and after pressing Enter, the input will end.
_password is a class field in which the password entered by the user is stored.

Decryption builds


Encryption is divided into two types: symmetric and asymmetric. In the symmetric one, the same key is used for encryption and decryption, and the asymmetric one is different.

Since we do not need different keys, we will focus on symmetric encryption.

To decrypt something encrypted, we need three components:

Since the initialization vector is not secret, it can be stored together with encrypted data.

To facilitate the work, we write a special class CryptedData:
Class CryptedData
 public sealed class CryptedData { /// <summary> ///     . /// </summary> public byte[] IV { get; set; } /// <summary> ///    . /// </summary> public byte[] EncryptedSource { get; set; } /// <summary> ///          . /// </summary> public byte[] ToArray() { using (MemoryStream ms = new MemoryStream()) { Store(ms); return ms.ToArray(); } } /// <summary> ///        . /// </summary> /// <param name="output">,     .</param> public void Store(Stream output) { Validate(this); if (!output.CanWrite) { throw new ArgumentException("    ", "output"); } using (BinaryWriter bw = new BinaryWriter(output)) { bw.Write(IV.Length); bw.Write(IV); bw.Write(EncryptedSource.Length); bw.Write(EncryptedSource); } } /// <summary> ///      . /// </summary> /// <param name="input">   .</param> public static CryptedData Create(Stream input) { if (!input.CanRead) { throw new ArgumentException("    ", "input"); } CryptedData data = new CryptedData(); using (BinaryReader reader = new BinaryReader(input)) { int ivLength = reader.ReadInt32(); data.IV = reader.ReadBytes(ivLength); int sourceLength = reader.ReadInt32(); data.EncryptedSource = reader.ReadBytes(sourceLength); } Validate(data); return data; } /// <summary> ///   . /// </summary> /// <param name="data">,   .</param> private static void Validate(CryptedData data) { if (data.IV == null || data.IV.Length == 0) { throw new ArgumentException("IV    "); } if (data.IV.Length > byte.MaxValue) { throw new ArgumentException(" IV     " + byte.MaxValue); } if (data.EncryptedSource == null || data.EncryptedSource.Length == 0) { throw new ArgumentException("Souce    "); } } } 


We will encrypt using the AES algorithm. For convenience, create a low-level wrapper:
AesCryptography class
 public static class AesCryptography { /// <summary> ///   . /// </summary> /// <returns></returns> internal static byte[] CreateIv() { using (AesManaged aes = new AesManaged()) { aes.GenerateIV(); return aes.IV; } } /// <summary> ///  . /// </summary> /// <param name="source">.</param> /// <param name="key"> .</param> /// <param name="iv"> .</param> /// <returns> .</returns> internal static byte[] Encrypt(byte[] source, byte[] key, byte[] iv) { Validate(source, key, iv); using (AesManaged aes = new AesManaged()) { using (ICryptoTransform transform = aes.CreateEncryptor(key, iv)) { using (MemoryStream ms = new MemoryStream()) { using (CryptoStream cs = new CryptoStream(ms, transform, CryptoStreamMode.Write)) { cs.Write(source, 0, source.Length); } byte[] encryptedBytes = ms.ToArray(); return encryptedBytes; } } } } /// <summary> ///  . /// </summary> /// <param name="source">  .</param> /// <param name="key"> .</param> /// <param name="iv"> .</param> /// <returns> .</returns> internal static byte[] Decrypt(byte[] source, byte[] key, byte[] iv) { Validate(source, key, iv); using (AesManaged aes = new AesManaged()) { using (ICryptoTransform transform = aes.CreateDecryptor(key, iv)) { using (MemoryStream ms = new MemoryStream(source)) { using (CryptoStream cs = new CryptoStream(ms, transform, CryptoStreamMode.Read)) { List<byte> bytes = new List<byte>(1024); int b; while ((b = cs.ReadByte()) != -1) { bytes.Add((byte)b); } return bytes.ToArray(); } } } } } /// <summary> ///  . /// </summary> /// <param name="source">.</param> /// <param name="key"> .</param> /// <param name="iv"> .</param> private static void Validate(byte[] source, byte[] key, byte[] iv) { if (source == null) { throw new ArgumentNullException("source"); } else if (source.Length == 0) { throw new ArgumentException("    ", "source"); } if (key == null) { throw new ArgumentNullException("key"); } else if (key.Length == 0) { throw new ArgumentException("    ", "key"); } if (key.Length.IsOneOf(16, 24, 32) == false) { throw new ArgumentException("    128, 192  256  (16, 24, 32 )", "key"); } if (iv == null) { throw new ArgumentNullException("iv"); } else if (iv.Length != 16) { throw new ArgumentException("     128  (16 )", "iv"); } } public static bool IsOneOf<T>(this T value, params T[] values) { return value.IsOneOf(values as IEnumerable<T>); } public static bool IsOneOf<T>(this T value, IEnumerable<T> values) { if (values == null) { throw new ArgumentNullException("values"); } foreach (T t in values) { if (Equals(t, value)) { return true; } } return false; } } 


Add another level of abstraction, for greater modularity:
CryptographyHelper class
 internal static class CryptographyHelper { /// <summary> ///  . /// </summary> /// <param name="source"> .</param> /// <param name="password"> .</param> /// <returns></returns> public static CryptedData Encrypt(byte[] source, SecureString password) { byte[] iv = AesCryptography.CreateIv(); byte[] key = GetKey(password); byte[] encrypted = AesCryptography.Encrypt(source, key, iv); return new CryptedData() { EncryptedSource = encrypted, IV = iv }; } /// <summary> ///  . /// </summary> /// <param name="data">,   .</param> /// <param name="password"> .</param> /// <returns> .</returns> public static byte[] Decrypt(CryptedData data, SecureString password) { byte[] key = GetKey(password); byte[] decrypted = AesCryptography.Decrypt(data.EncryptedSource, key, data.IV); return decrypted; } /// <summary> ///     . /// </summary> /// <param name="key">,   .</param> /// <returns></returns> private static byte[] GetKey(SecureString key) { using (InsecureString insecure = new InsecureString(key)) { using (SHA256Managed sha256 = new SHA256Managed()) { byte[] rawKey = new byte[key.Length]; int i = 0; foreach (char c in insecure) { rawKey[i++] = Convert.ToByte(c); } byte[] hashedKey = sha256.ComputeHash(rawKey); Array.Clear(rawKey, 0, rawKey.Length); return hashedKey; } } } } 


The last method, GetKey, contains a bit of magic.

The first point is that the key length must be equal to 128, 192 or 256 bits. And as a password to start can be a string of arbitrary length. Therefore, simply hash the password string with sha256 and get the desired length.

The second magic is more awesome and connected with SecureString. This class is only available for writing, and to get its contents we need to use unsafe code:
Class InsecureString
 [CLSCompliant(false)] public sealed class InsecureString : IDisposable, IEnumerable<char> { internal InsecureString(SecureString secureString) { _secureString = secureString; Initialize(); } public string Value { get; private set; } private readonly SecureString _secureString; private GCHandle _gcHandle; #if !DEBUG [DebuggerHidden] #endif private void Initialize() { unsafe { // We are about to create an unencrypted version of our sensitive string and store it in memory. // Don't let anyone (GC) make a copy. // To do this, create a new gc handle so we can "pin" the memory. // The gc handle will be pinned and later, we will put info in this string. _gcHandle = new GCHandle(); // insecurePointer will be temporarily used to access the SecureString IntPtr insecurePointer = IntPtr.Zero; RuntimeHelpers.TryCode code = delegate { // create a new string of appropriate length that is filled with 0's Value = new string((char)0, _secureString.Length); // Even though we are in the ExecuteCodeWithGuaranteedCleanup, processing can be interupted. // We need to make sure nothing happens between when memory is allocated and // when _gcHandle has been assigned the value. Otherwise, we can't cleanup later. // PrepareConstrainedRegions is better than a try/catch. Not even a threadexception will interupt this processing. // A CER is not the same as ExecuteCodeWithGuaranteedCleanup. A CER does not have a cleanup. Action alloc = delegate { _gcHandle = GCHandle.Alloc(Value, GCHandleType.Pinned); }; ExecuteInConstrainedRegion(alloc); // Even though we are in the ExecuteCodeWithGuaranteedCleanup, processing can be interupted. // We need to make sure nothing happens between when memory is allocated and // when insecurePointer has been assigned the value. Otherwise, we can't cleanup later. // PrepareConstrainedRegions is better than a try/catch. Not even a threadexception will interupt this processing. // A CER is not the same as ExecuteCodeWithGuaranteedCleanup. A CER does not have a cleanup. Action toBSTR = delegate { insecurePointer = Marshal.SecureStringToBSTR(_secureString); }; ExecuteInConstrainedRegion(toBSTR); // get a pointer to our new "pinned" string char* value = (char*)_gcHandle.AddrOfPinnedObject(); // get a pointer to the unencrypted string char* charPointer = (char*)insecurePointer; // copy for (int i = 0; i < _secureString.Length; i++) { value[i] = charPointer[i]; } }; RuntimeHelpers.CleanupCode cleanup = delegate { // insecurePointer was temporarily used to access the securestring // set the string to all 0's and then clean it up. this is important. // this prevents sniffers from seeing the sensitive info as it is cleaned up. if (insecurePointer != IntPtr.Zero) { Marshal.ZeroFreeBSTR(insecurePointer); } }; // Better than a try/catch. Not even a threadexception will bypass the cleanup code RuntimeHelpers.ExecuteCodeWithGuaranteedCleanup(code, cleanup, null); } } #if !DEBUG [DebuggerHidden] #endif public void Dispose() { unsafe { // we have created an insecurestring if (_gcHandle.IsAllocated) { // get the address of our gchandle and set all chars to 0's char* insecurePointer = (char*)_gcHandle.AddrOfPinnedObject(); for (int i = 0; i < _secureString.Length; i++) { insecurePointer[i] = (char)0; } #if DEBUG string disposed = "¡DISPOSED¡"; disposed = disposed.Substring(0, Math.Min(disposed.Length, _secureString.Length)); for (int i = 0; i < disposed.Length; ++i) { insecurePointer[i] = disposed[i]; } #endif _gcHandle.Free(); } } } public IEnumerator<char> GetEnumerator() { if (_gcHandle.IsAllocated) { return Value.GetEnumerator(); } else { return new List<char>().GetEnumerator(); } } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } private static void ExecuteInConstrainedRegion(Action action) { RuntimeHelpers.PrepareConstrainedRegions(); try { } finally { action(); } } } 


What happens in this code?
  1. Two pieces of code are prepared, one main, which does all the work, the second - the cleanup code in the case of an exception;
  2. The main code creates a new string in which the value of the SecureString will be stored and which will be forcibly cleared in the Dispose method;
  3. Copies data from the SecureString to the internal string via pointers and locks the internal string for the garbage collector;
  4. Through the internal string, we can get the securestring data.

In the Dispose method, the internal string is rubbed through pointers.

It is important to keep the InsecureString instance’s window of life as short as possible in order to minimize the risk of the debugger reading the data of the protected string.

The hashing described above helps in this, since we need an InsecureString instance only to get the hash, and then we work with the hash itself, from which we can’t pull out the original value of SecureString.

Override Assembly Downloads


Since we plan to use encrypted assemblies, we need to change the standard mechanism for loading them.
The application domain (AppDomain) is responsible for loading assemblies, through the special AssemblyResolve event.
AssemblyResolve Handler
 /// <summary> ///   . /// </summary> private static Assembly CurrentDomainOnAssemblyResolve(object sender, ResolveEventArgs args) { string[] fileParts = args.Name.Split(",".ToCharArray()); string assemblyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileParts[0] + ".edll"); string symbolsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileParts[0] + ".epdb"); byte[] assemblyBytes = null, symbolsBytes = null; if (File.Exists(assemblyPath)) { assemblyBytes = DecryptFile(assemblyPath); } if (File.Exists(symbolsPath)) { symbolsBytes = DecryptFile(symbolsPath); } return Assembly.Load(assemblyBytes, symbolsBytes); } /// <summary> ///  . /// </summary> /// <param name="path">  .</param> /// <returns>  .</returns> private static byte[] DecryptFile(string path) { CryptedData data; using (FileStream fs = File.OpenRead(path)) { data = CryptedData.Create(fs); } byte[] bytes = CryptographyHelper.Decrypt(data, _password); return bytes; } 



Since by the time they enter Main they may already be needed, we will redefine the mechanism earlier in the type constructor. In the same place, for convenience, we will fasten handling of exceptions:
Connection handler
 static Program() { AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) => Console.WriteLine(eventArgs.ExceptionObject.ToString()); AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainOnAssemblyResolve; _password = new SecureString(); } 


Application launch


With the launch of all quite simple:
Application launch
 private static void RunApplication() { SetConsoleWindowVisibility(false); App app = new App(); MainWindow window = new MainWindow(); app.Run(window); } 


Cherry on the cake


There are two things left:
  1. Hide the console window before the application appears;
  2. Masking the very existence of the application

Hiding the console window

We will need to import a couple of unmanaged methods.
We connect external functions
 [DllImport("user32.dll")] public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); [DllImport("user32.dll")] static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); 


and hide the window itself
Hide console
 /// <summary> ///    . /// </summary> /// <param name="visible">  .</param> private static void SetConsoleWindowVisibility(bool visible) { IntPtr hWnd = FindWindow(null, Console.Title); if (hWnd != IntPtr.Zero) { if (visible) ShowWindow(hWnd, 1); //1 = SW_SHOWNORMAL else ShowWindow(hWnd, 0); //0 = SW_HIDE } } 


Masking application

We can mask our application by issuing an error immediately after launch.
Main (string [] args)
 [STAThread] public static void Main(string[] args) { //     try { ArgumentException ex = new ArgumentException("There is not enough data to start application"); throw ex; } catch (ArgumentException ex) { Console.WriteLine(ex.ToString()); Console.WriteLine("Press Esc to exit"); } if (!ReadPassword()) return; RunApplication(); } 


It is clear that this will not work against the disassembler, but it will withdraw the idlers.

Additional project settings


In order to work correctly, an insecure code must be enabled in the bootloader assembly. The easiest way to do this is through project settings.



Deploying and encrypting builds


We need to encrypt the assembly immediately after compilation. Let's write a couple of functions for this:
Assembly Encryption
 /// <summary> ///      . /// </summary> private static void EncryptAssemblies() { Wiper wiper = new Wiper(); foreach (string file in Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll")) { byte[] source = File.ReadAllBytes(file); CryptedData crypted = CryptographyHelper.Encrypt(source, _password); string resultPath = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file) + ".edll"); File.WriteAllBytes(resultPath, crypted.ToArray()); //   wiper.WipeFile(file, 3); //File.Delete(file); } string currentAssemblyName = Assembly.GetEntryAssembly().GetName().Name; foreach (string file in Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.pdb")) { if (Path.GetFileNameWithoutExtension(file) == currentAssemblyName) continue; byte[] source = File.ReadAllBytes(file); CryptedData crypted = CryptographyHelper.Encrypt(source, _password); string resultPath = Path.Combine(Path.GetDirectoryName(file), Path.GetFileNameWithoutExtension(file) + ".epdb"); File.WriteAllBytes(resultPath, crypted.ToArray()); //   wiper.WipeFile(file, 3); } } 


In order to wipe the original file, we use the auxiliary class Wiper, which in several passes overwrites the file with random data and then deletes it.
Class wiper
 internal sealed class Wiper { /// <summary> /// Deletes a file in a secure way by overwriting it with /// random garbage data n times. /// </summary> /// <param name="filename">Full path of the file to be deleted</param> /// <param name="timesToWrite">Specifies the number of times the file should be overwritten</param> public void WipeFile(string filename, int timesToWrite) { if (File.Exists(filename)) { // Set the files attributes to normal in case it's read-only. File.SetAttributes(filename, FileAttributes.Normal); // Calculate the total number of sectors in the file. double sectors = Math.Ceiling(new FileInfo(filename).Length / 512.0); // Create a dummy-buffer the size of a sector. byte[] dummyBuffer = new byte[512]; // Create a cryptographic Random Number Generator. // This is what I use to create the garbage data. using (RNGCryptoServiceProvider rng = new RNGCryptoServiceProvider()) { // Open a FileStream to the file. FileStream inputStream = new FileStream(filename, FileMode.Open); for (int currentPass = 0; currentPass < timesToWrite; currentPass++) { // Go to the beginning of the stream inputStream.Position = 0; // Loop all sectors for (int sectorsWritten = 0; sectorsWritten < sectors; sectorsWritten++) { // Fill the dummy-buffer with random data rng.GetBytes(dummyBuffer); // Write it to the stream inputStream.Write(dummyBuffer, 0, dummyBuffer.Length); } } // Truncate the file to 0 bytes. // This will hide the original file-length if you try to recover the file. inputStream.SetLength(0); // Close the stream. inputStream.Close(); // As an extra precaution I change the dates of the file so the // original dates are hidden if you try to recover the file. DateTime dt = new DateTime(2037, 1, 1, 0, 0, 0); File.SetCreationTime(filename, dt); File.SetLastAccessTime(filename, dt); File.SetLastWriteTime(filename, dt); File.SetCreationTimeUtc(filename, dt); File.SetLastAccessTimeUtc(filename, dt); File.SetLastWriteTimeUtc(filename, dt); // Finally, delete the file File.Delete(filename); } } } } 


Afterword


Obviously a weak point of such protection is the need to know the password to anyone who will use the application.
It is also important to understand that the assembly code can be accessed by the disassembler when using the application.

But if there is a need to hide what the utility does, which you yourself use, from prying eyes, this approach justifies itself.

Materials


Source: https://habr.com/ru/post/244031/


All Articles