记录一次完整的协议逆向实践,针对阿里CTF2026中一道名为license的题目,从ELF加载、数据包结构、多层解密解压,到最终构造远程利用链获取FLAG。整个流程涉及多种现代加密与压缩技术的组合应用,极具实战参考价值。
协议流程概览
该程序的通信协议设计复杂,层层嵌套,完整流程如下:
- 数据包 = 长度(4字节)+ nonce(12字节)+ 数据流(长度-12字节)
- 数据流使用AES-GCM解密
- 解密后进行zstd解压缩
- Base64解码
- Protobuf解析出四个字段:
enc_data、password、salt、sha256_hash
- 使用魔改AES-160解密(CBC模式 + PKCS#7填充),密钥由
password和salt经PBKDF2-HMAC-SHA256生成48字节,前28字节为密钥,后20字节为IV
- 解密后为JSON格式,需包含
license_code和sign字段,其中sign为RSA-4096签名
- 全部验证通过后打印FLAG环境变量
ELF加载与动态通信
程序license与build_token之间存在动态通信机制。启动时,license会连接build_token以获取运行所需的数据。通过调试可观察到如下日志输出:
listening on 0.0.0.0:12345 for key exchange... press Ctrl+C to stop
[127.0.0.1:55012] connected, waiting for rsa public key...
[127.0.0.1:55012] received rsa public key, encrypting...
[127.0.0.1:55012] sending package...
[127.0.0.1:55012] package sent successfully...
本地可通过以下命令模拟服务端:
./build_token -p 'r&FGW9RpqTc*aqof' -s -l 0.0.0.0:12345
调试发现,主程序在运行过程中会动态加载由build_token发送的ELF代码段并执行,这是典型的运行时加载技术,增加了静态分析难度。
数据包结构分析
程序首先生成一个UUID作为license_code,随后要求用户输入两段数据:一段4字节,一段12字节。第三处输入长度为第一处减去12(含回车符),因此实际可用长度不可控。为便于测试,可在调试时手动修改长度限制。
最终数据包结构为:
- 前4字节表示总长度(小端序)
- 接着12字节为nonce
- 剩余部分为加密后的数据流
AES-GCM解密实现
数据流使用AES-GCM模式加密。通过对sub_403D10函数的分析,结合CPU是否支持AVX指令集进行分支选择,最终提取出用于密钥扩展的初始密钥:
AES_KEY = bytes.fromhex("f6778d8728d8f17ce8c5c81f45c3d5fd869ca851b7575be540776f4f26c1140d")
GCM模式包含CTR加密与GHASH认证两部分。调试中可见对16个零字节的加密过程,符合AES-256-GCM特征。验证时还会检查数据流末尾16字节是否等于GHASH计算结果。
核心加密逻辑如下:
void __fastcall sub_40C1B0(__m128i *_RDI, const __m128i *a2, _OWORD *a3)
{
_XMM0 = _mm_xor_si128(_mm_loadu_si128(a2), *_RDI);
__asm
{
aesenc xmm0, xmmword ptr [rdi+10h]
aesenc xmm0, xmmword ptr [rdi+20h]
aesenc xmm0, xmmword ptr [rdi+30h]
aesenc xmm0, xmmword ptr [rdi+40h]
aesenc xmm0, xmmword ptr [rdi+50h]
aesenc xmm0, xmmword ptr [rdi+60h]
aesenc xmm0, xmmword ptr [rdi+70h]
aesenc xmm0, xmmword ptr [rdi+80h]
aesenc xmm0, xmmword ptr [rdi+90h]
aesenc xmm0, xmmword ptr [rdi+0A0h]
aesenc xmm0, xmmword ptr [rdi+0B0h]
aesenc xmm0, xmmword ptr [rdi+0C0h]
aesenc xmm0, xmmword ptr [rdi+0D0h]
aesenclast xmm0, xmmword ptr [rdi+0E0h]
}
*a3 = _XMM0;
}
zstd解压缩处理
解密后数据需满足长度不小于13字节的要求。若解压失败,会抛出错误信息Unknown frame descriptor。进一步分析发现,zstd头部魔数为28 B5 2F FD,缺少此标识即报错。
使用Python的zstandard库可轻松完成压缩:
import zstandard as zstd
def zstd_compress(data):
return zstd.compress(data)
Base64与Protobuf解析
Base64解码环节明显,多处查表操作且对padding敏感。上层逻辑显示解压后的数据需为Base64编码字符串。
紧接着是Protobuf解析,字段定义如下:
enc_data:加密后的数据
password:用于密钥派生
salt:盐值
sha256_hash:原始数据SHA256哈希值
通过分析sub_40B960函数,确定字段编号与类型映射关系,符合Protobuf wire format规范,例如:
enc_data → field 1, type LengthDelimited
password → field 2, type LengthDelimited
salt → field 3, type LengthDelimited
sha256_hash → field 4, type LengthDelimited
魔改AES-160解密分析
核心解密算法为一种基于AES思想但参数不同的变体——Rijndael-160(分组大小20字节)。其主要魔改点包括:
S-Box生成方式不同
标准AES S-Box基于有限域GF(2^8)上的逆元运算,而本题使用自定义逻辑生成:
def xtime(a) -> int:
a &= 0xFF
return (((a << 1) ^ 0x8D) & 0xFF) if (a & 0x80) else ((a << 1) & 0xFF)
其中0x8D替代了标准的0x1B作为约简多项式系数。
MixColumn矩阵非标准
MixColumn使用的扩散矩阵为:
[[4, 1, 6, 2],
[2, 4, 1, 6],
[6, 2, 4, 1],
[1, 6, 2, 4]]
该矩阵在GF(2^8)下可逆,确保了解密可行性。
加密与解密功能互换
调试发现,原本应执行加密的地方实际调用了“解密”逻辑,反之亦然。这相当于将整个加解密流程倒置。
CBC模式 + PKCS#7填充
使用PBKDF2-HMAC-SHA256从password和salt生成48字节密钥材料,前28字节作为AES-160密钥,后20字节作为CBC模式的初始IV。
时间戳校验与RSA签名
解密后JSON需包含license_code和sign字段。前者必须与初始生成的UUID一致,后者需通过RSA-4096签名验证。
程序内置一个1024字节的大整数n(模数)和d(私钥指数),用于签名验证。调试发现,sign字段为UUID(去-后转为字节)使用公钥e=65537加密所得。
提取出的关键参数如下:
n = int.from_bytes(bytes.fromhex(
"B75815AF28D17CCE2ECA787C6004EC6F1A51AC14B5A7AF746C7B3DD1354517C3CF38B35BE17E64BAB76A47FEA92396DDA947CC268EE0DACC097911912AB64B4C572D7518003C8B24D9DF5E950D44FE9613805ABDC47EA1F693EB6B04D56A124522126770E9FC771B185EC44F308D57FAA6D3C67A585A5E1672F5F89FADB9B073E913166A99D5A3896BDC6430B1C4A5AECDA7FEFA15AEB41A37E76D61698FF36A2F1B2B0A0CBC1AA0AD068C8ECF9388558B0257335EADC4831397917CE1C9B5E2B033C6935F1B57A06BC830B3D03CC8C8D758A38CD8D85583435B3594A7599F1B692B9FBF0E98B388E6A96D20D6245EF5503F79693552987CCAEC2C86D481A45EEA1C573D33FA15109962E5C8D2A02A6923FB375C6B1F05FC4CDCCC17055AADBA17085FC8C22563DA4FFD05FEBD07A485B5B28C3203890FDABDDD6693C40FCECE05D4FEA9EE46F1447416B7FF4D914A6A5787917637977A3330659D8191CD102093F15ED4D2444E60A4950AE51EBF616721F8785F5656130CFABE2174DBB9F9E5121F8F10670EC0538465B283A02C187989E06C07E3BD3792C5E5E7C49752ADCCFA573DF668C90AB67C61CFC5E46D5CC387D51078D27ADBD8E3AE3CDA5C7A00E741B60E8DD1AD61D0F7633FC2E9F30A3BAC74897001B1AEB1B7C27A0C25A75620B8FD1374084F86186C56AF651FBEEE0DFEDA25FC110EFCD8BBB0C05222326BBF"
), "big")
e = 65537
完整EXP构造
综合上述分析,编写完整利用脚本如下:
import hashlib
from base64 import b64encode
import zstandard as zstd
def zstd_compress(data):
return zstd.compress(data)
def xtime(a) -> int:
a &= 0xFF
return (((a << 1) ^ 0x8D) & 0xFF) if (a & 0x80) else ((a << 1) & 0xFF)
def gf_mul(a, b) -> int:
a &= 0xFF
b &= 0xFF
res = 0
for _ in range(8):
if b & 1:
res ^= a
a = xtime(a)
b >>= 1
return res & 0xFF
AES_SBOX = bytes.fromhex(
"637C699066329A0E6441CBA99FFAD5AA6524F777371D83EB981A2A7DBD2502EEE"
"5E7455029C4ECA7CCF05C4D1396A2099EFF5AA1C76FE9150C1BC5975614A5B620"
"D62111700D7F4E4652354BA4C9011E310F2F17FCDB7430DE481C950653D36718F"
"D2D1F7A8D8775B426E071A3825807D4BADAA8B5D99CCFF960D81200798904C2B8"
"3C614276DF6CEA495462E8B3F50BF1287ED2CD23F28E80F836E3D722DDF34A2E5"
"510C0B15943AC683FBB6DAFCAC638B973AEDCBC9DC3D14CFEA63B92E42B5BFB2C"
"F6C1B25D8FEF78915F9472ED4088B7443427E16A0586C8938A7B8451E63D990A3"
"3BF39038C086B3E8519CEB08BABA0E247BE4F5E9B57AD6E81163AD0F4"
)
INV_SBOX = bytearray(256)
for i, v in enumerate(AES_SBOX):
INV_SBOX[v] = i
INV_SBOX = bytes(INV_SBOX)
def rijndael_rounds(Nb, Nk) -> int:
return max(Nb, Nk) + 6
def rot_word(w) -> int:
return ((w << 8) & 0xFFFFFFFF) | ((w >> 24) & 0xFF)
def sub_word(w, sbox) -> int:
return (
(sbox[(w >> 24) & 0xFF] << 24) |
(sbox[(w >> 16) & 0xFF] << 16) |
(sbox[(w >> 8) & 0xFF] << 8) |
(sbox[(w >> 0) & 0xFF] << 0)
) & 0xFFFFFFFF
def rcon(i) -> int:
c = 1
for _ in range(i - 1):
c = xtime(c)
return (c << 24) & 0xFFFFFFFF
def key_expansion(key, Nb, Nk, sbox):
if len(key) != 4 * Nk:
raise ValueError(f"key length must be {4*Nk} bytes for Nk={Nk}, got {len(key)}")
Nr = rijndael_rounds(Nb, Nk)
W_words = Nb * (Nr + 1)
w = [0] * W_words
for i in range(Nk):
w[i] = int.from_bytes(key[4*i:4*i+4], "big")
for i in range(Nk, W_words):
temp = w[i - 1]
if i % Nk == 0:
temp = sub_word(rot_word(temp), sbox) ^ rcon(i // Nk)
elif Nk > 6 and (i % Nk) == 4:
temp = sub_word(temp, sbox)
w[i] = (w[i - Nk] ^ temp) & 0xFFFFFFFF
return w
def bytes_to_state(block, Nb):
if len(block) != 4 * Nb:
raise ValueError("bad block size")
s = [[0]*Nb for _ in range(4)]
for c in range(Nb):
for r in range(4):
s[r][c] = block[4*c + r]
return s
def state_to_bytes(s, Nb):
out = bytearray(4*Nb)
for c in range(Nb):
for r in range(4):
out[4*c + r] = s[r][c] & 0xFF
return bytes(out)
def add_round_key(s, round_w, round_idx, Nb):
base = round_idx * Nb
for c in range(Nb):
w = round_w[base + c]
s[0][c] ^= (w >> 24) & 0xFF
s[1][c] ^= (w >> 16) & 0xFF
s[2][c] ^= (w >> 8) & 0xFF
s[3][c] ^= (w >> 0) & 0xFF
def sub_bytes(s, sbox, Nb):
for r in range(4):
for c in range(Nb):
s[r][c] = sbox[s[r][c]]
def shift_rows(s, Nb):
for r in range(1, 4):
k = r % Nb
row = s[r]
s[r] = row[-k:] + row[:-k]
def inv_shift_rows(s, Nb):
for r in range(1, 4):
k = r % Nb
row = s[r]
s[r] = row[k:] + row[:k]
MIX_MAT = [
[4, 1, 6, 2],
[2, 4, 1, 6],
[6, 2, 4, 1],
[1, 6, 2, 4],
]
def gf_mat_inv_4x4(mat):
A = [[mat[r][c] & 0xFF for c in range(4)] + [1 if c == r else 0 for c in range(4)] for r in range(4)]
def gf_inv(x) -> int:
x &= 0xFF
if x == 0:
raise ZeroDivisionError("no inverse for 0")
for y in range(1, 256):
if gf_mul(x, y) == 1:
return y
raise ZeroDivisionError("no inverse found (should not happen)")
for col in range(4):
pivot = None
for r in range(col, 4):
if A[r][col] != 0:
pivot = r
break
if pivot is None:
raise ValueError("matrix not invertible")
if pivot != col:
A[col], A[pivot] = A[pivot], A[col]
inv_p = gf_inv(A[col][col])
for j in range(8):
A[col][j] = gf_mul(A[col][j], inv_p)
for r in range(4):
if r == col:
continue
factor = A[r][col]
if factor == 0:
continue
for j in range(8):
A[r][j] ^= gf_mul(factor, A[col][j])
inv = [[A[r][4 + c] & 0xFF for c in range(4)] for r in range(4)]
return inv
INV_MIX_MAT = gf_mat_inv_4x4(MIX_MAT)
def mix_single_column(col):
a0, a1, a2, a3 = [x & 0xFF for x in col]
out = []
for r in range(4):
out.append(
gf_mul(MIX_MAT[r][0], a0) ^
gf_mul(MIX_MAT[r][1], a1) ^
gf_mul(MIX_MAT[r][2], a2) ^
gf_mul(MIX_MAT[r][3], a3)
)
return [x & 0xFF for x in out]
def inv_mix_single_column(col):
a0, a1, a2, a3 = [x & 0xFF for x in col]
out = []
for r in range(4):
out.append(
gf_mul(INV_MIX_MAT[r][0], a0) ^
gf_mul(INV_MIX_MAT[r][1], a1) ^
gf_mul(INV_MIX_MAT[r][2], a2) ^
gf_mul(INV_MIX_MAT[r][3], a3)
)
return [x & 0xFF for x in out]
def mix_columns(s, Nb):
for c in range(Nb):
col = [s[r][c] for r in range(4)]
mc = mix_single_column(col)
for r in range(4):
s[r][c] = mc[r]
def inv_mix_columns(s, Nb):
for c in range(Nb):
col = [s[r][c] for r in range(4)]
mc = inv_mix_single_column(col)
for r in range(4):
s[r][c] = mc[r]
BLOCK_SIZE = 20
def pkcs7_pad(data, block_size=BLOCK_SIZE):
pad_len = block_size - (len(data) % block_size)
if pad_len == 0:
pad_len = block_size
return data + bytes([pad_len]) * pad_len
def pkcs7_unpad(padded, block_size=BLOCK_SIZE):
if not padded or (len(padded) % block_size) != 0:
raise ValueError("bad padded length")
pad_len = padded[-1]
if pad_len < 1 or pad_len > block_size:
raise ValueError("bad padding")
if padded[-pad_len:] != bytes([pad_len]) * pad_len:
raise ValueError("bad padding bytes")
return padded[:-pad_len]
def derive_key_and_mask(password_b, salt, ITER):
enc_data_48 = hashlib.pbkdf2_hmac("sha256", password_b, salt, ITER, dklen=48)
key28 = enc_data_48[:28]
mask20 = enc_data_48[28:]
return key28, mask20
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def encrypt(data, password_b, salt, ITER):
key28, iv = derive_key_and_mask(password_b, salt, ITER)
data_p = pkcs7_pad(data, BLOCK_SIZE)
out = bytearray()
prev_block = iv
for i in range(0, len(data_p), BLOCK_SIZE):
plaintext_block = data_p[i:i + BLOCK_SIZE]
mixed = xor_bytes(plaintext_block, prev_block)
ciphertext_block = rijndael160_decrypt_block(mixed, key28, Nb=5, Nk=7)
out += ciphertext_block
prev_block = ciphertext_block
return bytes(out)
def decrypt(ciphertext, password_b, salt, ITER):
if len(ciphertext) % BLOCK_SIZE != 0:
raise ValueError("密文长度必须是 20 字节的倍数")
key28, iv = derive_key_and_mask(password_b, salt, ITER)
out = bytearray()
prev_block = iv
for i in range(0, len(ciphertext), BLOCK_SIZE):
ciphertext_block = ciphertext[i:i + BLOCK_SIZE]
decrypted_core = rijndael160_encrypt_block(ciphertext_block, key28, Nb=5, Nk=7)
plaintext_block = xor_bytes(decrypted_core, prev_block)
out += plaintext_block
prev_block = ciphertext_block
return pkcs7_unpad(bytes(out), BLOCK_SIZE)
def encode_varint(x: int) -> bytes:
if x < 0:
raise ValueError("varint encoder expects non-negative int")
out = bytearray()
while True:
b = x & 0x7F
x >>= 7
if x:
out.append(b | 0x80)
else:
out.append(b)
break
return bytes(out)
def encode_key(field_number: int, wire_type: int) -> bytes:
return encode_varint((field_number << 3) | wire_type)
def field_length_delimited(field_number: int, data) -> bytes:
if isinstance(data, str):
data = data.encode("utf-8")
return encode_key(field_number, 2) + encode_varint(len(data)) + data
MASK128 = (1 << 128) - 1
R = 0xE1000000000000000000000000000000
def aes_ecb_encrypt_block(key: bytes, block16: bytes) -> bytes:
if len(block16) != 16:
raise ValueError("block16 must be 16 bytes")
if len(key) not in (16, 24, 32):
raise ValueError("key must be 16/24/32 bytes")
from Crypto.Cipher import AES
return AES.new(key, AES.MODE_ECB).encrypt(block16)
def inc32(counter16: bytes) -> bytes:
if len(counter16) != 16:
raise ValueError("counter16 must be 16 bytes")
prefix = counter16[:12]
c = int.from_bytes(counter16[12:], "big")
c = (c + 1) & 0xFFFFFFFF
return prefix + c.to_bytes(4, "big")
def gf128_mul_gcm(x: int, y: int) -> int:
z = 0
v = x
for i in range(128):
if (y >> (127 - i)) & 1:
z ^= v
if v & 1:
v = (v >> 1) ^ R
else:
v >>= 1
return z & MASK128
def ghash(H: bytes, aad: bytes, c: bytes) -> bytes:
if len(H) != 16:
raise ValueError("H must be 16 bytes")
H_int = int.from_bytes(H, "big")
X = 0
def iter_blocks(data: bytes):
for i in range(0, len(data), 16):
blk = data[i:i+16]
if len(blk) < 16:
blk = blk + b"\x00" * (16 - len(blk))
yield blk
for blk in iter_blocks(aad):
X = gf128_mul_gcm(X ^ int.from_bytes(blk, "big"), H_int)
for blk in iter_blocks(c):
X = gf128_mul_gcm(X ^ int.from_bytes(blk, "big"), H_int)
len_block = (len(aad) * 8).to_bytes(8, "big") + (len(c) * 8).to_bytes(8, "big")
X = gf128_mul_gcm(X ^ int.from_bytes(len_block, "big"), H_int)
return X.to_bytes(16, "big")
def gcm_ctr_crypt(key: bytes, J0: bytes, data: bytes) -> bytes:
if len(J0) != 16:
raise ValueError("J0 must be 16 bytes")
out = bytearray()
counter = inc32(J0)
for off in range(0, len(data), 16):
block = data[off:off+16]
ks = aes_ecb_encrypt_block(key, counter)
out.extend(bytes(b ^ k for b, k in zip(block, ks[:len(block)])))
counter = inc32(counter)
return bytes(out)
def gcm_encrypt_and_tag(key: bytes, nonce12: bytes, plaintext: bytes, aad: bytes = b""):
if len(nonce12) != 12:
raise ValueError("nonce must be 12 bytes")
J0 = nonce12 + b"\x00\x00\x00\x01"
H = aes_ecb_encrypt_block(key, b"\x00" * 16)
ciphertext = gcm_ctr_crypt(key, J0, plaintext)
S = aes_ecb_encrypt_block(key, J0)
g = ghash(H, aad, ciphertext)
tag = bytes(x ^ y for x, y in zip(S, g))
return ciphertext, tag
def build_packet(nonce12: bytes, ciphertext: bytes, tag16: bytes) -> bytes:
if len(nonce12) != 12:
raise ValueError("nonce must be 12 bytes")
if len(tag16) != 16:
raise ValueError("tag must be 16 bytes")
body = nonce12 + ciphertext + tag16
length = len(body)
return length.to_bytes(4, "little") + body
def make_packet_from_nonce_plain(key: bytes, nonce12: bytes, plaintext: bytes, aad: bytes = b""):
ciphertext, tag16 = gcm_encrypt_and_tag(key, nonce12, plaintext, aad=aad)
pkt = build_packet(nonce12, ciphertext, tag16)
return pkt
n = int.from_bytes(bytes.fromhex(
"BF6B322252C0B0BBD8FC0E11FC25DAFE0DEEBE1F65AF566C18864F087413FDB82056A7250C7AC2B7B1AEB101708974AC3B0AF3E9C23F63F7D061ADD18D0EB641E7007A5CDA3CAEE3D8DB7AD27810D587C35C6DE4C5CF617CB60AC968F63D57FACCAD5297C4E7E5C59237BDE3076CE08979182CA083B2658453C00E67108F1F12E5F9B9DB7421BEFA0C1356565F78F8216761BF1EE50A95A4604E44D2D45EF1932010CD91819D6530337A9737769187576A4A914DFFB7167444F146EEA9FED405CECE0FC49366DDBDDA0F8903328CB2B585A407BDFE05FD4FDA6325C2C85F0817BAAD5A0517CCDC4CFC051F6B5C37FB23692AA0D2C8E562991015FA333D571CEA5EA481D4862CECCA7C98523569793F50F55E24D6206DA9E688B3980EBF9F2B691B9F59A794355B438355D8D88CA358D7C8C83CD0B330C86BA0571B5F93C633B0E2B5C9E17C91971383C4AD5E3357028B558893CF8E8C06ADA01ABC0C0A2B1B2F6AF38F69616DE7371AB4AE15FAFEA7CDAEA5C4B13064DC6B89A3D5996A1613E973B0B9AD9FF8F572165E5A587AC6D3A6FA578D304FC45E181B77FCE97067122245126AD5046BEB93F6A17EC4BD5A801396FE440D955EDFD9248B3C0018752D574C4BB62A91117909CCDAE08E26CC47A9DD9623A9FE476AB7BA647EE15BB338CFC3174535D13D7B6C74AFA7B514AC511A6FEC04607C78CA2ECE7CD128AF1558B7"
), "big")
d = int.from_bytes(bytes.fromhex(
"32C892BD6E6CF6B66F83B78BE7F4771C0DC0382A8644B54DEA57BFA20381C63F523D0B0D16397F6D52B380FC5BC9EBED41A0CF434628A131FED3DB548BF2CA41C3B269C4369600E42C0556997E07214F6A721C29A49D3744E9DB04C25709C14CA57E9A39EFA082621F3FB09E09BB45FAD2E8A9F64FDA457A8CE9982899C90EBA69CF0E12FDC572304E81D6D7056F478D3D2B3E9448B9BD27A5F13DEB1D32AF2E944440F58888A46EDC497AD2D91F14E4092C0D4EBF37E8BA220C4D00469377D6AE9E16AAD55C6619D73F65DF364B03A28AF910A0C442FC8871ECF9F8AA4624147F8F3C21BBC5BAF0A5B00A3CE67367AA665D4BDB8036F3289E8EE6192FFDEB8A800A60196CF0C73E75EA5B397057451ADFD512AEE8F1E2C04F959B74270DD7A2F434A3C378C50734570CFFF1880C3D0879A4378AB2E06E2A51C4021205F8CC8D5A24878DB2457984F0BDA467D617CBC92AE65D154C1C4A98740007F4DB588DEBABD1579EA1E5162E3A7B716FB2ABC99274A8E252C2DF32AD674C85219B55213BF4C7621E5E7D583F04F69A68391F1F93FC472425900C9B51DECBDE9B2F742753AF51B9F1C146119D628D379A427D92C8F860D4D662DAFB0E9AF766B0FE580E3325E90F364F8C747095D4A259803139C3937B58EF20E601E8B78B70C2093A4D7335847EF18E6CFD8694BD6928350D66F3809B573C73A00889411C0B97C1204F6986"
), "big")
e = 65537
def gen_packet(license_code):
password_b = b"mypassword"
salt = bytes.fromhex("1020304050")
ITER = 1000
TARGET_HEX = "".join(license_code.split("-"))
calculated_sig = pow(int.from_bytes(bytes.fromhex(TARGET_HEX), "big"), e, n)
raw_data = b'{"license_code":"' + license_code.encode() + b'","sign":"' + calculated_sig.to_bytes((calculated_sig.bit_length() + 7) // 8, 'big').hex().encode() + b'"}'
ct = encrypt(raw_data, password_b, salt, ITER)
protobuf_msg = b"".join([
field_length_delimited(1, ct),
field_length_delimited(2, password_b),
field_length_delimited(3, salt),
field_length_delimited(4, hashlib.sha256(raw_data).digest()),
])
b64 = b64encode(protobuf_msg)
buffer = zstd_compress(b64)
key = bytes.fromhex("f6778d8728d8f17ce8c5c81f45c3d5fd869ca851b7575be540776f4f26c1140d")
nonce12 = b"b" * 12
packet = make_packet_from_nonce_plain(key, nonce12, buffer, aad=b"")
return packet
远程交互脚本使用pwntools实现自动化:
from pwn import *
import subprocess
from solve import *
context.log_level = 'debug'
host = '223.6.249.127'
port = 11240
io = remote(host, port)
log.info("正在解析挑战信息...")
io.recvuntil(b"plz run this command to get solve result")
cmd = ""
while True:
line = io.recvline().decode().strip()
if "hashcash" in line:
cmd = line
break
log.info(f"提取到的命令: {cmd}")
try:
solution = subprocess.check_output(cmd, shell=True).strip()
log.success(f"计算结果: {solution.decode()}")
io.sendline(solution)
except Exception as e:
log.error(f"本地执行失败: {e}")
io.close()
exit()
try:
io.recvuntil(b"license code:")
code = io.recvline().strip()
log.success(f"收到 License Code: {code.decode()}")
try:
payload = gen_packet(code.decode())
log.info(f"正在发送长度为 {len(payload)} 的字节流...")
io.send(payload)
except ValueError:
log.error("输入的不是有效的十六进制字符串!")
except EOFError:
log.error("服务器在验证后断开连接。")
io.interactive()
总结
本次逆向完整复现了从协议分析、加密算法识别、魔改点定位到最终EXP构造的全过程。关键技术点包括:
- 多层嵌套协议解析(AES-GCM → zstd → Base64 → Protobuf)
- 魔改AES-160的S-Box与MixColumn分析
- RSA签名逻辑的逆向与利用
- 动态ELF加载行为的理解
对于从事逆向工程和CTF竞赛的安全研究人员而言,此类题目提供了宝贵的实战经验。
参考资料
[1] 阿里CTF2026-license, 微信公众号:mp.weixin.qq.com/s/tL-QO8GQSxX71nTcGz10AA
版权声明:本文由 云栈社区 整理发布,版权归原作者所有。