time.Now的底层研究

golang中的time.Now的研究与hook

前言

想要看看能不能通过简单的方式欺骗一个软件当前时间

尝试过程

编写简单demo

sudo apt install golang
mkdir test-go
cd test-go
go mod init test-go

代码

package main

import "time"
import "fmt"

func main() {
        fmt.Println(time.Now())
}

直接写time.Now()貌似会被golang的gc给回收掉

编译

go build .
./test-go

输出

2023-05-03 20:10:11.703231839 +0800 CST m=+0.000657001

strace定位系统调用

想通过strace直接查看软件的系统调用

strace ./test-go  2>syscalls.txt
cat syscalls.txt

得到的结果如下

cat syscalls.txt | grep -v rt_sig | grep -v SIGURG | grep -v mmap\( | grep -v clone\(

execve("./test-go", ["./test-go"], 0x7ffe03de7c10 /* 29 vars */) = 0
arch_prctl(ARCH_SET_FS, 0x532db0)       = 0
sched_getaffinity(0, 8192, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) = 32
openat(AT_FDCWD, "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", O_RDONLY) = 3
read(3, "2097152\n", 20)                = 8
close(3)                                = 0
sigaltstack(NULL, {ss_sp=NULL, ss_flags=SS_DISABLE, ss_size=0}) = 0
sigaltstack({ss_sp=0xc000004000, ss_flags=0, ss_size=32768}, NULL) = 0
gettid()                                = 1665715
futex(0xc000046548, FUTEX_WAKE_PRIVATE, 1) = 1
fcntl(0, F_GETFL)                       = 0x8402 (flags O_RDWR|O_APPEND|O_LARGEFILE)
futex(0xc000046948, FUTEX_WAKE_PRIVATE, 1) = 1
fcntl(1, F_GETFL)                       = 0x8402 (flags O_RDWR|O_APPEND|O_LARGEFILE)
fcntl(2, F_GETFL)                       = 0x8001 (flags O_WRONLY|O_LARGEFILE)
openat(AT_FDCWD, "/etc/localtime", O_RDONLY) = 3
read(3, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 561
read(3, "", 4096)                       = 0
close(3)                                = 0
write(1, "2023-05-03 20:10:11.703231839 +0"..., 55) = 55
exit_group(0)                           = ?
+++ exited with 0 +++

其实看到除了打开/etc/localtime获取时区之外,没有看到有关于获取当前时间的系统调用。

获取当前时间的系统调用列举:

  1. time():返回自1970年1月1日0时0分0秒以来经过的秒数。

  2. gettimeofday():返回系统时间和时区信息。

  3. ctime():将时间值转换为可读的字符串格式。

  4. localtime():将时间值转换为本地时间。

  5. gmtime():将时间值转换为UTC(Coordinated Universal Time)时间。

  6. strftime():将时间值按照指定格式转换为字符串。

  7. mktime():将结构体时间值转换为秒数。

  8. settimeofday():设置系统时间。

  9. adjtime():调整系统时钟的偏差。

  10. clock_gettime():返回指定时钟的时间值。

以上是常见的日期相关的系统调用,实际上还有其他的系统调用可用于更加精确的时间计算和操作。

思考

突然想起了之前上jyy的课的时候学到的关于linux vdso的知识。

关于vdso(摘抄自知乎

在linux中,某些系统调用返回的结果其实并不涉及任何数据安全问题,因为特权用户root和非特权用户都会获得相同的结果。这就意味着其实完全可以通过新的实现机制来避免执行系统调用的开销,因为本质上gettimeofday()就是从内核中读取与时间相关的数据(虽然会有一些复杂的计算过程)。与其费尽心力一定要通过陷入内核的方式来读取这些数据,不如在内核与用户态之间建立一段共享内存区域,由内核定期“推送”最新值到该共享内存区域,然后用户态程序在调用这些系统调用的glibc库函数的时候,库函数并不真正执行系统调用,而是通过vsyscall page来读取该数据的最新值,相当于将系统调用改造成了函数调用,直接提升了执行性能。

简而言之,vdso,是一个共享的内存页面,它是在内核态的,它的数据由内核来维护,但是,用户态也有权限访问这个内核页面,由此,不通过中断gettimeofday也就拿到了系统时间。所以在x86_64体系中,strace跟踪不到这个系统调用。

所以现在的思路

阅读golang源码,看time.Now()如何获取系统时间

golang源码

git clone https://github.com/golang/go.git

其实应该很好定位代码的位置

src/time/time.go:1110

跟进

src/runtime/timestub.go:15

后续了解到,这里的函数返回值是(sec int64, nsec int32, mono int64) 其实都是一样的表示当前时间,只是时间的单位区别,比如毫秒纳秒微秒。

我们继续跟进walltime()或者nanotime()函数

src/runtime/os_aix.go:338

可以看到walltime()或者nanotime()都被我们找到了

最后都是调用了clock_gettime函数

src/runtime/os2_aix.go:545

我们来到clock_gettime函数的内容,很简单,就是调用了用golang封装的syscall

src/runtime/os2_aix.go:230

我们观察一下syscall的代码,golang自举的代码真的看的还是挺让人震撼的,帅啊!

看到这里,是不是想着,这不还是通过系统调用完成的吗?

其实并不是这样的,上面的都是手动跟踪的源码,其实是有偏颇的。

now()跟踪到time_now()函数其实都是自己臆想出来的。

要真正的查看调用流程还得自己debug

debug golang source

如何debug golang,可以通过goland远程连接ssh或者wsl,在goland里面debug很方便。

具体参照goland给的文档

然后配置一下Run/Debug Configurations就可以直接debug了

然后找到src/cmd/compile/main.go:46就可以直接debug调试

src/time/time.go:1110

src/runtime/time_linux_amd64.s:14

// func time.now() (sec int64, nsec int32, mono int64)
TEXT time·now<ABIInternal>(SB),NOSPLIT,$16-24
	MOVQ	SP, R12 // Save old SP; R12 unchanged by C code.

	MOVQ	g_m(R14), BX // BX unchanged by C code.

	// Set vdsoPC and vdsoSP for SIGPROF traceback.
	// Save the old values on stack and restore them on exit,
	// so this function is reentrant.
	MOVQ	m_vdsoPC(BX), CX
	MOVQ	m_vdsoSP(BX), DX
	MOVQ	CX, 0(SP)
	MOVQ	DX, 8(SP)

	LEAQ	sec+0(FP), DX
	MOVQ	-8(DX), CX	// Sets CX to function return address.
	MOVQ	CX, m_vdsoPC(BX)
	MOVQ	DX, m_vdsoSP(BX)

	CMPQ	R14, m_curg(BX)	// Only switch if on curg.
	JNE	noswitch

	MOVQ	m_g0(BX), DX
	MOVQ	(g_sched+gobuf_sp)(DX), SP	// Set SP to g0 stack

noswitch:
	SUBQ	$32, SP		// Space for two time results
	ANDQ	$~15, SP	// Align for C code

	MOVL	$0, DI // CLOCK_REALTIME
	LEAQ	16(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CMPQ	AX, $0
	JEQ	fallback
	CALL	AX

	MOVL	$1, DI // CLOCK_MONOTONIC
	LEAQ	0(SP), SI
	MOVQ	runtime·vdsoClockgettimeSym(SB), AX
	CALL	AX

ret:
	MOVQ	16(SP), AX	// realtime sec
	MOVQ	24(SP), DI	// realtime nsec (moved to BX below)
	MOVQ	0(SP), CX	// monotonic sec
	IMULQ	$1000000000, CX
	MOVQ	8(SP), DX	// monotonic nsec

	MOVQ	R12, SP		// Restore real SP

	// Restore vdsoPC, vdsoSP
	// We don't worry about being signaled between the two stores.
	// If we are not in a signal handler, we'll restore vdsoSP to 0,
	// and no one will care about vdsoPC. If we are in a signal handler,
	// we cannot receive another signal.
	MOVQ	8(SP), SI
	MOVQ	SI, m_vdsoSP(BX)
	MOVQ	0(SP), SI
	MOVQ	SI, m_vdsoPC(BX)

	// set result registers; AX is already correct
	MOVQ	DI, BX
	ADDQ	DX, CX
	RET

fallback:
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	MOVL	$1, DI // CLOCK_MONOTONIC
	LEAQ	0(SP), SI
	MOVQ	$SYS_clock_gettime, AX
	SYSCALL

	JMP	ret

已经开始看不懂了。但是我们可以借助ChatGPT来帮我们看汇编代码,他给出的回复总结如下:

该段代码主要使用了vdso技术获取当前时间。根据CLOCK_REALTIME和CLOCK_MONOTONIC分别获取系统实时时间和单调时间。如果vdso技术不可用,则使用系统调用fallback方式获取时间。

接下来的思路

我们知道vdso在libc里面实现**(后续了解到其实并不是,是在linux kernel里面,参考)**,那我们是否可以patch libc,然后通过LD_PRELOAD来加载我们自己的libc,从而欺骗程序呢?试试

glibc debug

用vscode写c语言的感觉,像是又回到大一

debug glibc有很多方案,感觉都比较麻烦,这里使用patchelf方案来解决,sudo apt install patchelf

glibc编译,后续会用到,注意,如果要make install,请不要让--prefix=/usr,否则你的系统的glibc会被顶低调,这是很危险的!

我们写如下的vscode配置文件

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "gdbdebug", // 随便
            "type": "cppdbg", // c语言用cppdbg没毛病
            "request": "launch", // 不知道是啥
            "program": "${workspaceFolder}/build/${fileBasenameNoExtension}", // 每次debug运行的binary文件
            "args": [], // 程序的参数
            "stopAtEntry": false, // 是否停在main函数或者__start函数的首行
            "cwd": "${workspaceFolder}", // working directory
            "environment": [], // 环境变量
            "externalConsole": false, // 不启用外部Console (terminal), 在linux里面启用会出现异常的错误
            "MIMode": "gdb", // 不知道是啥
            "miDebuggerPath": "gdb", // 不知道是啥
            "preLaunchTask": "Build", // 此项命名需要与tasks.json中的label项命名一致,编译程序使用
            "setupCommands": [ // 不知道是啥
                {
                    "description": "为 gdb 启用整齐打印",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                }
            ]
        }
    ]
}

tasks.json

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "Build", // 需与lauch.json中"preLaunchTask"项命名一致
            "type": "shell", // shell命令
            "command": "gcc", // 编译用的命令
            "args": [
              "${file}",
              "-g",
              "-o",
              "${workspaceFolder}/build/${fileBasenameNoExtension}" // 输出的目标binary
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            },
            // Use the standard MS compiler pattern to detect errors, warnings and infos
            "problemMatcher": [
                "$gcc"
            ]
        }
    ]
}

