津门杯 Injected/Tunnel WP

 

周末的时候玩了一下津门杯,感觉遇到了几个比较有意思的题目,这边写一个wp用作记录。

Injected

这个逆向题目比较有趣,考察了比较多的知识点。

寻找入口点

首先下载的文件叫做Dbgview-Infected.exe,并且整个icon就和Dbgview长得一样,结合题目来看,应该是修改了一个真正的dbgview。于是我们用ida打开检查一下,发现整个exe里面多了一个段:

这个.easyre明显是外部注入的代码段。但是观察会发现,大部分的地方并不能被解析成代码,大部分的地方不知道如何解释

单纯的逆向又会发现这部分的逻辑比较复杂,所以首先需要找到程序的入口点。考虑到这个程序是被静态注入的,那么只要能够找到原先的程序,进行简单的比较,应该能找到作者修改的部分
我们这边检查一下文件的属性

可以发现是4.76.0的dbgview,这个版本的英文版本在网上很难找到下载的软件。不过联想到这个程序打开之后是一个中文的dbgview界面,这个会不会是国内的人汉化之后的版本呢?找了一圈之后真的找到了一个汉化的版本,正好也是4.76.0。于是直接跑一个bindiff对比一下:

对比后发现,确实有两个函数不一样,不过第一个函数sub_0401a30感觉可能是bindiff本身识别的问题,实际上代码非常相近。关键是第二个函数sub_0040f8b0

可以看到,注入的dbgview中,多了一段强制跳转的逻辑,很显然这就是题目的入口,我们跟随提示找到相关的代码:

  if ( a2 == 272 )
  {
    v23.hInstance = hInstance;
    v23.hwndOwner = dword_44F9E0;
    v23.lStructSize = 76;
    v23.lpstrFilter = "DebugView Dump (*.dmp)";
    v23.lpstrCustomFilter = 0;
    v23.nMaxCustFilter = 0;
    v23.nFilterIndex = 1;
    v23.lpstrFile = aMemoryDmp;
    v23.nMaxFile = 260;
    v23.lpstrFileTitle = 0;
    v23.nMaxFileTitle = 0;
    v23.lpstrInitialDir = 0;
    v23.lpstrTitle = "Open crash dump...";
    v23.nFileOffset = 0;
    v23.nFileExtension = 0;
    v23.lpstrDefExt = "*.dmp";
    v23.lpfnHook = 0;
    v23.Flags = 2103296;
    GetOpenFileNameA(&v23);
    dword_487000 = 65765608;
    JUMPOUT(0x487000);
  }

这边会将65765608赋值给dword_487000,此时这个地址会变成:

.easyre:00487004 ; ---------------------------------------------------------------------------
.easyre:00487004                 call    loc_4C5B89
.easyre:00487004 ; ---------------------------------------------------------------------------
;---------------------
;---------------------
;---------------------
;---------------------
.easyre:004C5B89                 sub     esp, 2CCh
.easyre:004C5B8F                 push    ebx
.easyre:004C5B90                 push    ebp
.easyre:004C5B91                 push    esi
.easyre:004C5B92                 mov     esi, [esp+2DCh]
.easyre:004C5B99                 xor     ebx, ebx
.easyre:004C5B9B                 push    edi
.easyre:004C5B9C                 mov     edi, ebx
.easyre:004C5B9E                 mov     eax, [esi+238h]
.easyre:004C5BA4                 or      eax, [esi+23Ch

这边就是注入地址的真正入口。从代码可以看到,这一段应该是dbgview中解析dmp的时候的相关逻辑,然后我们打开dbgview,和dmp相关的功能只有一处:

基本可以确认这边就是真正的程序入口。
回看到题目名称叫做injected,出了段注入之外,很容易想到的是可能存在远程线程注入。于是这边使用procmon观测程序,并且尝试触发程序入口,会发现有一个子进程启动的过程:

可以猜测到,这个程序肯定会有一个启动子进程的逻辑。

注入与动态调试

由于这段代码会有一些IDA无法处理的异常(或者我配置出现问题),这边使用x32dbg进行调试。首先我们一直调试,会发现整个代码将00487004+5,也就是我们的call指令之后的几个字节当作了数据段使用,将这个数据段存放在了esi中。代码检查了当前的数据段,并且发现再偏移238出的qword==0,于是跳转到了sub_4C6CA9

在这个函数,程序首先调用sub_4C7C08进行内存中的API加载,会加载如下的代码

  • VirtualAlloc
  • VirtualFree
  • RtlExitUserProcess

那之后,程序会分配一个[edi]大小的内存堆块,分配一个可读写的内存块。之后会完成一个内存拷贝并且解密的过程。当完成了相关的check之后,便会尝试加载解密后内存中的指定的dll:

   LoadLibrary = LoadFunction(addr, addr[12], addr[13], addr[10], addr[11]);
    addr[12] = LoadLibrary;
    if ( !LoadLibrary )
      return -1;
    v11 = (char *)(addr + 145);
    while ( 1 )
    {
      v12 = *v11;
      v13 = 0;
      if ( !*v11 )
        break;
      v14 = v11;
      do
      {
        if ( v12 == 59 )
          break;
        if ( v13 >= 0x104 )
          break;
        v14[v35 - v11] = v12;
        ++v13;
        v12 = *++v14;
      }
      while ( *v14 );
      if ( !v13 )
        break;
      v35[v13] = 0;
      v11 += v13 + 1;
      ((void (__stdcall *)(char *))addr[12])(v35);

之后,会根据edi中指向的edi,完成剩余所有dll中的API导出:

    if ( addr[144] > 1u )
    {
      v16 = addr + 13;
      v17 = addr + 14;
      do  // addr[144] -> kernel32中的API个数
      {
        v18 = LoadFunction(addr, *v17, v17[1], addr[10], addr[11]);
        *v16 = v18;
        if ( !v18 )
          goto LABEL_56;
        ++v15;
        v17 += 2;
        ++v16;
      }
      while ( v15 < addr[144] );
    }

之后,代码会比较[edi+6e4]处的flag,此时我们的flag为1,于是进入和1相关的处理逻辑:

.easyre:004C6E99                 push    edi
.easyre:004C6E9A                 call    sub_4C5FE0
.easyre:004C6E9F                 pop     ecx
.easyre:004C6EA0                 test    eax, eax
.easyre:004C6EA2                 jnz     short loc_4C6EB1
.easyre:004C6EA4                 cmp     dword ptr [edi+56Ch], 2
.easyre:004C6EAB                 jz      loc_4C6FF2
.easyre:004C6EB1
.easyre:004C6EB1 loc_4C6EB1:                             ; CODE XREF: sub_4C6CA9+1F9↑j
.easyre:004C6EB1                 push    edi
.easyre:004C6EB2                 call    sub_4C60C6
.easyre:004C6EB7                 pop     ecx
.easyre:004C6EB8                 test    eax, eax
.easyre:004C6EBA                 jnz     short loc_4C6EC9
.easyre:004C6EBC                 cmp     dword ptr [edi+56Ch], 2
.easyre:004C6EC3                 jz      loc_4C6FF2

第一个函数sub_4C5FE0内部首先load了Amsi的dll,并且将其中的

  • AmsiScanString
  • AmsiScanBuffer

这两个API进行了patch:

上述的两个API为微软提供的一个可以用于扫描缓冲区/字符串中的恶意软件的API

(patch后的相关函数)
第二个函数sub_4C60C6则提供了关于wldp的load,并且将以下的函数进行了patch

  • WldpQueryDynamicCodeTrust
  • WldpIsClassInApprovedList

这两个函数是wldp提供的用于支持DynmaicCodePolicy策略的函数,详情可以参考看雪的这篇文章,大致来讲就是,这两个API可以检查内存中的一些函数是否发生了hook,以及一些CLSID接口的函数是否是可信任的。通常来说,这两个函数可以用来检查前面提到的AmsiScanStringAmsiScanBuffer是否发生了hook,以及一些com对象是否被劫持。

完成了一些防御绕过之后,进入如下的逻辑:

         switch ( *v20 )
          {
            case 3:
            case 4:
              sub_4C73B3(addr, v20);
              break;
            case 1:
            case 2:
              if ( sub_4C6AA9(addr, v20, v34) )
                sub_4C706E(addr, v20, v34);
              sub_4C6581(addr, v34);
              break;
            case 5:
            case 6:
              sub_4C77ED(addr, v20);
              break;
          }

初次运行到这边的时候,这边的值为4,于是进入sub_4C73B3。这边首先获取了当前程序的基地址,并且分配了一个大小为44000大的RWX空间。然后将一个之前从.easyre解密后的一个模块拷贝到了当前空间:

之后依然是读取Kernel32.dll,将相关的API填充到对应的位置上,最后跳转到指定的函数地址:

  if ( *(_DWORD *)(a2 + 4) )
  {
    v35 = (*(int (__stdcall **)(_DWORD, _DWORD, void (__stdcall *)(_DWORD), _DWORD, _DWORD, _DWORD))(v2 + 92))(// CreateThread
            0,
            0,
            entry,
            0,
            0,
            0);
    if ( v35 )
      (*(void (__stdcall **)(int, int))(v2 + 88))(v35, -1);
  }
  else
  {
    entry(NtCurrentTeb()->ProcessEnvironmentBlock);
  }

进入这段逻辑之后,会发现这段逻辑非常熟悉,基本类似于windows的main函数外部,用于准备各类全局变量和环境变量的加载部分:

于是我们将这一段内存进行dump,可以看到主要逻辑:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char *v3; // edi
  struct _PROCESS_INFORMATION ProcessInformation; // [esp+8h] [ebp-324h] BYREF
  CONTEXT Context; // [esp+18h] [ebp-314h] BYREF
  struct _STARTUPINFOA StartupInfo; // [esp+2E4h] [ebp-48h] BYREF

  ((void (__cdecl *)(struct _STARTUPINFOA *, _DWORD, int))(&byte_28F518C + 4117))(&StartupInfo, 0, 68);
  ProcessInformation = 0i64;
  CreateProcessA(0, msiexec, 0, 0, 0, 4u, 0, 0, &StartupInfo, &ProcessInformation);
  Context.ContextFlags = 65537;
  GetThreadContext(ProcessInformation.hThread, &Context);
  v3 = (char *)VirtualAllocEx(ProcessInformation.hProcess, 0, 0x278F0u, 0x1000u, 0x40u);
  WriteProcessMemory(ProcessInformation.hProcess, v3, dword_29061E8, 0xED80u, 0);
  WriteProcessMemory(ProcessInformation.hProcess, v3 + 60800, dword_2914F70, 0xED80u, 0);
  WriteProcessMemory(ProcessInformation.hProcess, v3 + 121600, dword_2923CF8, 0x9DEEu, 0);
  Context.Eip = (DWORD)v3;
  Context.ContextFlags = 65537;
  SetThreadContext(ProcessInformation.hThread, &Context);
  ResumeThread(ProcessInformation.hThread);
  Sleep(0xEA60u);
  VirtualFreeEx(ProcessInformation.hProcess, v3, 0xED81u, 0x4000u);
  return 0;
}

上述代码做了如下的事情:

  • 创建了一个msiexec的进程,并且将其挂起
  • 往进程地址空间写入了三段内存空间,经过调试可以知道,其中有两段是加密的数据段,还有一个和之前injected的代码段一致
  • 使用ThreadContext修改了主线程的地址,然后重新运行线程,让子进程运行注入的shellcode

子进程调试以及最终解密

我们使用另一个x32dbg依附到子进程,并且根据我们父进程代码

Context.Eip = (DWORD)v3;

找到我们需要调试的函数地址:

然后让父进程运行到ResumeThread之后,子进程这边开始活动。我们直接让其运行到断点处,发现这边的代码大致上和.easyre开头的代码一致

之后的逻辑和之前很相似,也是进行了一些安全函数的Patch,然后解密一段逻辑,并且读取Kernel32中指定的API,最终跳转到一个被设置为RWX的内存空间上。最终我们用类似的方法找到对应的main函数地址:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  NumberOfBytesRead = 0;
  Buffer[0] = 0;
  sub_2E77100(&Buffer[1], 0, 999);
  *(_DWORD *)v12 = 0x534B675B;
  *(_WORD *)&v12[4] = 0x530F;
  v12[6] = 0x63;
  v3 = CreateFileA(aFlag, 0xC0000000, 0, 0, 3u, 0x80u, 0);
  if ( v3 == (HANDLE)-1 )
  {
    GetLastError();
    result = 0;
  }
  else
  {
    GetFileSizeEx(v3, &FileSize);
    if ( FileSize.QuadPart >= 0xFFFFFFFFFi64
      && ReadFile(v3, Buffer, 0x19u, &NumberOfBytesRead, 0)
      && (!self_str_cmp((int)Buffer, aFlag_0, 5u) || Buffer[24] == '}') )
    {
      v5 = *(_DWORD *)&Buffer[16];
      v6 = *(_DWORD *)&Buffer[20];
      i = 0;
      v13 = *(_DWORD *)&Buffer[12] + 0x6FC4108B;
      while ( 1 )
      {
        v8 = Buffer[i + 5];
        if ( v8 <= 97 || v8 >= 122 || sub_2E71CB0(v8, 2) != v12[i] )
          break;
        if ( (unsigned int)++i > 6 )
        {
          if ( *(_DWORD *)&Buffer[12] / 0x1DD7u == 0x18E82 )
          {
            v9 = 32;
            v10 = 0;
            do
            {
              v10 += v13;
              v5 += (v10 + v6) ^ (*(_DWORD *)Buffer + 16 * v6) ^ (*(_DWORD *)&Buffer[4] + (v6 >> 5));
              v6 += (v10 + v5) ^ (*(_DWORD *)&Buffer[12] + (v5 >> 5)) ^ (*(_DWORD *)&Buffer[8] + 16 * v5);
              --v9;
            }
            while ( v9 );
            *(_DWORD *)&Buffer[16] = v5;
            *(_DWORD *)&Buffer[20] = v6;
            if ( v5 == 0xC0CEE32 && v6 == 0xB7F3D728 )
            {
              v11 = sub_2E72A60();
              sub_2E72D20(v11);
            }
          }
          break;
        }
      }
    }
    result = 0;
  }
  return result;
}

