erlang-distribution-protocol 安全问题研究
2022-9-22 17:28:0 Author: paper.seebug.org(查看原文) 阅读量:44 收藏

作者:lxraa
本文为作者投稿,Seebug Paper 期待你的分享,凡经采用即有礼品相送! 投稿邮箱:[email protected]

由于目前公司部分业务使用erlang实现,中文互联网上对于erlang安全问题研究较少,为了了解erlang应用的安全问题本人结合代码和公开资料进行了一些研究。

本文为erlang安全研究项目中针对erlang distribution通信协议的研究,目的是解决erlang应用的公网暴露面问题。

文中的pcap包,文档,代码存放于https://github.com/lxraa/erl-matter/tree/master/otp25

1、erlang运行环境安装

Downloads - Erlang/OTP

2、erlang包管理安装-rebar3

git clone https://github.com/erlang/rebar3.git
cd rebar3
./bootstrap

3、erlang调试环境搭建(vscode)

VSCode Debug Erlang工程配置_犀牛_2046的博客-CSDN博客_vscode调试erlang

# vscode安装erlang插件时可能会出现以下提示
# no such file or directory pgourlain..._build...
# 原因是vscode erlang extension(pgourlain)不会自己编译
# 需要手动到extension目录下,使用rebar3 compile编译,生成_build文件夹

1、erlang语言的特点

  • 解释型语言

  • 函数式

  • 无反射

  • 擅长并行处理

    • 维护了一套ring3的线程,因此线程调度并不依赖syscall,开销较小,可以轻易创建大量线程。
  • 自带分布式

    • 底层通过rpc调用。

    • 由于没有反射,集群通信不存在反序列化rce(反序列化的本质是绕过黑名单的method.invoke),但是仍然可能存在其他安全问题。

2、集群通信原理图

1、machine1对外开放服务时,会先在4369端口开放epmd服务,这个服务可以理解为注册中心,用来保存machine1服务的(name,port)

2、machine2想调用machine1的服务时,需要先找epmd拿到machine1的(name,port)列表

3、machine2直接连接machine1的port,rpc调用

3、通信demo

开启一个linux虚拟机,使用windows远程调用linux节点

  • 以debug模式开启,为了方便连接,给机器指定一个hostname
# linux
epmd -d
hostname localcentos2

  • 使用-sname指定名称,erl会自动把process对外开放,并注册到epmd(没有epmd时,还会自动开启epmd)
erl -sname test

  • 设置cookie
%%注意,erlang中单引号代表atom类型,并不是string

%% atom可以理解为全局唯一标识符,类似js的Symbol

auth:set_cookie('123456'). 
  • windows开启erlang shell,并配置与linux node相同的cookie

host文件互相加dns解析记录

erlang -sname test
>> auth:set_cookie('123456').
  • 连接节点,并查看是否连接成功
%% 连接 记得关闭linux防火墙 systemctl stop firewalld
net_adm:ping('[email protected]').
%% 查看已连接的节点
nodes().

%% 执行代码
rpc:call('[email protected]','os','cmd',["touch /tmp/connect_success.txt"]).

可以看到,process是通过cookie保护的,拿到cookie相当于拥有执行任意代码权限,以下解决两个问题

1、认证是与epmd通信还是与process通信?

2、认证过程是否存在安全问题?

epmd是一台主机erlang节点的注册服务,提供了name到node的解析,可以理解为一个注册中心,用来告诉外部连接这个主机上的node信息。当有外部主机请求epmd服务时,epmd返回当前主机上所有node监听端口信息和节点的name

erl
1> net_adm:names("localcentos2").
{ok,[{"test",36612}]}

注意,epmd是没有认证的,也就是说epmd会暴露该主机所有通过sname或name启动的process信息,且epmd对非local的操作只支持查询,代码在otp_src/erts/epmd/src/epmd_src.c:line 799 : void do_request(g, fd, s, buf, bsize)

