blinksocks / blinksocks

A framework for building composable proxy protocol stack.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

一种利用分组密码的工作模式基于shadowsocks设计的新型反探测代理协议

micooz opened this issue · comments

一种利用分组密码的工作模式基于shadowsocks设计的新型反探测代理协议

0x00 背景

早在2015年8月底,breakwa11就在《ShadowSocks协议的弱点分析和改进》一文中就分析了shadowsocks(下称ss)的弱点和可能的攻击手段。2017年1月,新型认证加密协议AEAD Ciphers诞生,以弥补Stream Ciphers无法认证数据的不足,并且保证全量数据的完整性,相关讨论可以参考SIP004

本文探讨的协议是对shadowsocks的Stream Ciphers的补充和增强。虽然社区已经不再建议继续使用Stream Ciphers,但我认为可以另辟蹊径再续一秒。

0x01 核心**

不同人有不同的见解,展开前,先提出个人的愚见,望理性吐槽。

数据的完整性不是刚需,隐匿性最重要。

ss协议具有高度隐匿性,越是复杂的设计越容易暴露更多问题,从而破坏隐匿性。AEAD引入认证过程,将协议复杂度提升了一个层次,完美解决了数据篡改的问题,确保了完整性。但我认为无需面面俱到,画蛇添足,只需要确保协议在正常数据流上增加信息(iv以及addressing部分)的可靠性,至于负载数据部分则完全可以交由应用处理。比如举一个例子:使用ss承载https流量,机密性、完整性、抗重放、抗各类攻击已经得到足够保证,无需再在ss上做一层校验。简言之,ss在两端应用间应当尽可能透明。

0x02 问题分析

攻击者可以通过篡改数据的方式来探测服务端是否运行着ss服务,具体实施方法和详细分析可以参考《为何 shadowsocks 要弃用一次性验证 (OTA)》这篇文章,这里不再过多阐述。下面以分组密码的工作模式为切入点,来分析各分组密码的工作模式对协议设计的影响

AES处理的数据分组长度为128bit,也就是每16字节为一块(block)。ss原协议的atyp位于第一块的第一个字节;addr(这里只讨论atyp为hostname的变长情况)开始部分一定位于第一块,若len(addr) + 1 <= 15,则addr全部位于第一块。

AES中分组密码的工作模式有多种,常用的是CBC、CFB、OFB、CTR和GCM。Stream Cipher建议的只有两种,一种是CFB,另一种是CTR。那么CFB和CTR在解密经过篡改后的数据时的表现如何呢?

CFB

cfb-ciphertext

cfb-iv

  • 篡改第一块密文分组的任意字节,导致第一块明文分组的对应字节发生改变,同时破坏了下一个明文分组。
  • 篡改IV的任意字节,导致第一块明文分组错误。

CTR

ctr-ciphertext

ctr-iv

  • 篡改第一块密文分组的任意字节,导致第一块明文分组的对应字节发生改变。
  • 篡改IV的任意字节,导致所有明文分组错误。

那么其他没有被建议的工作模式又如何表现?

CBC

cbc-ciphertext

cbc-iv

  • 篡改第一块密文分组的任意字节,导致第一块明文分组错误,并且导致第二块明文分组对应字节发生改变。
  • 篡改IV的任意字节,导致第一块明文分组对应字节发生改变。

OFB

ofb-ciphertext

ofb-iv

  • 篡改第一块密文分组的任意字节,导致第一块明文分组对应位置发生改变。
  • 篡改IV的任意字节,导致所有明文分组错误。

希望发生的情况:

  • 正着说:攻击者在atyp或者IV上实施的任意篡改,会直接导致addr的改变。
  • 反过来:服务端通过检查addr的合法性,可以知道atyp或IV遭到了篡改。

通过上述分析,按照ss原先的设计,无论Stream Cipher使用何种模式都无法满足这个要求(不能确保addr能响应IV或者atyp的改变)。要满足这个要求,必须移动addr的位置,使得addr和数据篡改产生关联,这时候就要好好利用分组密码的工作模式了。

0x03 协议设计

新的设计十分简单,在ss原协议的基础上,将ATYP换成ALEN(表示ADDR的长度,一定不会超过255个字节),随后增加一个15字节的PADDING,并总是使用CFB工作模式:

// Client => Server, TCP Stream
+------+------+-----------+----------+----------+----------+---------+
|  IV  | ALEN |  PADDING  | DST.ADDR | DST.PORT |   DATA   |   ...   |
+------+------+-----------+----------+----------+----------+---------+
|  16  |  1   |    15     | Variable |    2     | Variable |   ...   |
+------+------+-----------+----------+----------+----------+---------+
       |<----------------- AES-XXX-CFB Encrypted ------------------->|

服务端收到足够的数据后,通过IV和共享密钥开始解密,随后进行如下检查:

  1. 检查填充字节PADDING是否为预期值。
  2. 通ALEN取得addr并检查addr的合法性(可通过正则表达式),如:isIPv4(addr)、isIPv6(addr)、isHostName(addr)。

协议分析:

  • 若攻击者篡改IV,那ALEN和填充字节必然会被破坏。
  • 若攻击者篡改ALEN,那addr必然会被破坏。
  • 攻击者只能既篡改IV又篡改ATYP,但是很难构造一个适当的组合来保证第二个明文分组(addr)是合法的。

检查出问题后,如何处置就是socket层的任务了,不在本文的讨论范围内。

几个问题:

1. 把ATYP换成ALEN的目的是什么?

ss原始协议是通过ATYP来判断addr的类型,依赖一个不可靠字节的值来做判断是存在很大隐患的。实际上完全可以直接检查addr的结构来判断地址类型。对于变长hostname的情形,需要一个字节来判断addr的长度,ss原先的设计是参考Socks5协议,把addr[0]当做hostname的长度,这个地方又存在和ATYP相同隐患。因此把addr[0]提到第一个分组的第一个字节,使得对ALEN的篡改可以直接在addr上体现出来。

2. 设计PADDING的意义是什么?

将addr挤到第二个密文分组内,使得篡改ALEN后addr会错乱。

3. 检查PADDING的意义是什么?

使得篡改IV后,PADDING会错乱。

4. 为什么只能选择CFB工作模式?

其实只要出现发生一对一篡改变化的工作模式都不能利用。而CFB不存在一对一篡改变化的情况,都是一对多:一个字节变了,会影响至少16个字节。

5. ADDR是IP的情况?

如果addr为IP地址,其实可以转为字符串(ascii码)后再发送,这样更有利于暴露错误和检查,而不要使用4字节(IPv4)或16字节(IPv6)数值的表示方式,因为这样随意篡改都会得到一个合法的IP地址,从而无法作出判断。

6. 篡改其他地方的可行性?

如果攻击者另辟蹊径:

  • 篡改PADDING,必然通不过检查。
  • 篡改ADDR,有较低的概率得到另一个合法的地址。
  • 无法精准篡改PORT,因为ADDR是变长的,导致PORT无法定位。

对于第二种篡改,会破坏第三个密文分组,服务端发现目标地址不存在、无法连接或者连接成功后发送的DATA被拒绝,便无法向攻击者给出正确响应。攻击者采取这种方法得手不是完全可靠的。

0x04 重放攻击

对CFB工作模式的有效攻击是重放攻击(Replay Attack)。攻击者可以事先收集一些以往合法的密文分组,但不能改变IV,改变IV的重放会导致后续解密全部错误,因为密文分组是和开始的IV强相关联的。

cfb-encryption

攻击者希望通过替换部分密文分组,来确保第二个密文分组总是合法的,从而逃避服务端对addr的检查。同时替换第一、第二个密文分组才能达到这个效果。但是很遗憾,重放第一个密文分组会破坏第一个明文分组,导致PADDING检查错误。因此重放攻击对此协议无效。

cfb-replay-attack

0x05 等待超时攻击

由于协议只能在接收到足够的数据后才能做进一步检查,如果攻击者故意制造不够解密长度的数据,服务端会陷入等待然后超时。攻击者一点一点增加数据量,评估服务端是否响应(哪怕是出错断开连接),就可以确定是否满足协议设计条件从而做出判断。

应对这种攻击,可以规定首次接受的数据长度必须超过足够服务端完成所有检查的数据长度即可。

0x06 缺点和局限

  • 只依赖工作模式的特性来确保addr的完整性没有HMAC科学(能保证到什么程度还需进一步量化)。
  • 只能使用CFB模式。
  • 不能向后兼容。