于是可以分析出如下的逻辑:

  • 代码首先会打开一个叫做flag的文件,并且其长度可以推测要为25
  • 读入的字符串前五个字节为flag{,最后一个字母为}
  • sub_2E71CB0函数会决定flag中(出去前缀)的前七个字符为多少,这个函数为一个简单的递归函数。
  • 接下来的四个字符可通过运算得到
  • 以前16个字符为密钥,剩下的内容为密文进行TEA加密
  • 最终检查加密结果是否为0xC0CEE320xB7F3D728

于是我们可以根据上述逻辑写出最终的解题:

#include<iostream>
#include<string>
#include<Windows.h>


unsigned int __fastcall sub_4541CB0(unsigned __int8 chr, int num)
{
    char v2; // al
    int v3; // edx
    unsigned int v4; // ebx
    char v6; // [esp+Ch] [ebp-4h]

    v2 = num;
    v3 = num - 1;
    v6 = v2;
    if (v3 < 0)
    {
        v4 = chr;
    }
    else
    {
        v4 = sub_4541CB0(chr, v3);
        v2 = v6;
    }
    return ((v4 & (1 << (6 - v2))) >> (2 * (3 - v2))) | ((v4 & (1 << v2)) << (2 * (3 - v2))) | v4 & ~(1 << v2) & ~(1 << (6 - v2));
}

