在 .Net (C#) 中解密 mcrypt 文件

Posted

技术标签:

【中文标题】在 .Net (C#) 中解密 mcrypt 文件【英文标题】:Decrypting an mcrypt file in .Net (C#) 【发布时间】:2019-02-27 10:54:00 【问题描述】:

我成为 C# 爱好者已有一段时间了,我会考虑具备中级开发技能,但几乎没有加密知识。作为辅助项目的一部分,我需要解密使用 MCrypt 加密的文件。似乎没有将任何特殊参数传递到命令中。例如,这很常见(密钥和文件名已更改)并且密钥的长度不同,从 14 到 18 个字符不等。 mcrypt -a rijndael-256 fileToEncrypt.tar.gz -k 0123456789abcdef1

到目前为止,我已经采取了两种方法来完成这项任务。第一种是使用 mcrypt.exe 并使用Process 启动该进程。但是,我觉得这使得代码(和程序流程)非常笨拙。二是尝试从我的内部程序中直接解密文件,外部程序依赖为零;我想走这条路。

我对 MCrypt 格式有点困惑。我已经查看了源代码中的 FORMAT 文档(here 在线查看),我相信我已经妥善处理了标题的开头部分。但是,我似乎无法解密文件中的加密数据。

1) IV 有多大,如何将其传递到解密器中? 2) 文件末尾的校验和有多大,我需要它吗? 3) 上面的长度是静态的吗? 4) 什么是 keymode (mcrypt-sha1) 以及它是如何使用的? 5)我注意到,当正确解密(使用 mcrypt.exe)时,加密文件和解密文件之间存在 140 字节的差异。这 140 个字节由什么组成?

下面的代码和加密文件的开头;毫无疑问,我的代码从注释“获取数据”开始是错误的 任何指向正确方向的指针将不胜感激。

/// <summary>
/// Decrypt an mcrypt file using rijndael-256
/// </summary>
/// <param name="inputFile">File to decrypt</param>
/// <param name="encryptionKey">Password</param>
/// <param name="purge"></param>
public static bool Decrypt (string inputFile, string encryptionKey)

    var rv = false;
    if (File.Exists(inputFile) == true)
    
        using (FileStream stream = new FileStream(inputFile, FileMode.Open))
        
            var buffer = new byte[1024];

            // MCrypt header
            stream.Read(buffer, 0, 3);

            if (buffer[0] == 0x00 && buffer[1] == 0x6D && buffer[2] == 0x03)
            
                // Flag
                // Bit 7 - Salt Used
                // Bit 8 - IV not used
                var flag = (byte)stream.ReadByte();

                byte[] saltVal = null;
                var saltUsed = Utils.GetBit(flag, 6);
                byte[] ivVal = new byte[16];
                var ivUsed = (Utils.GetBit(flag, 7) == false);

                var algorithmName = Utils.GetNullTerminatedString(stream);

                stream.Read(buffer, 0, 2);
                var keyLen = (buffer[1] << 8) + buffer[0];

                var algorithModeName = Utils.GetNullTerminatedString(stream);

                var keygenName = Utils.GetNullTerminatedString(stream);

                if (saltUsed)
                
                    var saltFlag = (byte)stream.ReadByte();
                    if (Utils.GetBit(saltFlag, 0))
                    
                        // After clearing the first bit the salt flag is now the length
                        Utils.ClearBit (ref saltFlag, 0);
                        saltVal = new byte[saltFlag];
                        stream.Read(saltVal, 0, saltFlag);
                    
                

                var algorithmModeName = Utils.GetNullTerminatedString(stream);

                if (ivUsed)
                
                    stream.Read(ivVal, 0, ivVal.Length);
                

                // Get the data - how much to get???
                buffer = new byte[stream.Length - stream.Position + 1];
                var bytesRead = stream.Read(buffer, 0, buffer.Length);

                using (MemoryStream ms = new MemoryStream())
                
                    using (RijndaelManaged rijndael = new RijndaelManaged())
                    
                        rijndael.KeySize = 256;
                        rijndael.BlockSize = 128;

                        var key = new Rfc2898DeriveBytes(System.Text.Encoding.ASCII.GetBytes(encryptionKey), saltVal, 1000);
                        rijndael.Key = key.GetBytes(rijndael.KeySize / 8);
                        //AES.Key = System.Text.Encoding.ASCII.GetBytes(encryptionKey);
                        //AES.IV = key.GetBytes(AES.BlockSize / 8);
                        rijndael.IV = ivVal;

                        rijndael.Mode = CipherMode.CBC;
                        rijndael.Padding = PaddingMode.None;

                        using (var cs = new CryptoStream(ms, rijndael.CreateDecryptor(), CryptoStreamMode.Write))
                        
                            cs.Write(buffer, 0, buffer.Length);
                            cs.Close();

                            using (FileStream fs = new FileStream(inputFile + Consts.FILE_EXT, FileMode.Create))
                            
                                byte[] decryptedBytes = ms.ToArray();
                                fs.Write(decryptedBytes, 0, decryptedBytes.Length);
                                fs.Close();
                                rv = true;
                            
                        
                    
                
            
        
    

    return rv;

