【基于TCP的Codesys V3协议的C#实现】

基于TCP的Codesys V3协议的C#实现

前言

Codesys是全球最著名的软PLC内核软件研发厂家德国的3S(SMART,SOFTWARE,SOLUTIONS)公司发布的一款与制造商无关IEC 61131-1编程软件及工控设备内核(runtime SDK)。CodeSys被大量的应用在工控领域中,市场占有率35%,被誉为“工控界的安卓”。
Codesys平台组成
CodeSys属于私有协议,官方从未披露其报文格式和实现细节。为此,研究人员不得不从逆向工程的角度去分析该协议(除非购买昂贵的授权获取到相关源码)。如今CodeSys V2版本逐步被淘汰,取而代之是V3版本,本文将会从逆向角度介绍该协议的格式以及授权机制。
CodeSys覆盖的客户如下所示(只列出一部分,详细情况参考官网 http://www.codesys.cn/list-Partner.html):
Codesys目前的使用者

Codesys V3的协议格式

该协议架构如下图所示,该协议栈分为四层:
1.块驱动层——Block Driver layer
2.数据报层——Datagram layer
3.通道层——Channel layer
4.服务层——Services layer
Codesys V3协议层结构
CodeSys V3通过ServerRegisterServiceHandler注册对应service_group处理的handler(相当于传统意义上上的功能码):
PLC固件反编译结果
所有注册的服务如下图所示(图示为本人编写的C#代码内容),可以看到CodeSys协议包含相当多的功能,包括用户管理,日志管理,文件传输等功能。
Codesys V3功能码
为了帮助理解和研究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:
客户端获取challenge
PLC将会随机生成一个密钥对和一个32字节的challenge,响应包将包含公钥和challenge。
公钥信息如下所示:
公钥信息
32 字节challenge信息如下:
公钥字段
RSA加密使用的是PKCS1_OAEP,其中的哈希算法为SHA256。
XOR算法是简单的逐位异或算法。
最后,客户端再次通过CmpDevice的2号服务ID下发到PLC中:
密码加密与下发
如果密码正确的话将会返回app key,每次进行操作都需要携带该授权key。
获取AppKey

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

### 汇川 OPC UA Server 软件下载、安装与使用教程 #### 下载 为了获取汇川OPC UA Server软件,建议访问官方渠道或授权合作伙伴网站。通常这类工业自动化产品会在制造商官方网站提供最新版本的下载链接。确保从可信来源下载以保障安全性和兼容性。 #### 安装 完成下载后,遵循标准的Windows应用程序安装流程: 1. 双击已下载的安装包启动安装向导。 2. 阅读并接受许可协议条款。 3. 选择目标文件夹,默认路径通常是合适的选项除非有特殊需求。 4. 执行安装过程直至结束提示出现。 #### 使用教程 一旦成功安装了汇川OPC UA Server,在初次运行时可能需要进行一些初始配置工作来适配具体的硬件环境和网络条件[^4]。 - **连接设置** - 进入“Connection”菜单项下的编辑界面。 - 输入PLC设备对应的IP地址及其他必要参数(具体参照随附的手册《Codesys_Opc_Server_V3_User_Guide》章节6.2.6部分),以便建立稳定的数据交换通道。 - **服务端配置** - 利用内置工具定义数据节点及其属性,这些节点代表了可被远程访问的对象模型中的实体。 - 设置安全性策略,包括但不限于认证机制、加密算法的选择等措施,从而保护通信链路免受未授权访问威胁。 - **客户端集成** - 将汇川OPC UA Server作为中间件接入现有的SCADA系统或者其他监控平台。 - 测试不同类型的请求操作,验证能否正常接收到来自现场层设备的状态反馈以及执行控制指令。 ```csharp // 示例:创建一个简单的 C# 应用来连接汇川 OPC UA Server 并读取某个变量值 using Opc.Ua; using System; class Program { static void Main(string[] args) { var endpointUrl = "opc.tcp://localhost:4840"; ApplicationInstance application = new ApplicationInstance(); Session session = Session.Create( null, new ConfiguredEndpoint(new Uri(endpointUrl), EndpointConfiguration.Default), false, "", 60000, new UserIdentity(new AnonymousIdentityToken()), null).Result; // 假设我们要读取 ID 为 &#39;ns=2;s=MachineStatus&#39; 的节点 ReadValueIdCollection nodesToRead = new ReadValueIdCollection() { new ReadValueId(new NodeId("ns=2;s=MachineStatus"), 0, QualifiedName.Null, TimestampsToReturn.Neither) }; DataValueCollection results; DiagnosticInfoCollection diagnosticInfos; StatusCodeCollection statusCodes = session.Read(null, 0, TimestampsToReturn.Source, nodesToRead, out results, out diagnosticInfos); Console.WriteLine($"Node Value: {results[0].Value}"); } } ```
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值