0x07 协议实现(Show Me The Code)

#70

测试参数配置:

  presets: [{
    "name": "exp-base-with-padding",
    "params": {
      "salt": "any string"
    }
  }, {
    "name": "ss-stream-cipher",
    "params": {
      "method": "aes-256-cfb"
    }
  }],

0x08 实验代码

const crypto = require('crypto');

const method = 'aes-256-cfb';
// const method = 'aes-256-cbc';
// const method = 'aes-256-ofb';
// const method = 'aes-256-ctr';
const key = Buffer.alloc(32);

let iv = Buffer.alloc(16);

function decrypt(cipherText) {
  const decipher = crypto.createDecipheriv(method, key, iv);
  return Buffer.concat([decipher.update(cipherText)]);
}

const cipherText = Buffer.from(
  '00000000000000000000000000000000' +
  '00000000000000000000000000000000' +
  '00',
  'hex'
);

const tamperedCipherText = Buffer.from(
  'ff000000000000000000000000000000' +
  '00000000000000000000000000000000' +
  '00',
  'hex'
);

console.log(method);
console.log('<== before tamper ==>');

let plainText = decrypt(cipherText);
console.log('len =', plainText.length, '\nbuf =', plainText);

// iv[0] = 0xff;

console.log('<== after tamper ==>');

plainText = decrypt(tamperedCipherText);
console.log('len =', plainText.length, '\nbuf =', plainText);

#有人看……
我的一个不成熟的想法是:header部分直接使用block cipher,payload部分才使用stream cipher。

@coolypf 很好的想法,但是问题有三:

  1. header部分是变长的,block cipher按整数块加密,这就意味着payload很可能有一部分会落在最后一块,那payload还能用stream cipher吗?
  2. 拿到密文后又如何确定解密前面多少块?
  3. 使用多种cipher会降低性能。

只需要前16个字节的header采用block cipher即可,里面可以放proto ver、addr type、addr len、dest port等,再加上一个固定的padding作为校验。至于dest addr和payload data后续采用stream cipher。不放心还可以在header中添加dest addr checksum。
在header中加入proto ver,后续协议升级起来也比较容易。

@coolypf 想法很美好,现实很骨感,加的内容越多,协议设计的越复杂,就越容易暴露出更多问题。

你的描述略显模糊,能否给出你觉得最理想的详细设计?包括字段、字段长度、字段位置以及含义。

salt, header, padding, dest_addr, dest_port, payload
         |             |
aes256(salt ^ header)  |
         |             |
      xheader          |--> aes256cfb, xheader as IV

struct header_v1 {
  char proto_ver;  // = 1
  char addr_type;
  char addr_len;
  char padding_len;
  char crc32[4];   // checksums padding, dest_addr & dest_port
  char proto_v1_magic[8];
};

@coolypf

很不错的想法,略显复杂,但我没有想到廉价的攻击方法。

对于上面没有明确说明的几个地方先做假设,如果和假设不一样,请在后面补充:

  1. 对header的块加密模式可以选择cbc或ctr,初始化向量IV是salt,salt是16字节。
  2. addr_type和原版一样有三种合法的取值。
  3. padding为0~255长度的随机填充。

值得借鉴的地方:

  1. 随机填充,能防止对dest_addr, dest_port的定位。
  2. xheader as IV,我在这个地方思考了很久,这招太狠了,基本上可以防止对header的任何篡改。

缺点和改进:

  1. 接收数据时,必须完成两次解密才能校验数据。
  2. 单独对header进行加密需要另外花费16字节salt的代价。
  3. salt ^ header的意义是什么,似乎并没有必要,salt随机的情况下,即便没有异或,xheader也每次都不一样。
  4. crc32有风险,并不能完全保证[padding, dest_addr, dest_port]的完整性,如果换成MAC可能更好。

对header加密直接使用原始的AES算法,输入16字节,输出也是16字节,为保证每次生成的密文不相同,所以需要salt^header操作。
虽然是两次调用解密操作,但是计算复杂度并没有增加。
CRC32对付一般的篡改够了,rar、zip、7z等压缩包都用它,不过header空间足够,也可以改成CRC64。

不过确实有一个问题,同时修改padding的多个字节可以让crc32值不变,导致易被探测出协议。
可以改成从padding处就开始aes-cfb加密。