编辑 打开详细模式且未指定 rijndael-256 时,我收到以下信息。当我指定算法时,它确实反映在详细输出中;两者都正确解密文件。剧情变厚了……

算法:rijndael-128 密钥大小:32 模式:cbc 关键字模式:mcrypt-sha1 文件格式:mcrypt

此外,在软件的各个部分中用于加密的“密码”范围从 12 到 28 个字符。

【问题讨论】:

60 个八位字节是标头,20 个八位字节是校验和。不确定其他 60 个八位字节的来源。 未加密数据的大小是否可以被 256 位整除?如果不是,那么还有填充使其成为 256 位的倍数,因此最多填充 31 个八位字节。不过,这仍然至少有 29 个八位字节下落不明。 不,解密后大小为322801;加密大小为 322,941。我认为标题扩展到 77。字节 61 是校验和算法名称的空终止符,然后是 IV - 至少根据我指向的 FORMAT 文档。然而,与文档相反,加密解密数据指定 --noiv 然后第 8 位设置为字节 4。此时任何事情都是可能的。 叹息 好的,所以有 15 个八位字节的填充使数据适合 256 位的整个块(假设他们的 Rijndael-256 实现使用 256 位块大小)。不幸的是,在 Rijndael 中,块大小和密钥长度都是可变的,并且调用算法“rijndael-256”并不能告诉我们太多。 实际上,如果校验和按照格式文档的说明进行加密,那么它也需要填充到块大小,因此需要 12 个八位字节的填充。 【参考方案1】:

MCrypt 文件格式

使用mcrypt-2.6.7-win32 进行观察,使用命令mcrpyt.exe --no-openpgp -V test_in.txt 加密以下文件

test_in.txt未加密长度为25字节,上面的命令加密如下,得到文件test_out.txt.nc长度为125字节。

+-------------+----------------------+----------------+---------------------------------------------+
| File Offset | Field Length (bytes) | Field Content  | Description                                 |
+-------------+----------------------+----------------+---------------------------------------------+
| 0           | 1                    | 0x0            | Zero byte                                   |
+-------------+----------------------+----------------+---------------------------------------------+
| 1           | 1                    | 0x6d           | m                                           |
+-------------+----------------------+----------------+---------------------------------------------+
| 2           | 1                    | 0x3            | Version                                     |
+-------------+----------------------+----------------+---------------------------------------------+
| 3           | 1                    | 0x40           | Flags - bit 7 set = salt, bit 8 set = no IV |
+-------------+----------------------+----------------+---------------------------------------------+
| 4           | 13                   | rijndael-128   | Algorithm name                              |
+-------------+----------------------+----------------+---------------------------------------------+
| 17          | 2                    | 32             | Key Size                                    |
+-------------+----------------------+----------------+---------------------------------------------+
| 19          | 4                    | cbc            | Algorithm mode                              |
+-------------+----------------------+----------------+---------------------------------------------+
| 23          | 12                   | mcrypt-sha1    | Key generator algorithm                     |
+-------------+----------------------+----------------+---------------------------------------------+
| 35          | 1                    | 21             | Salt length + 1                             |
+-------------+----------------------+----------------+---------------------------------------------+
| 36          | 20                   | Salt data      | Salt                                        |
+-------------+----------------------+----------------+---------------------------------------------+
| 56          | 5                    | sha1           | Check sum algorithm                         |
+-------------+----------------------+----------------+---------------------------------------------+
| 61          | 16                   | IV data        | Initialisation vector                       |
+-------------+----------------------+----------------+---------------------------------------------+
| 77          | 48                   | Encrypted data | 25 original data + 20 check sum + 3 padding |
+-------------+----------------------+----------------+---------------------------------------------+
| TOTAL       | 125                  |                |                                             |
+-------------+----------------------+----------------+---------------------------------------------+

观察不同场景下的输出,使用了以下block/key/IV大小:

+--------------+--------------------+------------+------------------+
| Algorithm    | Block Size (bytes) | IV (bytes) | Key Size (bytes) |
+--------------+--------------------+------------+------------------+
| rijndael-128 | 16                 | 16         | 32               |
+--------------+--------------------+------------+------------------+
| rijndael-256 | 32                 | 32         | 32               |
+--------------+--------------------+------------+------------------+