test.c

#include "stdio.h"

int main(void) {
    printf("hello world!\n");
    return 0;
}

记得手动创建一下build目录,不然会提示权限不足,F5运行test.c即可

gettimeofday

man 2 gettimeofday看一下怎么用

#include <sys/time.h>

// int gettimeofday(struct timeval *tv, struct timezone *tz);

// The tv argument is a struct timeval (as specified in <sys/time.h>):
struct timeval
{
    time_t tv_sec;       /* seconds */
    suseconds_t tv_usec; /* microseconds */
};

// and gives the number of seconds and microseconds since the Epoch (see time(2)).

// The tz argument is a struct timezone:

struct timezone
{
    int tz_minuteswest; /* minutes west of Greenwich */
    int tz_dsttime;     /* type of DST correction */
};
// If either tv or tz is NULL, the corresponding structure is not set or returned.  (However, compilation warnings will result if tv is NULL.)

写下我们的代码进行测试

#include "stdio.h"
#include <sys/time.h>

int main(void)
{
    struct timeval tv;
    
    gettimeofday(&tv, NULL);

    printf("Is is %ld now\n", tv.tv_sec);
    return 0;
}

正确回显了当前的unix时间戳

接下来debug一下libc中gettimeofday的逻辑,正常debug的时候是进不了gettimeofday的具体逻辑的,我们需要添加环境变量让gcc加载我们自己编译的glibc