...
case EPMD_ALIVE2_REQ:
    //只允许local调用
    dbg_printf(g, 1, "** got ALIVE2_REQ");
    if (!s->local_peer)
    {
      dbg_printf(g, 0, "ALIVE2_REQ from non local address");
      return;
    }


  case EPMD_PORT2_REQ:
    dbg_printf(g, 1, "** got PORT2_REQ");

    if (buf[bsize - 1] == '\000') /* Skip null termination */
      bsize--;

    if (bsize <= 1)
    {
      dbg_printf(g, 0, "packet too small for request PORT2_REQ (%d)", bsize);
      return;
    }

    for (i = 1; i < bsize; i++)
      if (buf[i] == '\000')
      {
        dbg_printf(g, 0, "node name contains ascii 0 in PORT2_REQ");
        return;
      }

    {
      char *name = &buf[1]; /* Points to node name */
      int nsz;
      Node *node;

      nsz = verify_utf8(name, bsize, 0);
      if (nsz < 1 || 255 < nsz)
      {
        dbg_printf(g, 0, "invalid node name in PORT2_REQ");
        return;
      }

      wbuf[0] = EPMD_PORT2_RESP;
      for (node = g->nodes.reg; node; node = node->next)
      {
        int offset;
        if (is_same_str(node->symname, name))
        {
          wbuf[1] = 0; /* ok */
          put_int16(node->port, wbuf + 2);
          wbuf[4] = node->nodetype;
          wbuf[5] = node->protocol;
          put_int16(node->highvsn, wbuf + 6);
          put_int16(node->lowvsn, wbuf + 8);
          put_int16(length_str(node->symname), wbuf + 10);
          offset = 12;
          offset += copy_str(wbuf + offset, node->symname);
          put_int16(node->extralen, wbuf + offset);
          offset += 2;
          memcpy(wbuf + offset, node->extra, node->extralen);
          offset += node->extralen;
          if (!reply(g, fd, wbuf, offset))
          {
            dbg_tty_printf(g, 1, "** failed to send PORT2_RESP (ok) for \"%s\"", name);
            return;
          }
          dbg_tty_printf(g, 1, "** sent PORT2_RESP (ok) for \"%s\"", name);
          return;
        }
      }
      wbuf[1] = 1; /* error */
      if (!reply(g, fd, wbuf, 2))
      {
        dbg_tty_printf(g, 1, "** failed to send PORT2_RESP (error) for \"%s\"", name);
        return;
      }
      dbg_tty_printf(g, 1, "** sent PORT2_RESP (error) for \"%s\"", name);
      return;
    }
    break;

  case EPMD_NAMES_REQ:
    dbg_printf(g, 1, "** got NAMES_REQ");
   ...
   break;
  case EPMD_DUMP_REQ:
    dbg_printf(g, 1, "** got DUMP_REQ");
    if (!s->local_peer)
    {
      dbg_printf(g, 0, "DUMP_REQ from non local address");
      return;
    }
    // 只允许local调用
    ...
    break;

  case EPMD_KILL_REQ:
    if (!s->local_peer)
    {
      dbg_printf(g, 0, "KILL_REQ from non local address");
      return;
    }
    dbg_printf(g, 1, "** got KILL_REQ");

   // 只允许local调用

  case EPMD_STOP_REQ:
    dbg_printf(g, 1, "** got STOP_REQ");
    if (!s->local_peer)
    {
      dbg_printf(g, 0, "STOP_REQ from non local address");
      return;
    }
    // 只允许local调用
    break;

  default:
    dbg_printf(g, 0, "got garbage ");
  }

EPMD_NAMES_REQ显然是用来响应net_adm:names().,以下调试EPMD_PORT2_REQ

①修改epmd代码,在do_request前print输出tcp包的内容,并make&&make install,在主机A通过epmd -d启动epmd的调试模式:

// epmd_srv.c - print16:
...
print16(s->buf,s->got);
do_request(g, s->fd, s, s->buf + 2, s->got - 2);
...
static int print16(char * s,unsigned int size){
  int i = 0;
  int count = 0;
  for(i = 0;i < size;i++){
    if(count > 16){
      count = 0;
    }
    printf("%x ",s[i]);
    count++;
  }
  printf("\n");
  return 0;
}

