浅谈逆向中关于位运算的常见问题

debu8ger Lv3

零、前言

在CTF比赛中,逆向题目通过IDA反编译后为C/C++的汇编逻辑。而我们作为打比赛的,最常用的语言肯定是python了。

问题是,就算我们逆出来逻辑完全一样,但我们在编译器里实际编译出来却跑出了乱码。大多数时候,只是我们没想到两语言之间的整数溢出不同。

问题:逆向逻辑完全正确,但 Python 解密出来是乱码。

本文仅浅谈相关例子与建议


一、C 与 Python 整数模型差异

C 语言整数

C 中整数是 固定宽度

类型 位宽
char 8 bit
short 16 bit
int 32 bit
long long 64 bit

例如:

unsigned char

只有 8 bit,在IDA中以BYTE显示,范围是0 ~ 255

位结构

11111111
↑
8 bit

最大值:

255

Python 整数

Python 使用:

Arbitrary Precision Integer

也就是:

无限精度整数

示例:

2 ** 1000

Python 中完全合法。

但在 C 中会溢出。


二、什么是整数溢出

例子:

unsigned char x = 250;
x = x + 20;

数学结果表示:

250 + 20 = 270

但 8bit 最大是:

255

溢出过程

270 = 100001110

只保留 低 8 位

00001110

结果:

14

图解

270: 100001110
        ↓
保留低8位
        ↓
     00001110
        ↓
       14

数学等价:

270 mod 256

三、Python 如何模拟溢出

使用 mask

& 0xFF

示例:

x = (250 + 20) & 0xFF
print(x)

结果:

14

位运算图解

270 = 100001110
0xFF = 11111111

AND
----------------
      00001110

四、CTF最常见运算陷阱

1.加法溢出

C 代码

unsigned char x = 250;
x = x + 20;

真实结果:

14

Python错误写法

x = 250 + 20
print(x)

结果为:

270

正确写法

x = (250 + 20) & 0xFF

五、减法负数问题

C 代码

unsigned char x = 10;
x = x - 50;

数学结果:

-40

C 实际结果

216

原因是:

-40 mod 256 = 216

Python正确

x = (10 - 50) & 0xFF

六、按位取反 ~(最常见)

C

unsigned char x = 0x55;
x = ~x;

位变化:

01010101
↓
10101010

结果:

0xAA = 170

Python直接计算

~0x55

结果:

-86

原因:

Python使用 无限补码


正确写法

(~0x55) & 0xFF

结果:

170

七、左移溢出

C 代码:

unsigned char x = 200;
x = x << 2;

计算:

200 << 2 = 800

8 bit截断:

800 mod 256 = 32

Python正确写法:

x = (200 << 2) & 0xFF

八、右移问题

右移分为两种:算术右移逻辑右移

算术右移

符号位扩展

-8 >> 1 = -4

逻辑右移

即为高位补0


Python只有:

算术右移

如果要逻辑右移:

(x & 0xFFFFFFFF) >> n

九、char 符号问题

C 中:

char

可能是:

signed char

unsigned char

例如:

char x = 200;

真实值:

200 - 256 = -56

Python模拟:

x = 200
if x > 127:
x -= 256

十、脚本模板

推荐准备以下函数:

def u8(x):
return x & 0xFF

def u16(x):
return x & 0xFFFF

def u32(x):
return x & 0xFFFFFFFF

def u64(x):
return x & 0xFFFFFFFFFFFFFFFF

示例:

plain = u8((cipher + 86) ^ 0x32)

十一、例题

1.[HGAME 2023 week1]easyenc

下载了题目附件,先查壳&查看基本信息:

re1

没壳,直接进IDA,主函数main即为核心加密逻辑

{
__int64 v3; // rbx
__int64 v4; // rax
char v5; // al
char *v6; // rcx
_DWORD v8[10]; // [rsp+20h] [rbp-19h]
char v9; // [rsp+48h] [rbp+Fh]
_OWORD v10[3]; // [rsp+50h] [rbp+17h] BYREF
__int16 v11; // [rsp+80h] [rbp+47h]

v8[0] = 167640836;
v8[1] = 11596545;
v11 = 0;
v8[2] = -1376779008;
memset(v10, 0, sizeof(v10));
v3 = 0LL;
v8[3] = 85394951;
v8[4] = 402462699;
v8[5] = 32375274;
v8[6] = -100290070;
v8[7] = -1407778552;
v8[8] = -34995732;
v8[9] = 101123568;
v9 = -7;
sub_140001064("%50s");
v4 = -1LL;
do
++v4;
while ( *((_BYTE *)v10 + v4) );
if ( v4 == 41 )
{
while ( 1 )
{
v5 = (*((_BYTE *)v10 + v3) ^ 0x32) - 86;
*((_BYTE *)v10 + v3) = v5;
if ( *((_BYTE *)v8 + v3) != v5 )
break;
if ( ++v3 >= 41 )
{
v6 = "you are right!";
goto LABEL_8;
}
}
v6 = "wrong!";
LABEL_8:
sub_140001010(v6);
}
return 0;
}

可以看到,v8数组+ v9存放的就是我们的密文,do-while即为我们的输入环节;循环里的*((_BYTE *) v10 + v3)就是所传入的输入数组,循环校验输入与原本密文进行对比

根据查看基本信息,x86是小端序的,所以内存中实际为struct.pack(" <i ", v8[i] ),从低字节到高字节,得按小端序再转换为字节流。

加密过程是

y = (x ^ 0x32) - 86

所以逆着加密即为解密

input[i] = (cipher[i] +86) ^ 0x32

我们尝试编写exp:

import struct
v8 = [
167640836,
11596545,
-1376779008,
85394951,
402462699,
32375274,
-100290070,
-1407778552,
-34995732,
101123568
]
v9 = -7 & 0xFF
cipher = b''.join(struct.pack('<i', x) for x in v8) + bytes([v9])

flag = ''
for c in cipher:
flag += chr(((c + 86)) ^ 0x32)

print(flag)

输出的是一堆乱码

re2

我们重新看反编译代码,注意到 *((BYTE*) V8 + V3),前面说过,BYTE实际上就是unsigned char,当C进行运算时,写回char只能保留低 8 位,也等价于value mod 256

而我们是用python编写的exp,python并不会自动截断,这时候就要用到& 0xFF了。

0xFF = 255 = 11111111b

作用就是模拟 C 的字节溢出,只保留最低 8 位,使 Python 运算结果与程序中的 unsigned char 完全一致。

有了以上知识,我们再次编写exp:

import struct
v8 = [
167640836,
11596545,
-1376779008,
85394951,
402462699,
32375274,
-100290070,
-1407778552,
-34995732,
101123568
]
v9 = -7 & 0xFF
cipher = b''.join(struct.pack('<i', x) for x in v8) + bytes([v9])

flag = ''
for c in cipher:
flag += chr(((c + 86) & 0xFF) ^ 0x32)

print(flag)

这个时候就能输出正确的flag了

re3

所以,有时候我们分析完反汇编代码,写了正确逻辑的exp后跑出了乱码,有时候并不是思路不对,而是没理解语言背后的一些溢出机制,一些差异

Comments