修改launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "gdbdebug", // 随便
            "type": "cppdbg", // c语言用cppdbg没毛病
            "request": "launch", // 不知道是啥
            "program": "${workspaceFolder}/build/${fileBasenameNoExtension}", // 每次debug运行的binary文件
            "args": [], // 程序的参数
            "stopAtEntry": false, // 是否停在main函数或者__start函数的首行
            "cwd": "${workspaceFolder}", // working directory
            "externalConsole": false, // 不启用外部Console (terminal), 在linux里面启用会出现异常的错误
            "MIMode": "gdb", 
            "miDebuggerPath": "gdb",
            "preLaunchTask": "buildAndSetGlibc", // 此项命名需要与tasks.json中的label项命名一致,编译程序使用
            "setupCommands": [ // gdb启动时的命令
                {
                    "description": "为 gdb 启用整齐打印",
                    "text": "-enable-pretty-printing",
                    "ignoreFailures": true
                },
            ]
        }
    ]
}

tasks.json

{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "buildAndSetGlibc", 
            "type": "shell", 
            "command": "/bin/bash",
            "args": [
                "setGlibc.sh"
            ],
        }
    ]
}

setGlibc.sh

gcc test.c -o build/test -g
patchelf --replace-needed libc.so.6 /glibc/x64/2.35/lib/libc.so.6 build/test
patchelf --set-interpreter /glibc/x64/2.35/lib/ld-linux-x86-64.so.2 build/test