void decrypt(uint32_t v[2], const uint32_t k[4]) {
    unsigned int boundary = 0x18E82* 0x1DD7;
    unsigned int delta = 0x9E3779B9;
    unsigned int sum = 0xC6EF3720;
    uint32_t v0 = v[0], v1 = v[1], i;  /* set up; sum is 32*delta */
    uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3];   /* cache key */
    for (i = 0; i < 32; i++) {                         /* basic cycle start */
        v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
        v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
        sum -= delta;
    }                                              /* end cycle */
    v[0] = v0; v[1] = v1;
}

void encrypt(uint32_t v[2], const uint32_t k[4]) {
    uint32_t v0 = v[0], v1 = v[1], sum = 0, i;           /* set up */
    uint32_t delta = 0x9E3779B9;                     /* a key schedule constant */
    uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3];   /* cache key */
    for (i = 0; i < 32; i++) {                         /* basic cycle start */
        sum += delta;
        v0 += ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
        v1 += ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
    }                                              /* end cycle */
    v[0] = v0; v[1] = v1;
}

int main()
{
    // msiex
    unsigned char table[] = { 0x5b,0x67,0x4b,0x53,0x0f,0x53,0x63};
    for (int i = 0; i < 7; i++)
    {
        for (unsigned char c = 0x61; c <= 0x7a; c++)
        {
            if (sub_4541CB0(c, 2) == table[i])
            {
                printf("%c", c);
                break;
            }
        }
    }
    puts("----");
    // .si.
    unsigned int boundary = 0x2e73692e;
    unsigned int num = boundary + 0x6FC4108B;
    char buffer[10] = { 0 };
    //unsigned int check[10] = { 0xC0CEE32 , 0xB7F3D728 };
    unsigned char check[9] = "\x32\xee\x0c\x0c\x28\xd7\xf3\xb7";
    // unsigned int check[2] = {0xB7F3D728,0xC0CEE32 };

    unsigned char input_string[] = "flag{msiexec.is.";
    decrypt((uint32_t*)check, (uint32_t*)input_string);
    printf("%s\n", check);
}