②使用erl -sname test 在主机A重新启动一个process,得到调试信息:

invoke do_request
0 11 78 ffffffa8 d 4d 0 0 6 0 5 0 4 74 65 73 74 0 0 
epmd: Mon Sep  5 15:50:22 2022: ** got ALIVE2_REQ
epmd: Mon Sep  5 15:50:22 2022: registering 'test:1662364223', port 43021
epmd: Mon Sep  5 15:50:22 2022: type 77 proto 0 highvsn 6 lowvsn 5
epmd: Mon Sep  5 15:50:22 2022: ** sent ALIVE2_RESP for "test"

③从主机B发起cookie错误的连接请求:

%% 主机A  这句并不会得到调试信息,也就是说node的auth信息并不会通知epmd
auth:set_cookie("654321").
%% 主机B
erl -sname test2
auth:set_cookie("123456").
net_adm:ping("[email protected]").

得到debug信息,可以看到请求包并不包含认证信息,也就是说auth是直接在process之间进行的,epmd不负责认证

invoke do_request
0 5 7a 74 65 73 74   
epmd: Mon Sep  5 15:55:14 2022: ** got PORT2_REQ
epmd: Mon Sep  5 15:55:14 2022: ** sent PORT2_RESP (ok) for "test"

0 5 前两个字节为长度

7a 74 65 73 74即为z t e s t ,z是控制字符,请求name为test的process信息

process通信安全问题之前有人研究过:https://github.com/gteissier/erl-matter

先给结论:

1、erl默认生成的cookie是伪随机的,可以被爆破。

2、erl distribution protocol握手靠cookie保护,通信过程没有认证,且默认无tls,可被中间人攻击。

由于erlang otp(标准库,里面含分布式通信的代码)通信协议在变化,高版本OTP process并不能与低版本通信,erl-matter工程的测试代码在otp 25(最新版本)下没有测试成功。

以下结合官方文档对通信细节的描述和wireshark的抓包结果复现一下握手过程

(为了方便阅读,这里提供一个我的翻译版,握手在13.2 章)

实验机器:

hostname ip system_type 别名
PPC2LXR 192.168.245.1 WINDOWS machine1
localcentos1 192.168.245.128 linux machine2

python3代码:

见本章末

1、windows和linux重新开启process后执行以下命令,使用wireshark抓到握手包

net_adm:ping('[email protected]').

2、握手第一步,machine1向machine2发送:

字段名 长度 存储方式 说明
Length 2bytes 大端 data的长度
Tag 1byte 操作码,握手时为'N'
Flags 8bytes 见文档
Creation 4bytes 大端 节点A标记自己pid、ports和references的标识符,由于是个标识符,编写代码时随机生成一个4bytes长的unsigned整数即可
NameLength 2bytes 大端 name的长度
Name NameLength machine1节点的名称

字段名 长度 存储方式 说明
Length 2bytes 大端 data的长度
Tag 1byte 操作码,成功时值为's'
Status 2bytes 成功时值为ok

3、握手第二步,machine2向machine1发送:

字段名 长度 存储方式 说明
Length 2bytes 大端 data的长度
Tag 1byte 值为'N'
Flags 8bytes 见文档
challenge 4bytes 大端 machine2生成的32位随机数
Creation 4bytes 大端 标识符
NameLength 2bytes 大端 name的长度
Name NameLength machine2节点的名称

4、握手第三步,machine1向machine2发送

字段名 长度 存储方式 说明
Length 2bytes 大端 data的长度-2
Tag 1byte 值为'r'
Challenge 4bytes 大端 machine1生成的32位随机数
Digest 16bytes md5(cookie+machine2_challenge)

digest代码在otp_src/lib/kernel/src/dist_util.erl,注意转换成python代码的写法(见本章末代码)

machine2向machine1发送

