本文仅做信息安全学习记录讨论,请勿用于非法用途。由此造成的一切问题由操作者自行承担。
评论区有一些有用的信息。

  • 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 端口,旧版彩屏机提供的后台网页,功能很少,设计很烂,现已不常见。本文不表。

修改打卡时间的思路

  1. 修改时间补卡。可以通过超管 8888(见 0x02 章节)或者 SDK(见 0x05 章节)修改时间,然后补卡。前提是考勤表是类似于课程表那种,或打卡信息按照考勤机打卡时间顺序升序,而非真实先后顺序升序排序打卡时间(见 0x03 章节);
  2. 获取并修改 ZKDB.db 文件
    • ZKDB.db 类型为 SQLite3 格式文件(见 0x01 章节);
    • 如果你有了 telnet 权限,参考 0x01 使用 tftp 命令将文件传输至电脑即可;
    • 如果你无法进入 telnet,可以通过超管 8888(见 0x02 章节)或者 SDK(见 0x05 章节)将自己设为管理员后进入后台,并确认考勤机版本较新,这样可以在后台通过 U 盘将考勤机数据备份出来,其中就有 ZKDB.db,修改完成后将备份还原到考勤机上;
    • 参考 0x04 章节对 ZKDB.db 新增触发器,每日打卡时自动调整时间;
    1. 社会工程学搞定人事(见《我和人事小姐姐的故事》)。

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 版本1libverify.so,获得如下可能的新算法。

密码算法 2

首先,通过简单验证输入的 PIN 是否为超管 PIN 8888,如果是,则进入超管账号的验证流程。进入后,机器获取名为「BreakNormal」2的配置,如果配置存在且不为 0,则采用前文的密码算法1进行验证,反之进入如下的新版流程:

  1. 通过 ngx_crc32_short 和中控的自选算法,计算出机器序列号的校验值,并用 999999 - 校验值,得到数 A,由于该算法得到的校验值结果恒小于 900000,故 A 必为 6 位。此处假设得到的 A = 995764;
  2. 提供某个长度小于等于 8 位的密码,该密码满足:
    • 密码的最后一位 = 前面所有位的和 + 5 的结果的最后一位,比如密码前所有位为 1234567,则 1 + 2 + ... + 7 = 28,28 + 5 = 33,最后一位是 3,所以最后密码为 12345673
  3. 取密码前所有位数 B,此处 B = 123456;如果不满 6 位的,在前面补 0;
  4. 将 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 再拼接;
  5. 将当前日期时间戳(秒数)通过自选算法计算得到时间戳校验值 D;
  6. 若 144 < C - D < 8641,则视为合法的动态密码。

实测的时候发现一个问题:第 6 步的 C - D 的值,按照分析结果需要落在 (144, 8641) 区间内,但实测结果是其他区间。我机器上尝试出来的下界是 3023,上界暂未测出来。

下面是我编写的密码生成算法的测试,第一个框输入序列号,第二个框输入 offset,点生成密码即可。第三个框输入 yyyyMMdd 格式的日期,适用于:修改系统时间为非当日时间后,再次尝试进入后台。offset 可能需要你自己尝试。我用 5000 在我自己的机器上是没有问题的。-999999 这样的大负数也没问题。

这里不是图片,是可以真实操作的

如果结果是 NaN,或者得到的密码位数不为 8 位,或者输入后密码错误,请更换 offset 的值再尝试。评论区已经有老哥尝试成功,他的考勤机序列号还是带字母的,也可以用

这个密码有几个特性:

  1. 由于用于计算的时间戳去除了时分秒的影响,故密码在同一天内都有效;
  2. 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 盘和管理后台获取备份文件(建议)

你可以通过两种常见的方式来进入考勤机后台:

  1. 参照 0x02 超管 8888 章节,通过工号为 8888 的隐藏超管用户进入后台。该方法不要求考勤机联网;
  2. 参照 0x03 使用 ZKTime 5.0/SDK 获取管理权限 章节的几个 API,获取管理员密码,或将自己设置成管理员,而后通过管理员工号验证进入后台。该方法要求你能通过局域网访问考勤机。

Insert USB Intro

进入后台后,在考勤机上插上你的 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_INFO 表中找到本人记录并修改密码

本篇说明中,示例的本人工号,即 User_PIN 为 9. 本文将用 9 作为示例。具体到你操作时,请记得替换为你本人的工号。

在进入编辑触发器的章节之前,假设你没有任何有关于 SQLite 的知识,那么我们需要按照如下的步骤热身。

0x04 << 3 热身:模拟打卡

使用 SQLiteStudio 载入 ZKDB.db 文件,按照下面的步骤,在 ATT_LOG 表中新增一条记录:

在 ATT_LOG 表中新增一条数据

ATT_LOG 表第一次插入数据完成

没错!所谓的「打卡」,就是往ATT_LOG表内插入一条新的数据行。其中关键字段记录了本次打卡的信息。上面的操作已经为你新增了一条于 2024-02-24 08:46:32 的上班打卡记录。你也可以通过以下的SQL 编辑器方式新增一条下班打卡记录:

在 ATT_LOG 使用 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(插入)的两条「打卡」记录:

ATT_LOG 第一次插入数据结果视图

请记住本次打卡的两条记录的 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

ATT_LOG 第一次查询结果

可以看到你查询出了两条记录,正是你于 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相差
12024-02-24T08:30:00-
22024-02-24T09:00:0030分钟
32024-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函数来统计满足查询条件的记录数、用substrrandomabsjuliandaystrftime函数来实现复杂的功能了!离你后续深入理解触发器又更近了一步。

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

ATT_LOG 第一次 UPDATE 操作

参照 0x04 << 4 热身:查询打卡记录 相同步骤,再次打开新的SQL 编辑器进行查询:

ATT_LOG 第二次查询结果

可以发现,你的上班「打卡」记录已经变成迟到前了!现在你已经学会了如何通过UPDATE语句,精准修改指定 ID 的打卡记录了。只是,总不能每次你都将上班时间改成 08:29:59 吧?你可不是 Timing 侠。有没有一条语句,每次UPDATE都能随机生成时间,又能确保其生成的时间总在 08:30 以前呢?

修改打卡时间为满足条件的随机时间

让我们回顾随机生成符合条件的时间这一小节,在其中,我们介绍了如何使用randomabsjuliandaystrftime来组合生成随机符合条件的时间。我们也可以将其用来修改符合条件的打卡时间:

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 即可。

😎现在,你已经学会了INSERTSELECTUPDATEDELETE语句,你已经是一个合格的程序员了。接下来我们将进入本章的关键小节:编写触发器。

0x04 << 9 编写触发器

按照下面的步骤,我们开始新增触发器:

在 ATT_LOG 表中新增触发器 - 1

在 ATT_LOG 表中新增触发器 - 2

其中,前提条件代码分别如下:

前提条件:

(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 字段的值来判断触发器是否对指定人员生效,当你离职后,你的信息被管理员删除,而后被释放的原本属于你的 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 的入门和教程,了解基本的INSERTUPDATESELECT语句的语法,甚至是触发器的语法和原理来完成这件事。过程中,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


  1. 你可以使用 JEB Decompiler 来进行 MIPS 程序的分析。

  2. 如果你参照 0x04 章节获取了 ZKDB.db,你会发现备份文件内部同目录下有一个名为 ZKConfig.cfg 的文件。对于支持新版超管密码算法的机子,里面就存放着 BreakNormal 的配置。你可以通过修改该配置来切换新旧密码算法。