作者: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運行環境安裝
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無法運行。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1978/