无语了,debug了好几天的glibc,一直跳不进去gettimeofday函数,我以为是环境有问题,结果我发现只有这个跳不进去,其他都可以,服了

可以简单测试一下修改glibc里面的逻辑,我们随便改一个sleep.c

/* Sleep for a given number of seconds.  POSIX.1 version.
   Copyright (C) 1991-2022 Free Software Foundation, Inc.
   This file is part of the GNU C Library.

   The GNU C Library is free software; you can redistribute it and/or
   modify it under the terms of the GNU Lesser General Public
   License as published by the Free Software Foundation; either
   version 2.1 of the License, or (at your option) any later version.

   The GNU C Library is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   Lesser General Public License for more details.

   You should have received a copy of the GNU Lesser General Public
   License along with the GNU C Library; if not, see
   <https://www.gnu.org/licenses/>.  */

#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <errno.h>
#include <sys/param.h>


/* Make the process sleep for SECONDS seconds, or until a signal arrives
   and is not ignored.  The function returns the number of seconds less
   than SECONDS which it actually slept (zero if it slept the full time).
   If a signal handler does a `longjmp' or modifies the handling of the
   SIGALRM signal while inside `sleep' call, the handling of the SIGALRM
   signal afterwards is undefined.  There is no return value to indicate
   error, but if `sleep' returns SECONDS, it probably didn't work.  */
unsigned int
__sleep (unsigned int seconds)
{
  int save_errno = errno;
  printf("you are sleeping....\n"); // 加一句哈哈哈
  const unsigned int max
    = (unsigned int) (((unsigned long int) (~((time_t) 0))) >> 1);
  struct timespec ts = { 0, 0 };
  do
    {
      if (sizeof (ts.tv_sec) <= sizeof (seconds))
        {
          /* Since SECONDS is unsigned assigning the value to .tv_sec can
             overflow it.  In this case we have to wait in steps.  */
          ts.tv_sec += MIN (seconds, max);
          seconds -= (unsigned int) ts.tv_sec;
        }
      else
        {
          ts.tv_sec = (time_t) seconds;
          seconds = 0;
        }

      if (__nanosleep (&ts, &ts) < 0)
        /* We were interrupted.
           Return the number of (whole) seconds we have not yet slept.  */
        return seconds + ts.tv_sec;
    }
  while (seconds > 0);

  __set_errno (save_errno);

  return 0;
}
weak_alias (__sleep, sleep)

test.c

#include "time.h"
#include "stdio.h"


int main(void)
{
    // struct timeval tv;
    // gettimeofday(&tv, NULL);
    sleep(1);
    // printf("Is is %ld now\n", tv.tv_sec);
    // printf("Is is %ld now\n", tv.tv_usec);
    printf("Hello world!\n");
    return 0;
}

然后重新编译libc,然后运行我们的程序,可以看到patch成功了

失败的我

gettimeofday的逻辑在linux kernel里面,不能简单patch libc来修改。怎么办呢?

后续

libfaketime

使用libfaketime绕过了一些系统应用,原理还是使用LDPRELOAD劫持libc的函数,这其实就是上面的工作想做的,但是奈何写不来c代码,也不懂怎么劫持libc的函数,所以源码简单看了一下,放弃了,后续再认真看吧。

虽然但是,这个方案依然对golang无效,原因:

修改系统时间 (成功)

再后来想到了修改系统的时间,一些就成功了。。但是这个方法应该会对系统的其他应用产生一些影响

[root@test ~]# timedatectl set-time "2018-01-22 23:29"
Failed to set time: Automatic time synchronization is enabled
[root@test ~]# timedatectl set-ntp 0
[root@test ~]# timedatectl set-time "2018-01-22 23:29"
[root@test ~]# timedatectl set-ntp 1
[root@test ~]# timedatectl set-time "2018-01-22 23:29"
Failed to set time: Automatic time synchronization is enabled
[root@test ~]#