对加密前的原始数据进行校验和,并附加到原始数据的末尾。使用的默认校验和算法是 SHA-1,它产生 20 字节的散列。所以,原来的 25 字节数据变成了 45 字节。如果块大小为 128 位(16 字节),则需要 3 个字节的填充才能达到 48 字节的块大小。如果块大小为 256 位(32 字节),则需要 19 字节的填充才能达到 64 字节。零字节用于填充,这在解密过程中很重要,因为原始数据的大小未知,因此不会自动删除这些字节。

读取标题

这里是读取文件头部和尾部加密数据的代码示例。为简洁起见,并非所有辅助函数都包含在内。

public void ReadHeader(Stream stream)

    byte[] buffer = new byte[512];
    stream.Read(buffer, 0, 3);
    if (buffer[0] != 0x0) throw new FormatException($"First byte is not 0x0, invalid MCrypt file");
    if ((char)buffer[1] != 'm') throw new FormatException($"Second byte is not null, invalid MCrypt file");
    if (buffer[2] != 0x3) throw new FormatException($"Third byte is not 0x3, invalid MCrypt file");

    byte flags = (byte)stream.ReadByte();
    KeyGeneratorUsesSalt = (flags & (1 << 6)) != 0;
    HasInitialisationVector = (flags & (1 << 7)) != 1;
    AlgorithmName = ReadNullTerminatedString(stream);
    stream.Read(buffer, 0, 2);
    KeySize = BitConverter.ToUInt16(buffer, 0);
    BlockSize = GetBlockSize(AlgorithmName);

    var cipherModeAsString = ReadNullTerminatedString(stream);
    CipherMode cipherMode;
    if (Enum.TryParse<CipherMode>(cipherModeAsString, out cipherMode))
        CipherMode = cipherMode;

    KeyGeneratorName = ReadNullTerminatedString(stream);

    if (KeyGeneratorUsesSalt)
    
        var saltSize = ((byte)stream.ReadByte()) - 1;
        Salt = new byte[saltSize];
        stream.Read(Salt, 0, saltSize);
    

    CheckSumAlgorithmName = ReadNullTerminatedString(stream);

    if (HasInitialisationVector)
    
        InitialisationVector = new byte[BlockSize / 8];
        stream.Read(InitialisationVector, 0, BlockSize / 8);
    

    int read = 0;
    byte[] remainingData = null;
    using (MemoryStream mem = new MemoryStream())
    
        while ((read = stream.Read(buffer, 0, buffer.Length)) != 0)
        
            mem.Write(buffer, 0, read);
            remainingData = mem.ToArray();
        
    

    EncryptedData = remainingData;

密钥生成

密钥生成算法在标头中指定,默认情况下,MCrypt 格式为 mcrypt-sha1。查看 mcrypt 源,该密钥是使用 mhash 库生成的。它将密码短语与盐相结合,以生成算法所需字节数的密钥(在我查看的两种情况下均为 32 个字节)。我将函数 _mhash_gen_key_mcrypt 从 mhash 库翻译成 C#,如下所示 - 也许它已经在 .NET 框架的某个地方,但如果是这样,我找不到它。

public byte[] GenerateKeyMcryptSha1(string passPhrase, byte[] salt, int keySize)

    byte[] key = new byte[KeySize], digest = null;
    int hashSize = 20;
    byte[] password = Encoding.ASCII.GetBytes(passPhrase);
    int keyBytes = 0;

    while (true)
    
        byte[] inputData = null;
        using (MemoryStream stream = new MemoryStream())
        
            if (Salt != null)
                stream.Write(salt, 0, salt.Length);
            stream.Write(password, 0, password.Length);
            if (keyBytes > 0)
                stream.Write(key, 0, keyBytes);
            inputData = stream.ToArray();
        

        using (var sha1 = new SHA1Managed())
            digest = sha1.ComputeHash(inputData);

        if (keySize > hashSize)
        
            Buffer.BlockCopy(digest, 0, key, keyBytes, hashSize);
            keySize -= hashSize;
            keyBytes += hashSize;
        
        else
        
            Buffer.BlockCopy(digest, 0, key, keyBytes, keySize);
            break;
                        
    

    return key;

解密

我们可以使用标准的 .NET 加密类来完成大部分解密,传入我们通过散列密码和盐生成的 32 字节密钥,并且我们使用 128 位或 256 位风格基于标头中的算法名称。我们通过rijndael.IV = InitialisationVector; 分配从标题中读取的初始化向量(IV)。

