作者:lxraa
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org

前言

由于目前公司部分業務使用erlang實現,中文互聯網上對于erlang安全問題研究較少,為了了解erlang應用的安全問題本人結合代碼和公開資料進行了一些研究。

本文為erlang安全研究項目中針對erlang distribution通信協議的研究,目的是解決erlang應用的公網暴露面問題。

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

一、環境搭建(windows)

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文件夾

二、erlang集群通信demo

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('test@localcentos2').
%% 查看已連接的節點
nodes().

  • 這時test@PPC2LXR和test@localcentos2連接成功
%% 執行代碼
rpc:call('test@localcentos2','os','cmd',["touch /tmp/connect_success.txt"]).

可以看到,process是通過cookie保護的,拿到cookie相當于擁有執行任意代碼權限,以下解決兩個問題

1、認證是與epmd通信還是與process通信?

2、認證過程是否存在安全問題?

三、epmd協議分析

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("test@192.168.245.128").

得到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信息

四、erlang-distribution握手協議分析

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('test@localcentos1').

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('test@localcentos1','os','cmd',["touch /tmp/tttt"]) 在otp25下使用了otp23新增的29號ctrl SPAWN_REQUEST(見pcap包和文檔),而erl-matter中的send_cmd使用了6號指令REG_SEND,在otp25無法運行。


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1978/