至此,整个解题流程结束

 

misc tunnel

本题通过出题的方式介绍了一种基于DNS的攻击手段

流量检查

从名字上看,本题应该是使用了某种通信的加密技术,打开流量包检查相关信息:

可以看到这边请求的url非常有规律,都是以evil.im作为后缀,并且前缀有类似base64后的字符串。根据以前搞事情的了解,这里应该是使用了一种叫做DNS tunnel的技巧。

DNS tunnel

这边简单介绍一下这种技巧。DNS(Domain Name System),也就是域名解析系统,是一种能够将数字ip与域名形成映射的协议。DNS在解析的过程中,当本地没有缓存的时候,会尝试以 根服务器->顶级服务器->二级域名服务器 等递归的方式对url进行解析,最终找到url对应的真实IP

如上图。假设test.com对应的就是我们的服务器,那么dns请求最终就会得到我们的服务器的ip。那么,如果在这个基础上,我们再增加一个子域名,类似于c2VjcmV0.test.com,那么根据dns递归查找的原理,此时dns会尝试在我们的服务器上进行123的解析,最终会变成如下的形式:

于是,我们就通过dns请求,将我们想要传递的信息发送给了我们自己的服务器。
这种多此一举的操作是为啥呢?其实就是为了隐蔽传输的信息,将某些特殊的信息藏在dns流量中,甚至绕过某些认证,进行网络通信,这边就不仔细展开

解析dns

那么既然知道url中藏有重要的通信内容,这边就需要尝试分析流量。从图1可以知道,整个通信过程中似乎进行了A类和AAAA类两种请求方式,不过乍一看传输数据都是类似的,于是这边我们首先使用tshark将所有需要分析的dns解析的域名导出

tshark -r tunnel.pcap -T fields -e dns.qry.name -Y 'ip.src == 192.168.1.103 && ip.dst == 8.8.8.8 && dns.qry.type eq 1'
tshark -r tunnel.pcap -T fields -e dns.qry.name -Y 'ip.src == 192.168.1.103 && ip.dst == 8.8.8.8 && dns.qry.type eq 28'

这边1是A记录,28是AAAA记录。导出之后,我们比对一下两个导出结果的差异,会发现A记录比AAAA记录多了好几条,说明可能A才是我们要找的记录。然后我们将导出的dns tunnel的数据进行解密:

import base64
fd = open("dns2.txt",'r')
content = []
for eachline in fd:
    content.append(eachline)

fd.close()
print(content)
contents = b""
index = 0
passwd = []
for index in range(len(content)):
    if "evil.im" in content[index]:
    # if ("evil.im" in content[index] and 
    # (index ==0 or content[index-1] != content[index])):
        eachline = content[index].strip()
        print(eachline)
        cont = eachline.split(".")[0]
        if len(cont) % 4 != 0:
            cont += '='*(4-(len(cont) % 4))
        # contents += cont.encode("utf-8")
        cont_out = base64.standard_b64decode(cont)
        contents += cont_out

