多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

西门子S7-1200/1500动态加密功能块:从CPU绑定到时间锁的完整实现

西门子S7-1200/1500动态加密功能块:从CPU绑定到时间锁的完整实现 先说说这个事情的来龙去脉。做非标设备的工程师应该都有这个体会设备调试完、程序写好了客户现场运行起来稳定没问题接下来甲方就可能琢磨着让别的供应商来维护或者把你的程序拷走研究。辛辛苦苦调出来的逻辑、算了好几天的工艺参数一下就变成公开资源了。西门子S7-1200/1500用得越广这个问题就越明显——博途里写的程序块如果不做任何保护别人拿到项目文件之后打开就能看到所有梯形图、SCL代码连你DB块里的注释都看得一清二楚。所以今天聊的这个“动态加密功能块”说白了就是解决这么几件事第一程序交付之后不能被人随便读走、改掉第二设备卖给客户之后尾款没结清或者维保合同到期了怎么给自己留一个技术上的回收手段第三同一套程序装到多台设备上怎么做到每一台只能在一台指定的CPU上运行。这篇文章我会把思路、原理、实操步骤、踩过的坑全部摊开来说适合手里正好在做S7-1200/1500项目、又不想交付之后被动的工程师。内容尽量讲透但话说在前面加密这件事本质上是提高破解门槛不存在绝对打不开的程序我们的目标是把门槛抬高到让对方觉得不值得去搞。1. 项目背景与核心需求拆解1.1 为什么需要“动态加密”而不是简单加个密码很多人一开始接触博途以为给程序块设个访问密码就算加密了。确实博途有Know-how保护功能可以给FC、FB、DB、OB设置密码设置之后别人打开块只能看到接口定义看不到内部实现。但这里有个致命的问题这种密码是静态的。只要你的密码被泄露——不管是通过U盘拷贝项目时被复制还是某个售后工程师把密码口述给了客户——保护就彻底失效了。我在实际项目里遇到过这种情况一套三台设备的程序用的是同一个密码做的Know-how保护结果客户现场维修时自己买了个软件狗把项目下载出来用密码一解锁整个程序全部暴露了。从那以后我意识到静态密码只能作为第一层防护真正的“动态加密”应该是基于CPU本身来绑定的——程序块在CPU里跑出来的结果是对的但是你想把这块的逻辑原样搬走搬不走因为逻辑内部依赖了CPU的序列号、时间信息等动态变量。所以“动态加密功能块”实际包含两层含义。第一层是程序块的访问保护利用博途原生机制让外部无法读取内部代码第二层是运行期校验程序在CPU里跑的时候会周期性地或者在上电时检查“当前CPU是不是我授权的那台CPU”“当前时钟是不是被人篡改了”“授权期限是不是到了”。这两层叠在一起才叫真正的动态加密。1.2 三种主流保护方案的选型对比我在多个项目里试过不同的方案现在基本上形成了自己的选型逻辑。单独用Know-how保护实现成本最低但防御强度也最低单独用CPU序列号绑定能防住“同一套程序拷到别的PLC上运行”这种情况但防不住别人改程序逻辑单独用时间锁能控制设备使用时长但客户如果把PLC的系统时间往前改就能白嫖很长时间。所以我的习惯是把三种方案做一个组合遵循的原则是“适用场景不同组合方式不同”。方案实现成本防护对象局限博途Know-how保护低防止程序被直接读取查看密码泄露即失效CPU序列号绑定中防止程序被拷贝到其他CPU运行无法控制授权时长动态时间锁中控制设备使用期限、尾款保障系统时间可被篡改三合一组合高读取、拷贝、期限三重防护存在一定误码率和维护成本1.3 动态加密的边界认知这里要泼一盆冷水。在PLC领域做加密和IT领域做AES加密完全不是一个量级的攻防。PLC程序运行在客户现场的硬件上CPU是客户的程序下载权限理论上也在客户手里只要对方愿意暴力破解他可以把你的块删除掉、重新写一个空块替代绕过你的功能块调用。所以动态加密程序的作用对象通常是“没有很强技术背景的现场维护人员”和“另一家自动化公司的基础工程师”而不是顶尖的反逆向专家。想清楚这一点很重要否则你会把时间浪费在追求完美的加密算法上忽略了真正该做的——把加密机制设计得足够复杂让破解成本高于重新开发一套程序的成本这样客户自然就不折腾了。我在方案设计时经常跟同事说加密不是为了让你永远安全而是让你的程序在被破解之前价值已经兑现完了。2. 博途原生块保护机制的正确用法2.1 Know-how Protection的操作步骤与细节先讲基础操作。在TIA Portal中选中你写好的功能块FB或者FC右键选择“Know-how protection”弹窗里输入密码并确认。保护之后块图标上会多一个小锁标记。别人打开项目双击这个块看到的只有接口参数、注释、Interface里的变量定义内部实现代码完全不可见。如果勾选了“允许查看接口”选项还可以看到输入输出变量名称这对使用者调参是有必要的如果不勾选连接口定义都被隐藏对方只能看到你留的外部引脚说明。操作上有一个容易被忽视的点加保护之前一定要先编译通过并且保存好源文件副本。因为Know-how保护是不可逆的一旦你加密之后忘了密码神仙也救不回来。博途官方提供的密码找回服务只在特定商业授权条件下才提供对普通工程师来说基本等于没有。我自己就吃过这个亏一个写了两个星期的配方管理FB加密之后密码遗失导致后面升级维护时只能重新写一遍逻辑。2.2 ENO输出的正确处理方式SCL编写的功能块默认会在块底部生成一个ENO输出。很多人的习惯是忽略ENO认为它只是梯形图里的一个级联标志。但是如果你做了Know-how保护ENO就变成了外部调用者判断功能块是否执行成功的重要依据。你的加密块内部一旦发生异常——比如序列号校验失败、时间鉴权失败——应该主动把ENO置为FALSE并且让调用方通常是OB1或者其他FB根据ENO的状态去切换设备的安全模式。这里有个实操细节在SCL中直接写ENO : FALSE是不规范的更可靠的做法是在块的输出参数里显式定义一个错误状态字比如ErrorCode同时在块声明里把ENO的可见性打开。SCL代码运行结束的时候根据内部逻辑决定ENO状态可以通过RETURN或者直接给ENO赋值。我测试过在TIA V15/V16/V17上给ENO赋值FALSE可以让梯形图级联断开但要注意赋值操作不能写在条件判断分支里导致某些路径没有赋值否则编译器会报警告。2.3 接口混淆与DB数据隐藏用过几次Know-how保护之后你会发现单纯加密块还不够。因为对方虽然看不到块内部代码但能通过TAG表和你定义的DB变量名称大概猜出来程序在干什么。比如你设备工艺管线里有个DB叫“DesiredTemperature_Out”对方一看就知道这是个温度设定值。所以我在做保护之前习惯把关键DB变量名做一遍“混淆处理”改成无意义的编号比如DBW[1]、DBW[2]。当然这会牺牲一些程序可读性所以通常是项目交付前最后做开发阶段不要混淆否则自己维护都累死。除了变量名混淆还可以把DB设为“仅由功能块访问”Optimized DB加保护这样外部程序或者外部OPC UA无法直接读写这个DB只有加密块自己内部可以访问。这个功能在S7-1500上原生支持S7-1200的优化访问也可以做。加密块和受保护的DB交叉使用外部人员即使看到了DB也无法在在线监视中看到实时变化的数据明细只能看到一堆地址。3. 动态加密核心CPU序列号绑定与校验逻辑3.1 读取CPU序列号的底层方式如果说Know-how保护是“静态锁”那序列号绑定就是“动态锁”的钥匙——程序拿当前CPU的身份证去比对身份证对不上就直接罢工。S7-1200/1500想获取CPU序列号最标准的方式是调用系统指令RDREC读取数据记录通过CPU的硬件标识符读取记录号为1的数据块从里面解析出模块标识信息包括订货号和序列号。我推荐的做法是先在TIA Portal的设备视图里找到CPU查看“硬件标识符”属性记下CPU的ID值。然后建立一段SCL代码声明一个自定义数据块作为RECORD缓冲区长度设64个字节调用RDREC。序列号字符串通常出现在数据记录的固定偏移位置但不同固件版本偏移位置略有差异。所以更稳妥的方法是读取出完整的记录后在缓冲区里搜索“Serial number”ASCII字符串找到之后往下偏移固定字节读取实际序列号。这个方法比硬编码偏移要稳得多因为西门子在固件更新时改变过数据记录结构。3.2 序列号校验的逻辑设计拿到序列号之后不能直接拿它和DB里存的序列号比较——如果直接比较别人在监控表里看到你DB里存了一串“S V 1 2 3 4 5 6 7 8”用变量监控工具就能看到明文然后照着改自己的程序轻松绕过。所以正确的做法是把序列号经过一个自定义的变换得到“指纹值”比较的是指纹值而不是序列号本身。我在项目里常用的一套简单算法是这样的// 输入序列号字符串 // 输出8位十六进制指纹字符串 #len : LEN(#serialStr); #finger : 0; FOR #i : 1 TO #len DO #byte : BYTE_TO_UINT(CHAR_TO_UINT(MID(IN : #serialStr, L : 1, P : #i))); #finger : #finger #byte * (20 (#i MOD 13)); #finger : ROL(IN : #finger, N : #i MOD 5); END_FOR; #fingerString : CONCAT(INT_TO_STRING(#finger), AX);思路就是逐个字符取ASCII码乘上带位置因子的权重系数再累加每轮循环做一次循环左移。最终累加结果转成字符串接上一个固定尾缀。这样做的好处是序列号的微小差异哪怕只有一个字符不同会导致指纹值完全不一样而且从指纹值反推序列号需要解一个含模运算和循环移位的方程人工反推几乎不可能。用这个指纹去和DB中存储的“期望指纹”比较一样就代表当前CPU是经过授权的。3.3 代码实现过程中容易踩的坑这个算法的实现过程中我踩过不少坑在这里集中说出来帮你避一避。首先是ROL指令在S7-1200和S7-1500上的支持差异。S7-1500有专门的循环左移指令但S7-1200的SCL编译环境中如果你直接在FB里用ROL会提示指令不存在。解决办法是改用SHL和SHR的组合来模拟循环左移把高位移出的位补到低位。写一个函数接口比较麻烦但可行。其次是字符串与字符数组的转换。SCL里处理字符串变量直接索引下标会得到字符而不是字节值你需要用CHAR_TO_INT转换注意ASCII码的映射。如果原始序列号里包含数字和字母这个转换没什么问题但如果序列号长度不固定需要先确认你有边界保护否则串越界会读到脏数据。我在代码里添加了长度判断如果LEN(#serialStr) 0直接用错误码退出。最后是性能问题。序列号校验逻辑如果每个扫描周期都跑一遍字符串循环40个字符的序列号做40次乘法和移位CPU负载虽不大但几十个功能块都这么做扫描周期会被拖累。我建议把校验做成边沿触发第一次扫描或者上电沿时才执行结果存在静态变量里后续周期只用比较一个指纹值而已。这样既保证安全性又不影响程序实时性。4. 动态授权与时间限制的实现4.1 时间锁的基本原理与SCL框架动态加密的另一半是时间锁。设备卖出去合同约定分三期付款第二期没到账这时候程序里的授权逻辑可以控制设备在某个日期之后只能低速运行或者直接禁止自动运行。时间锁的核心很简单PLC内部有实时时钟功能块每次上电读取当前RTC时间对比一个授权的截止时间超过就进入限制模式。具体实现是这样的在FB内部维护一个静态变量LastPowerOnTime声明为Retain保持型同时在一个受保护的DB里存一个AuthDeadline授权截止时间。每次上电时读取当前时间CurrentTime如果CurrentTime AuthDeadline说明授权到期把功能块内部标志AuthOK置为FALSE。外部OB1调用这个FB的时候根据AuthOK状态决定是否允许主程序继续执行。我习惯的做法是即使授权到期也不是一刀切让设备停死——那样容易引起安全事故——而是先报警、再限制部分功能、最后停机分三级处理给现场留出应对时间。4.2 防止时间被往回调的关键设计时间锁最大的软肋就是客户把PLC系统时间往回调。假设授权截止时间是2025年6月30日客户在6月29日把时间调到2024年6月29日PLC就会一直认为授权还没到期。为了防这招我在设计里引入了一个“时钟单调性检查”。逻辑也很简单每次上电读取当前时间与LastPowerOnTime比较如果当前时间比上次上电时间还要早并且早的幅度超过一个阈值我设为2小时就判定系统时间被人为往回调整过。触发判定之后功能块记录一次ClockTamperCount计数大于设定值就强制进入锁定状态提示需要输管理员恢复码才能解锁。这个防篡改机制有个边界情况要小心PLC如果长期断电内部的保持型存储区数据会丢失LastPowerOnTime会复位成初始值0而RTC时间由于超级电容维持不了太久也会回到出厂初始年份。这时候上电比较电流时间小于上次时间会被误判为“人为调表”。所以我在判定代码里做了处理如果LastPowerOnTime的年份小于一个基准年份比如2020就跳过篡改判定当作首次上电处理。4.3 授权续期的方式与安全操作那么正常的授权续期怎么做最稳妥的做法是在加密功能块里开放一个隐藏的“授权接口”只有输入合法的“续期码”才能修改AuthDeadline。这个续期码由OEM工程师根据CPU序列号和新的截止日期算出来离线生成现场输入。续期码的计算方式我在上位机用C#写了一个小工具调用和PLC端相同的算法逻辑输入序列号和截止日期输出一串16位十六进制字符。现场维护人员把这段字符通过触摸屏的隐藏密码页录入功能块校验通过后更新截止日期。这样做还有一个好处就算客户拿到了这串续期码也只能在特定CPU上针对特定截止日期使用换一台CPU或者改一个日期码就失效了。同时续期码是一次性的功能块内部校验通过后会把该码标记为已使用防止反复延期。我在实际项目中用这个方法维护了二十多台设备可以负责任地说效果相当稳定。5. 实操过程从建块到联调的完整流程5.1 创建加密功能块的开发环境配置下面说一遍我是怎么从零开始建这个加密功能块的。先打开TIA Portal新建一个项目在PLC数据类型或者程序块区域点击添加新块选择FB块语言选SCL。注意SCL和LAD相比对复杂逻辑和循环表达更友好加密算法里如果非要用梯形图写出来会非常痛苦。所以我强烈建议用SCL语言创建这个功能块。创建好之后第一件事是定义接口参数。我常用的参数集合是这样的输入EN、REQ触发校验、SerialInput可选外部输入的序列号调试时用输出AuthOK授权是否通过、AuthStatus错误码、DeviceInfo解析出的设备信息静态变量区放LastPowerOnTime、TamperCount、FingerprintResult。接口定义别做得太多因为Know-how保护之后外部能看到的只有接口定义接口越少暴露信息越少。5.2 SCL核心代码的编写与编译常见问题我来给出一个简化但结构完整的时间锁序列号校验功能块代码框架你可以在此基础上扩展FUNCTION_BLOCK FB_EncryptAuth { S7_Optimized_Access : TRUE } VERSION : 0.1 VAR_INPUT REQ : BOOL; END_VAR VAR_OUTPUT AuthOK : BOOL; AuthStatus : WORD; END_VAR VAR // 静态变量 LastPowerOnTime : DTL; TamperCount : INT; FirstRun : BOOL : TRUE; END_VAR BEGIN // 第一次调用时读取系统时间初始化保持变量 IF #FirstRun THEN #FirstRun : FALSE; // 读取RTC时间的指令具体用RD_SYS_T或者外部传入简化为系统时间变量 // 此处示意实际项目中可用系统时间块赋值 #LastPowerOnTime : #currentTimeFromRTC; #AuthOK : TRUE; #AuthStatus : 16#0000; RETURN; END_IF; // 如果收到重新校验请求 IF #REQ THEN // 1. 校验CPU序列号指纹此处简化 // 2. 校验时间单调性 IF #currentTimeFromRTC #LastPowerOnTime THEN #TamperCount : #TamperCount 1; IF #TamperCount 3 THEN #AuthStatus : 16#A101; // 时间被篡改 #AuthOK : FALSE; RETURN; END_IF; END_IF; // 3. 校验授权截止时间 IF #currentTimeFromRTC #AuthDeadline THEN #AuthStatus : 16#A102; // 授权过期 #AuthOK : FALSE; RETURN; END_IF; // 4. 连续运行限制可选 // 5. 更新上电时间 #LastPowerOnTime : #currentTimeFromRTC; #AuthOK : TRUE; #AuthStatus : 16#0000; END_IF; END_FUNCTION_BLOCK这个框架省略了RTC读取的具体实现是因为不同项目里获取时间的方式有差异——可以直接调用系统块RD_SYS_T也可以在OB1里把系统时间读出来通过接口传进来。我自己的项目里更喜欢在OB1的启动组织块中读取时间通过FB的输入传入方便仿真调试。编译时经常碰到的SCL问题我顺便整理一下。第一DTL类型比较大小需要在同一个时区基准下别拿一个不含毫秒的DTL和含毫秒的DTL直接比较先把毫秒字段清零或者统一格式。第二Retain变量的声明需要放在VAR区并勾选保持属性如果你用的是优化访问的FB需要在FB属性中开启“保持性”。第三避免在SCL中直接访问IEC定时器静态实例的内部结构应该调用定时器指令封装好的接口。5.3 加密、下载与现场联调的经验功能块写完并且在公司实验室仿真通过之后下一步是加密和联调。先在仿真环境里完整测试一遍序列号校验逻辑用PLCSIM模拟一个CPU读出仿真系统的序列号生成指纹值再故意修改DB里的期望指纹确认功能块输出错误码。这一步非常关键因为到客户现场才发现算法有问题改动成本和尴尬程度都会成倍上升。测试没问题之后在TIA Portal里右键功能块设置Know-how保护。加密之后再次编译、下载到PLC。这里有一个经验之谈下载的时候如果CPU里已经有旧的未加密版本需要先做“一致下载”或者“完全下载”否则会出现DB块数据不一致的提示。加密块的DB变量在下载后无法在线监视内部值所以联调时你需要额外设计一个调试DB——我通常留一个公开的调试DB存一些中间计算结果方便在现场查看交付前再把它删掉。现场联调时带上一个断路器、一个万用表重点检查设备断电重启之后RTC时间是否正确。很多客户的现场有UPSPLC断电时间很短RTC不会跑偏但有些老旧设备断电一晚上第二天上电时我会观察到系统时间落后了几个小时。这属于正常现象不会被防篡改机制误判因为时间落后会和LastPowerOnTime比较但如果落后太多可能触发篡改判定阈值。所以阈值的设定要结合现场实际情况我用2小时是经过测试的各位可以根据自己客户的设备情况进行微调。6. 常见问题与排查技巧实录6.1 序列号读不出来怎么办读序列号失败是大家遇到最多的一个问题。我遇到过的情况包括RDREC返回状态码16#8092表示数据记录不存在或者读出来的RECORD里面全是0。排查思路是这样的先在设备视图里确认CPU硬件标识符再在上位机测试工具里用同一个标识符调用RDREC对比返回值如果返回记录长度不够把缓冲区长度从32改成64试试如果返回的数据里找不到序列号关键字那可能是固件版本太新修改了数据记录的结构。还有一个常见问题是S7-1200和S7-1500读取序列号的方式不完全一样。S7-1500可以通过RDREC直接读取S7-1200特别是老固件版本可能在标准调用方式上有限制需要用扩展指令或者通过系统块GET_DIAG间接获取。如果你手头正好是S7-1200建议优先查一下当前固件版本支持的指令集再用通用方式读取。网上的资料不少但版本关系比较乱最靠谱的是看你博途里具体版本自带的帮助文档。6.2 加密块被别人套壳绕过怎么办有人把带有Know-how保护的功能块复制一份改个名字然后在OB1里不调用你的原块调用它复制出来的这个壳逻辑上你的加密块就完全被绕过了。这种情况在同行竞争里并不少见。单纯靠Know-how保护是防不住这种做法的因为博途的水平比较高的工程师完全可以把你的FB从项目中提取出来重新封装。我的解决方案是“程序完整性自检”在主OB或者某个周期性调用的块里加入一个“心跳校验”逻辑。加密功能块在运行过程中会不断更新一个静态变量的值更新过程依赖一个内部递推序列——比如每次执行都调用相同的数学变换对外表现为一串连续变化的数值。主程序里放一个影子变量用同样的递推公式计算期望值如果连续若干个扫描周期密码块没有更新到期望值主程序就认为加密块被绕过了直接跳入安全状态所有输出置零并记录报警信息。这个逻辑实现起来不复杂但要注意递推序列的种子值不能写死在程序里得依赖当前扫描周期计数器的低几位比特或者依赖CPU时钟的一部分这样外部更难通过静态分析来伪造递推结果。6.3 加密程序拖慢CPU扫描周期怎么办字符串处理、循环移位、数据记录读取这些操作比较消耗资源如果在主扫描周期里频繁执行S7-1200的扫描周期可能从原本的5ms飙升到30ms以上这是不能接受的。我把校验逻辑归纳成两类一类是一次性的上电校验只在OB100或者第一次扫描时执行另一类是周期性的轻量校验比如每秒执行一次“指纹缓存比较”只是整数比较开销几乎可以忽略。我实际测试过把序列号求指纹的逻辑放在第一次扫描时跑40个字符的处理时间在S7-1500上是微秒级的在S7-1200上大约增加了2~3ms的启动时间完全不影响运行。但如果你偷懒直接放在OB1里每周期执行肯定会被FATAL错误打爆。所以代码组织结构很重要加密块内部用边沿检测控制逻辑运行频率平时只是简单判断一个布尔标志把复杂算法放到上升沿里。6.4 授权失效导致设备停机怎么快速恢复现场最怕出现的情况就是授权到期后设备直接停机客户急得跳脚售后电话被打爆。我上面提到分级处理策略就是为了避免这种情况。除了分级停机还应该设计一个“紧急恢复通道”功能块内部每60秒检查一次是否有人输对了授权恢复码恢复码由OEM用万能密钥生成不依赖具体CPU序列号可以在紧急情况下临时延长12小时授权给工程师赶到现场留出窗口。这个恢复通道是双刃剑——它同时也是整个加密系统的后门。所以我把它放到一个独立的受保护FC块里并且设置了严格的输错锁定连续5次错误锁定1小时。基于我自己的维护经验建议工程师开工时就把这个恢复码算法和主加密算法分别保存分别交给不同的人保管避免一个人同时掌握全部权限。这个做法和个人密码保管的习惯一样双重控制能最大限度降低被内部泄露的风险。结尾的一点个人体会做这套动态加密功能块前前后后测试了大概一个多月最大的体会是技术上最难的不是写代码而是想清楚“你到底要防谁、对方会怎么下手、如果防不住怎么不把设备搞瘫”。加密逻辑太简单防不住有经验的工程师加密逻辑太复杂又会影响PLC的运行稳定性和维护效率。这个度需要根据自己产品的实际价值来判断设备单台利润高、程序复用性强加密投入就值得多一点非标单台一次性项目其实做好Know-how保护加一个简单的序列号校验就够了。最后再分享一个实操小技巧在块接口定义里留一个输入参数DEBUG_EN平时置FALSE联调时置TRUE会输出详细错误码到公共DB。交付客户时把DEBUG_EN的默认值改成FALSE但是按钮留在调试页面上。这样做既方便你现场排查问题又不会让客户轻易看到内部状态。等加密逻辑跑一年半载、验证稳定之后再考虑把这个调试口彻底删掉。项目加密这条路永远是“够用就行留有余地”这也是我这几年做非标自动化最大的心得之一。
返回列表