/// <summary>
/// Decrypt using Rijndael
/// </summary>
/// <param name="key">Key to use for decryption that was generated from passphrase + salt</param>
/// <param name="keySize">Algo key size, e.g. 128 bit, 256 bit</param>
/// <returns>Unencrypted data</returns>
private byte[] DecryptRijndael(byte[] key, int keySize)

    using (RijndaelManaged rijndael = GetRijndael(key, keySize))
    
        rijndael.IV = InitialisationVector;
        using (MemoryStream unencryptedStream = new MemoryStream())
        using (MemoryStream encryptedStream = new MemoryStream(EncryptedData))
        
            using (var cs = new CryptoStream(encryptedStream, rijndael.CreateDecryptor(), CryptoStreamMode.Read))
                cs.CopyTo(unencryptedStream);

            byte[] unencryptedData = RemovePaddingAndCheckSum(unencryptedStream.ToArray(), GetCheckSumLen());                    
            return unencryptedData;
        
    


/// <summary>
/// Set algorithm mode/settings
/// </summary>
/// <param name="key">Key to use for decryption that was generated from passphrase + salt</param>
/// <param name="keySize">Algo key size, e.g. 128 bit, 256 bit</param>
/// <returns>Instance ready to decrypt</returns>
private RijndaelManaged GetRijndael(byte[] key, int keySize)

    var rijndael = new RijndaelManaged()
    
        Mode = CipherMode, // e.g. CBC
        KeySize = keySize, // e.g. 256 bits
        Key = key, // e.g. 32-byte sha-1 hash of passphrase + salt
        BlockSize = BlockSize, // e.g. 256 bits
        Padding = PaddingMode.Zeros
    ;

    return rijndael;

由于填充样式是零字节,因此在解密过程中不会将其删除,因为我们不知道此时原始数据的大小,因此无论使用何种填充,解密后的数据都将始终是块大小的倍数原始数据的大小。它还将在末尾附加校验和。我们可以简单地从解密块的尾部删除所有零字节,但如果真的以零字节结束,我们就有可能破坏校验和和原始数据。

因此,我们可以一次从尾部向后处理一个字节,并使用校验和来验证我们何时拥有正确的原始数据。

/// <summary>
/// Remove zero padding by starting at the end of the data block assuming
/// no padding, and using the check sum appended to the end of the data to
/// verify the original data, incrementing padding until we match the 
/// check sum or conclude data is corrupt
/// </summary>
/// <param name="data">Decrypted data block, including zero padding and checksum at end</param>
/// <param name="checkSumLen">Length of the checksum appended to the end of the data</param>
/// <returns>Unencrypted original data without padding and without check sum</returns>
private byte[] RemovePaddingAndCheckSum(byte[] data, int checkSumLen)

    byte[] checkSum = new byte[checkSumLen];
    int padding = 0;

    while ((data.Length - checkSumLen - padding) > 0)
    
        int checkSumStart = data.Length - checkSumLen - padding;
        Buffer.BlockCopy(data, checkSumStart, checkSum, 0, checkSumLen);
        int dataLength = data.Length - checkSumLen - padding;
        byte[] dataClean = new byte[dataLength];
        Buffer.BlockCopy(data, 0 , dataClean, 0, dataLength);

        if (VerifyCheckSum(dataClean, checkSum))
            return dataClean;

        padding++;
    

    throw new InvalidDataException("Unable to decrypt, check sum does not match");

SHA1 20 字节校验和可以简单地针对数据进行验证,如下所示:

private bool VerifySha1Hash(byte[] data, byte[] checkSum)

    using (SHA1Managed sha1 = new SHA1Managed())
    
        var checkSumRedone = sha1.ComputeHash(data);
        return checkSumRedone.SequenceEqual(checkSum);
    

就是这样,128位经过3次尝试我们应该得到正确的校验和和相应的原始数据,然后我们将其作为未加密的原始数据返回给调用者。

【讨论】:

你之前的评论(现在是回答)让我走上了移植 _mhash_gen_key_mcrypt 的道路,你打败了我。虽然我没有逐字使用您的代码,但它确实解决了问题。感谢您的努力和详细的解释。

以上是关于在 .Net (C#) 中解密 mcrypt 文件的主要内容,如果未能解决你的问题,请参考以下文章

如何安装 mcrypt 并将 mcrypt.h 添加到我的 C 程序文件中?

PHP如何实现AES加解密

使用 MCRYPT 在 PHP 中加密/解密...结果不一致

如何在没有 mcrypt 的情况下使用 PHP 解密 ClickBank 通知数据?

在PHP中解密密码[关闭]

如何使用之前使用 mcrypt 加密的 OpenSSL 解密字符串?