# out = base64.standard_b64decode(contents)
fd = open("out2",'wb')
fd.write(contents)
# fd.write(out)
fd.close()

解密完,会发现二进制文件经典PK头,所以又是一个zip包,打开后发现是一个加了密的图片。

密钥与base64

最初我以为密钥藏在了流量中,不过剩余流量并没有分析的价值,直到队友提醒可能和base64隐写有关。关于base64隐写,其实最关键的点就在于,base64在加密过程中,发生了信息膨胀,这就意味着这里进行了base64隐写。详情可以参考这边博客
这边尝试对第一条导出的base64进行了解码再编码,发现果然不相等,说明确实存在base64隐写的问题:

In [10]: test = b"UEsDBDMAAwBjAJ12k1KDFWibyjR="
In [11]: base64.standard_b64encode(base64.standard_b64decode(test))
Out[11]: b'UEsDBDMAAwBjAJ12k1KDFWibyjQ='

于是修改了一下导出脚本,将密钥导出也放在其中:

import base64
fd = open("dns2.txt",'r')
content = []
for eachline in fd:
    content.append(eachline)

fd.close()
print(content)
contents = b""
index = 0
passwd = []
for index in range(len(content)):
    if "evil.im" in content[index]:
    # if ("evil.im" in content[index] and 
    # (index ==0 or content[index-1] != content[index])):
        eachline = content[index].strip()
        print(eachline)
        cont = eachline.split(".")[0]
        if len(cont) % 4 != 0:
            cont += '='*(4-(len(cont) % 4))
        # contents += cont.encode("utf-8")
        passwd.append(cont)
        cont_out = base64.standard_b64decode(cont)
        contents += cont_out


# print(contents)
print(passwd)

# out = base64.standard_b64decode(contents)
fd = open("out2.zip",'wb')
fd.write(contents)
# fd.write(out)
fd.close()

# try to find password
b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
bin_str = ''
result_offset = []
# fd = open("diff.txt",'r')
for eachline in passwd:
    # eachline = eachline.strip()

    # if len(eachline) % 4 != 0:
    #     eachline += '='*(4-(len(eachline) % 4))
    # print(eachline)
    out = base64.standard_b64encode(base64.standard_b64decode(eachline.encode('utf-8')))
    print("test:"+eachline)
    out = out.decode('utf-8')
    if out != eachline:
        print("encrypted!")
        print(out,eachline)
        offset = abs(b64chars.index(eachline.replace('=','')[-1])-b64chars.index(out.replace('=','')[-1]))
        equalnum = eachline.count('=') 
        print(offset)
        result_offset.append(offset)
        if offset != 0:
            # bin_str += bin(offset)[2:].zfill(equalnum * 2)
            bin_str += bin(offset)[2:].zfill(equalnum * 2)
            # bin_str += (4-equalnum*2)*'0'
        # else:
        #     bin_str += '0' * equalnum * 2
        print(bin_str)
    else:
        print("not encrypted!")
        equalnum = eachline.count('=') 
        bin_str += '0' * equalnum * 2
    print(''.join([chr(int(bin_str[i:i + 8], 2)) for i in range(0, len(bin_str), 8)]))

print(result_offset)
# for i in range(0, len(bin_str),8):
#     print(chr(int(bin_str[i:i+8],2)))

可以隐藏的内容为:

password: B@%MG"6FjbS8^c#r

于是最终能够解开压缩包,得到答案。

 

一点思考

这次津门杯虽然有一些题目也是原题修改,不过我觉得Injected和tunnel两个题目还是非常有趣的。Injected中感觉使用了一种真实攻击样例中使用的工具进行题目的封装,里面甚至还有一些关于WLDQ的一些非常新的防御绕过的机制。而tunnel则是以出题的方式介绍了DNS tunnel,这两个题目能作为引子,让人尝试了解DynamicCodePolicy以及DNS tunnel,个人感觉还是不错的。

 

参考链接

https://www.tr0y.wang/2017/06/14/Base64steg/

(完)