字段名 长度 存储方式 说明
Length 2bytes 大端 data的长度-2
Tag 1byte 值为'a'
Digest 16bytes md5(cookie+machine1_challenge),互相通信,所以需要互相校验

最终得到完整的代码:

class Erldp:
    def __init__(self,host:string,port:int,cookie:bytes,cmd:string):
        self.host = host
        self.port = port
        self.cookie = cookie
        self.cmd = cmd
    def setCookie(self,cookie:bytes):
        self.cookie = cookie

    def _connect(self):
        self.sock = socket(AF_INET,SOCK_STREAM,0)
        self.sock.settimeout(1)
        assert(self.sock)
        self.sock.connect((self.host,self.port))

    def rand_id(self,n=6):
        return ''.join([choice(ascii_uppercase) for c in range(n)]) + '@nowhere'

    # 注意,这里的challenge是str.encode(str(int.from_bytes(challenge,"big")))
    def getDigest(self,cookie:bytes,challenge:int):
        challenge = str.encode(str(challenge))
        m = md5()
        m.update(cookie)
        m.update(challenge)
        return m.digest()
    def getRandom(self):
        r = int(random() * (2**32))
        return int.to_bytes(r,4,"big")
    def isErlDp(self):
        try:
            self._connect()
        except:
            print("[!]%s:%s tcp连接失败" % (self.host,self.port))
            return False
        try:
            self._handshake_step1()
        except:
            print("[!]%s:%s不是erldp" % (self.host,self.port))
            return False
        print("[*]%s:%s是erldp" % (self.host,self.port))
        return True

    def _handshake_step1(self):

        self.name = self.rand_id()
        packet = pack('!Hc8s4sH', 1+8+4+2+len(self.name), b'N', b"\x00\x00\x00\x01\x03\xdf\x7f\xbd",b"\x63\x15\x95\x8c", len(self.name)) + str.encode(self.name)
        self.sock.sendall(packet)
        (res_packet_len,) = unpack(">H",self.sock.recv(2))
        (tag,status) = unpack("1s2s",self.sock.recv(res_packet_len))
        assert(tag == b"s")
        assert(status == b"ok")
        print("step1 end:发送node1 name成功")

    def _handshake_step2(self):
        (res_packet_len,) = unpack(">H",self.sock.recv(2))
        data = self.sock.recv(res_packet_len)
        tag = data[0:1]
        flags = data[1:9]
        self.node2_challenge = int.from_bytes(data[9:13],"big")
        node2_creation = data[13:17]
        node2_name_len = int.from_bytes(data[17:19],"big")
        self.node2_name = data[19:]
        assert(tag == b"N")
        print("step2 end:接收node2 name成功")

    def _handshake_step3(self):
        node1_digest = self.getDigest(self.cookie,self.node2_challenge)
        self.node1_challenge = self.getRandom()
        packet2 = pack("!H1s4s16s",21,b"r",self.node1_challenge,node1_digest)
        self.sock.sendall(packet2)
        (res_packet_len,) = unpack(">H",self.sock.recv(2))
        (tag,node2_digest) = unpack("1s16s",self.sock.recv(res_packet_len))

        assert(tag == b"a")

        print("step3 end:验证md5成功,握手结束")


    def handshake(self):
        self._connect()
        self._handshake_step1()
        self._handshake_step2()
        self._handshake_step3()
        print("handshake done")

基于上述代码已经可以实现otp25口令爆破和端口扫描,已经能够满足需求。

默认口令的伪随机、中间人攻击、控制指令等原理见github.com/gteissier/erl-matter,如果编写用于OTP25的代码需要调整代码,例如rpc:call('[email protected]','os','cmd',["touch /tmp/tttt"]) 在otp25下使用了otp23新增的29号ctrl SPAWN_REQUEST(见pcap包和文档),而erl-matter中的send_cmd使用了6号指令REG_SEND,在otp25无法运行。


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1978/



文章来源: https://paper.seebug.org/1978/
如有侵权请联系:admin#unsafe.sh