本文仅做信息安全学习记录讨论,请勿用于非法用途。由此造成的一切问题由操作者自行承担。不提供任何远程指导,也不收费,任何需要交流的请在评论区直接评论。
评论区有一些有用的信息。
- 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
考勤机没有一拿下来就报警吧(zkt720s)
应该没有报警
offset是啥,随便取值吗?求解
随便,只要最终密码是8位就行