基于TCP的Codesys V3协议的C#实现
前言
Codesys是全球最著名的软PLC内核软件研发厂家德国的3S(SMART,SOFTWARE,SOLUTIONS)公司发布的一款与制造商无关IEC 61131-1编程软件及工控设备内核(runtime SDK)。CodeSys被大量的应用在工控领域中,市场占有率35%,被誉为“工控界的安卓”。
CodeSys属于私有协议,官方从未披露其报文格式和实现细节。为此,研究人员不得不从逆向工程的角度去分析该协议(除非购买昂贵的授权获取到相关源码)。如今CodeSys V2版本逐步被淘汰,取而代之是V3版本,本文将会从逆向角度介绍该协议的格式以及授权机制。
CodeSys覆盖的客户如下所示(只列出一部分,详细情况参考官网 http://www.codesys.cn/list-Partner.html):
Codesys V3的协议格式
该协议架构如下图所示,该协议栈分为四层:
1.块驱动层——Block Driver layer
2.数据报层——Datagram layer
3.通道层——Channel layer
4.服务层——Services layer
CodeSys V3通过ServerRegisterServiceHandler注册对应service_group处理的handler(相当于传统意义上上的功能码):
所有注册的服务如下图所示(图示为本人编写的C#代码内容),可以看到CodeSys协议包含相当多的功能,包括用户管理,日志管理,文件传输等功能。
为了帮助理解和研究CodeSys V3协议,本人不得不使用Wireshark解析插件来解析数据报文,下面简要介绍每个layer来分析V3数据包格式。
块驱动层
块驱动层的组件的主要任务是创建通过物理或软件接口进行通信的能力。任何块驱动程序组件都是一个“输入点”,用于接收数据包和传输数据包的点。
数据报层
该层的主要目的是路由数据包,检测 CODESYS 网络中的节点,并将数据传输到下一层。
简单介绍上面关键字段的含义:
Magic 字段:
通过 CODESYS协议生成的数据包的魔数。该字段的大小为一个字节。
Hop count 字段:
表示在网络上接收到的数据包所经过的CodeSys节点数。每当一个 CODESYS 网络节点收到一个数据包并将其重定向到另一个 CODESYS 网络节点时,它就会递减hop_count的值。如果一个节点收到了一个数据包但不是它的最终接收者并且hop_count字段的值等于 0,则节点将丢弃此数据包。本质上,该字段让 CODESYS 节点的网络免于无休止地转发数据包。
packet_info字段:
表示数据包相关设置。
priority字段:
指定处理数据包的优先级。以下数值用于指定优先级:0 – 低,1 – 正常,2 – 高,3 – 紧急
length_data_block字段:
表示接收者可以接受的最大数据大小。
Service id 字段:
服务ID,指示特定服务器必须处理接收到的数据。
通道层
通道是 CODESYS 网络节点之间的一种通信机制,它保证通信的同步、传输数据完整性的验证、消息传递的通知以及大量数据的传输。
Packet_type字段:
BLK(0x1)包类型,表示数据传输。
flags字段:
BLK数据包类型的数据包标志。
channel id 字段:
用于数据传输的通道 ID。
Blk_id 字段:
当前 BLK 消息的 ID。此 ID 每次由通过信道发起通信开始的一方递增。
Ack_id字段:
最后一条 ACK 消息的 ID。该 ID 每次都由响应方更改。在应用层接收到最后一个数据传输到服务的数据包后,响应端将该ID进行更改。
Remaining_data_size 字段:
remaining_data 所包含的预期数据的大小。
Checksum 字段:
该字段是对remaining_data中包含的数据进行校验,CRC32算法用于计算校验和。
服务层
该层的主要任务是查询请求的服务并传输其操作设置。服务层的任务包括对在该层传输的数据进行编码、解码、加密和解密。
protocol_id字段:
使用的协议的ID。此ID指示哪个协议处理程序修改了数据以及应使用哪个协议将数据传输到服务。
Header size 字段:
protocol_header的大小。该字段的值不包含先前字段和当前字段的大小。
Service group字段:
被查询服务的ID。如果在ID中设置了最高有效位,则意味着该消息是来自服务的响应。
Service ID 字段:
命令的ID,这个ID决定了服务执行的功能。
session_id 字段:
会话的ID,包含接收到的会话或空会话的值。
data size 字段:
protocol_data字段中数据的大小。
通讯授权
CodeSys V3通讯协议中已经默认开启授权机制,用户必须设置账号密码才可以进一步使用。在设置密码成功后,用户必须登录后才能对PLC进行控制管理以及编程组态等操作。但是具体的算法之前从未有人披露过,下面我将简单介绍其流程和相关算法。
账号密码加密算法
CodeSys V3通讯协议账号是以明文传输的,密码是加密传输的,使用了scrypt算法来进行加密解密。该算法在C#中的实现,如下所示:
授权协议流程
客户端先通过CmpDevice的2号服务ID请求PLC获取公钥和challenge:
PLC将会随机生成一个密钥对和一个32字节的challenge,响应包将包含公钥和challenge。
公钥信息如下所示:
32 字节challenge信息如下:
RSA加密使用的是PKCS1_OAEP,其中的哈希算法为SHA256。
XOR算法是简单的逐位异或算法。
最后,客户端再次通过CmpDevice的2号服务ID下发到PLC中:
如果密码正确的话将会返回app key,每次进行操作都需要携带该授权key。
Codesys V3协议基于C#的具体实现部分代码展示
PLC变量批量读取实现
private bool Read(string[] variableNames, out Hashtable values)
{
lock (lockObj)
{
bool ret = false;
values = new Hashtable();
try
{
startRead();
if (IsConnected)
{
variableNames = variableNames.Distinct().ToArray();
string key = GetUniqueIdentifier(new List<string>(variableNames));
if (!readPacketCache.TryGetValue(key, out List<Tag> tag_lst))
{
List<byte> data_buffer = new List<byte>();
tag_lst = new List<Tag>();
Tag tag_0x13 = new Tag(0x13, new byte[] { 0x48, 0x00, 0x01, 0x00 });
tag_lst.Add(tag_0x13);
tag_lst.Add(tag_0x3a);
Tag tag_0x18 = new Tag(0x18, BitConverter.GetBytes((uint)variableNames.Length));
tag_lst.Add(tag_0x18);
for (int i = 0; i < variableNames.Length; i++)
{
data_buffer.Clear();
int varLen = variableNames[i].Length;
data_buffer.AddRange(BitConverter.GetBytes((ushort)(varLen + 1)));
data_buffer.AddRange(Encoding.ASCII.GetBytes(variableNames[i]));
string[] sections = variableNames[i].Split('.');
data_buffer.AddRange(generateSuffix(sections[sections.Length - 1].Length));
Tag tag_0x19 = new Tag(0x19, data_buffer.ToArray(), 0x42);
tag_lst.Add(tag_0x19);
}
readPacketCache.TryAdd(key, tag_lst);
}
//注册变量
channel.Send(CmdGroup.CmpIecVarAccess, 0x01, tag_lst.ToArray());
var (service_layer_regist, tags_regist) = channel.Read();
if (!tags_regist.ContainsKey(0x10))
return false;
//读取变量值
Tag tag_0x10 = tags_regist[0x10][0];
channel.Send(CmdGroup.CmpIecVarAccess, 0x03, new Tag[] { tag_0x10 });
var (service_layer_read, tags_read) = channel.Read();
if (!tags_read.ContainsKey(0x1d))
return false;
for (int j = 0; j < tags_read[0x1d].Count; j++)
{
Tag tag_0x1d = tags_read[0x1d][j];
byte[] data = new byte[tag_0x1d.data.Length - 4];
Buffer.BlockCopy(tag_0x1d.data, 4, data, 0, data.Length);
values.Add(variableNames[j], data);
}
//结束读值
channel.Send(CmdGroup.CmpIecVarAccess, 0x02, new Tag[] { tag_0x10 });
var (service_layer_over, tags_over) = channel.Read();
}
}
catch
{
ret = false;
}
return ret = values.Count == variableNames.Length;
}
}
PLC变量的写入实现
private bool Write(string variableName, byte[] value)
{
lock(lockObj)
{
bool ret = false;
try
{
if (IsConnected)
{
List<byte> data_buffer = new List<byte>();
List<Tag> tag_lst = new List<Tag>();
Tag tag_0x13 = new Tag(0x13, new byte[] { 0x00, 0x00, 0x00, 0x00 });
tag_lst.Add(tag_0x13);
data_buffer.AddRange(Encoding.ASCII.GetBytes(variableName));
string[] sections = variableName.Split('.');
data_buffer.AddRange(generateSuffix(sections[sections.Length -1].Length));
Tag tag_0x21 = new Tag(0x21, data_buffer.ToArray());
tag_lst.Add(tag_0x21);
data_buffer.Clear();
//data_buffer.AddRange(BitConverter.GetBytes(length));
//data_buffer.AddRange(new byte[] { 0x00, 0x00 });
data_buffer.AddRange(value);
Tag tag_0x1e = new Tag(0x1e, data_buffer.ToArray());
tag_lst.Add(tag_0x1e);
//写入
channel.Send(CmdGroup.CmpIecVarAccess, 0x04, tag_lst.ToArray());
var (service_layer_write, tags_write) = channel.Read();
if (tags_write.ContainsKey(0x20))
{
Tag tag_0x20 = tags_write[0x20][0];
if (BitConverter.ToUInt16(tag_0x20.data, 0) == 0)
{
ret = true;
}
else
{
ret = false;
}
}
}
}
catch
{
ret = false;
}
return ret;
}
}
PLC账号密码登录实现
public bool Login(string username = "", string password = "")
{
Logger.writeSystemLog($"Trying to login over the channel {_channelId:X4}");
try
{
List<Tag> tags = new List<Tag>();
if (username.Length % 2 != 0)
{
username += '\0';
}
if (!(username.Length > 0))
{
username = "\0\0";
}
tags.Add(new Tag(0x22, new byte[] { 0x01, 0x00, 0x00, 0x00 }));
if (password.Length > 0)
{
byte[] password_hash = CodeSysV3Encryption.HashPassword(CodeSysV3Encryption.CHALLENGE, password);
tags.Add(new Tag(0x23, BitConverter.GetBytes(CodeSysV3Encryption.CHALLENGE)));
Tag username_pass_tag = new Tag(0x81);
tags.Add(username_pass_tag);
username_pass_tag.AddTag(new Tag(0x10, Encoding.ASCII.GetBytes(username), 0x42));
username_pass_tag.AddTag(new Tag(0x11, password_hash));
}
else
{
tags.Add(new Tag(0x23, BitConverter.GetBytes(CodeSysV3Encryption.CHALLENGE)));
Tag username_pass_tag = new Tag(0x81);
tags.Add(username_pass_tag);
byte[] password_hash = //此处的加密算法为上文提到的加密算法,C#实现暂时不予公开;
username_pass_tag.AddTag(new Tag(0x10, Encoding.ASCII.GetBytes(username), 0x42));
username_pass_tag.AddTag(new Tag(0x11, password_hash));
}
Send(CmdGroup.CmpDevice, 2, tags.ToArray());
var(service_layer, _tags) = Read();
if (_tags.Count > 0 && service_layer.cmd_group == (ushort)CmdGroup.CmpDevice && service_layer.subcmd == 2)
{
Tag session_id = _tags[0x82][0][0x21];
if (session_id != null)
{
_session_id = BitConverter.ToUInt32(session_id.data,0);
Logger.writeSystemLog($"Login successfully to the device, session id: {_session_id:X4}");
return true;
}
else
{
Logger.writeSystemLog($"Failed to login to the device, error_code: {BitConverter.ToUInt16(_tags[0x82][0][0x20].data,0):X4}");
}
}
}
catch (Exception ex)
{
Logger.writeSystemLog($"Failed to login to the device, error: {ex}");
}
return false;
}
演示视频
Codesys通讯测试视频
总结与鸣谢
总结
本文的描述与代码的实现均是在工控安全领域的前辈们的文章指导下完成,在这里特别感谢看雪论坛的前辈们。
引用
https://bbs.kanxue.com/thread-276027.htm