硬核!如何在容器中做時(shí)間漫游者
本文轉(zhuǎn)載自微信公眾號(hào)「董澤潤的技術(shù)筆記」,作者董澤潤。轉(zhuǎn)載本文請(qǐng)聯(lián)系董澤潤的技術(shù)筆記公眾號(hào)。
題目稍有些標(biāo)題黨,最近公司想用 chaos-mesh 對(duì) k8s 做混沌測試,開始做前期的調(diào)研,發(fā)現(xiàn) pingcap[1] 對(duì)時(shí)間的注入非常硬核,而且最終方案居然是實(shí)習(xí)生構(gòu)思出來的 ^^ 感謝 pingcap 貢獻(xiàn)的項(xiàng)目
TL;DR: 通過劫持 vdso, 將時(shí)間函數(shù)跳轉(zhuǎn)到 hack 過的匯編指令來實(shí)現(xiàn) time skew. 原理不難懂,但細(xì)節(jié)超多,參考官方文檔[2]
為什么需要 time skew
可以參考 Chaos Mesh - 讓時(shí)間在容器中自由搖擺[3], 簡單來說就是:
分布式數(shù)據(jù)庫要實(shí)現(xiàn)全局一致性快照,很多方案使用時(shí)間做邏輯時(shí)鐘,所以需要解決不同節(jié)點(diǎn)之間時(shí)鐘一致的問題。但往往物理節(jié)點(diǎn)上的物理時(shí)間總是會(huì)出現(xiàn)偏差,不管是使用 NPT 服務(wù)同步也好,或者其他方法總是沒辦法完全避免出現(xiàn)誤差,這時(shí)候如果我們的應(yīng)用不能夠很好的處理這樣的情況的話,就可能造成無法預(yù)知的錯(cuò)誤。
其實(shí)這很符合工程設(shè)計(jì)哲學(xué):design for failure, 任何一個(gè)硬件或是軟件都會(huì)有錯(cuò)誤(fault),系統(tǒng)如何在不影響對(duì)外提供服務(wù)的前提下,如何處理這些故障,就是我們常說的 fault tolerance
但是對(duì)于非金融業(yè)務(wù)來說,時(shí)間偏移一點(diǎn)影響并不大,相比其它 chaos, time的場景還是受限一些
如何注入
從實(shí)體機(jī)的經(jīng)驗(yàn)來看,所謂的混沌測試都比較直觀的,比如用 tc 做網(wǎng)絡(luò)的丟包,限速來模擬網(wǎng)絡(luò)故障,使用 stress 模擬 cpu 壓力。但是在容器中做如何模擬 time skew 呢?
如果直接使用 linux date 命令修改,會(huì)影響到宿主機(jī)上其它所有容器。有沒有方法能只影響某個(gè)容器?
之前發(fā)過一篇文章 時(shí)鐘源為什么會(huì)影響性能[4], 從中可以看到,go 調(diào)用系統(tǒng)時(shí)間函數(shù)時(shí),會(huì)先調(diào)用 vdso 的代碼,如果時(shí)鐘源符合條件,直接在用戶空間完成,并不會(huì)進(jìn)入內(nèi)核空間,所以針對(duì)這一現(xiàn)象,syscall 劫持的方法就不能使用了
那么是否可以直接修改 vdso 段代碼呢?
查看 vdso
- # cat /proc/1970/maps
- ......
- 7ffe8478a000-7ffe847ab000 rw-p 00000000 00:00 0 [stack]
- 7ffe847bb000-7ffe847be000 r--p 00000000 00:00 0 [vvar]
- 7ffe847be000-7ffe847bf000 r-xp 00000000 00:00 0 [vdso]
- ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
可以看到 vdso 代碼段的起始邏輯地址,同時(shí)注意權(quán)限位是 r-xp, 這就意味著用戶態(tài)的進(jìn)程是無法直接修改該內(nèi)容。
真的就沒辦法了嘛?有的,ptrace[5] 法力無邊
The ptrace() system call provides a means by which one process (the "tracer") may observe and control the execution of another process (the "tracee"), and examine and change the tracee's memory and registers. It is primarily used to implement breakpoint debugging and system call tracing.
ptrace 提供了一種修改和觀察其它進(jìn)程的手段,包括修改內(nèi)存值和寄存器,巧了這些 chaos-mesh 都用到了。如何實(shí)現(xiàn) go 調(diào)試器[6] 這篇文章也講了 ptrace 的用途,很棒的文章。
整體實(shí)現(xiàn)
這就是簡單的流程圖,主要代碼都是 time_linux_amd64.go[7], 當(dāng)前僅支持 linux amd64 平臺(tái),不支持 Windows/MacOS
- // ModifyTime modifies time of target process
- func ModifyTime(pid int, deltaSec int64, deltaNsec int64, clockIdsMask uint64) error {
- ......
- runtime.LockOSThread() // 將當(dāng)前 goroutine 綁定底層線程
- defer func() {
- runtime.UnlockOSThread()
- }()
- program, err := ptrace.Trace(pid) // ptrace 獲得 program
- if err != nil {
- return err
- }
- defer func() {
- err = program.Detach()
- if err != nil {
- log.Error(err, "fail to detach program", "pid", program.Pid())
- }
- }()
- var vdsoEntry *mapreader.Entry // 遍歷 entry 找到 vdso
- for index := range program.Entries {
- // reverse loop is faster
- e := program.Entries[len(program.Entries)-index-1]
- if e.Path == "[vdso]" {
- vdsoEntry = &e
- break
- }
- }
- if vdsoEntry == nil {
- return errors.New("cannot find [vdso] entry")
- }
- // minus tailing variable part
- // 24 = 3 * 8 because we have three variables
- constImageLen := len(fakeImage) - 24
- var fakeEntry *mapreader.Entry
- // find injected image to avoid redundant inject (which will lead to memory leak)
- for _, e := range program.Entries {
- e := e
- image, err := program.ReadSlice(e.StartAddress, uint64(constImageLen))
- if err != nil {
- continue
- }
- if bytes.Equal(*image, fakeImage[0:constImageLen]) {
- fakeEntry = &e // 遍歷找到 fake Image Entry,不能重復(fù)生成
- log.Info("found injected image", "addr", fakeEntry.StartAddress)
- break
- }
- }
- if fakeEntry == nil { // 如果 fakeEntry 不存在,用 Mmap 分配內(nèi)存,內(nèi)容是 fakeImage 匯編指令
- fakeEntry, err = program.MmapSlice(fakeImage)
- if err != nil {
- return err
- }
- }
- fakeAddr := fakeEntry.StartAddress
- // 139 is the index of CLOCK_IDS_MASK in fakeImage 寫 clockidsmask
- err = program.WriteUint64ToAddr(fakeAddr+139, clockIdsMask)
- if err != nil {
- return err
- }
- // 147 is the index of TV_SEC_DELTA in fakeImage 寫偏移量秒
- err = program.WriteUint64ToAddr(fakeAddr+147, uint64(deltaSec))
- if err != nil {
- return err
- }
- // 155 is the index of TV_NSEC_DELTA in fakeImage 寫偏移量納秒
- err = program.WriteUint64ToAddr(fakeAddr+155, uint64(deltaNsec))
- if err != nil {
- return err
- }
- // 找到 clock_gettime 在 vdso 中的位置
- originAddr, err := program.FindSymbolInEntry("clock_gettime", vdsoEntry)
- if err != nil {
- return err
- }
- // originAddr 位置 hijack, 寫上 jump 指令,跳轉(zhuǎn)到 fakeImage
- err = program.JumpToFakeFunc(originAddr, fakeAddr)
- return err
- }
代碼寫上了注釋,分別對(duì)應(yīng)上面的流程圖。下面分解來看。
1. Ptrace
- type TracedProgram struct {
- pid int
- tids []int
- Entries []mapreader.Entry
- backupRegs *syscall.PtraceRegs
- backupCode []byte
- }
TracedProgram 結(jié)構(gòu)體比較簡單,pid 是待注入 chaos 的進(jìn)程 id, 同時(shí) tids 保存所有的線程 id, Entries 是進(jìn)程邏輯地址空間,
Trace 函數(shù)在代碼 ptrace_linux_amd64.go[8] 中
通過讀取 /proc/{pid}/task 獲取進(jìn)程的所有線程,然后分別對(duì)所有線程執(zhí)行 linux ptrace 調(diào)用。然后生成 Entries, 什么是 Entry 呢?就是上文提到的 /proc/{pid}/maps 內(nèi)容
- ......
- 7ffe8478a000-7ffe847ab000 rw-p 00000000 00:00 0 [stack]
- 7ffe847bb000-7ffe847be000 r--p 00000000 00:00 0 [vvar]
- 7ffe847be000-7ffe847bf000 r-xp 00000000 00:00 0 [vdso]
- ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
2. Mmap FakeImage
查找 vdso[9], 如何失敗,直接退出。一般 vdso 都在最后,所以從尾開始遍歷
同時(shí)還要查找 fakeEntry, 如果存在,直接復(fù)用。否則會(huì)造成內(nèi)存泄漏,當(dāng)然了,一直創(chuàng)建新的 fakeEntry .....
- // MmapSlice mmaps a slice and return it's addr
- func (p *TracedProgram) MmapSlice(slice []byte) (*mapreader.Entry, error) {
- size := uint64(len(slice))
- addr, err := p.Mmap(size, 0)
- if err != nil {
- return nil, errors.WithStack(err)
- }
- err = p.WriteSlice(addr, slice)
- if err != nil {
- return nil, errors.WithStack(err)
- }
- return &mapreader.Entry{
- StartAddress: addr,
- EndAddress: addr + size,
- Privilege: "rwxp",
- PaddingSize: 0,
- Path: "",
- }, nil
- }
注意,這不是簡單的調(diào)用 Mmap Syscall !!! ptrace.Syscall[12] 是利用 ptrace 控制進(jìn)程,讓目標(biāo)進(jìn)程單步執(zhí)行 syscall
- // Syscall runs a syscall at main thread of process
- func (p *TracedProgram) Syscall(number uint64, args ...uint64) (uint64, error) {
- err := p.Protect() // 保存目標(biāo)進(jìn)程的寄存器
- if err != nil {
- return 0, err
- }
- var regs syscall.PtraceRegs
- err = syscall.PtraceGetRegs(p.pid, ®s)
- if err != nil {
- return 0, err
- }
- regs.Rax = number // 設(shè)置操作 syscall number, 填充其它參數(shù)
- for index, arg := range args {
- // All these registers are hard coded for x86 platform
- if index == 0 {
- regs.Rdi = arg
- } else if index == 1 {
- regs.Rsi = arg
- } else if index == 2 {
- regs.Rdx = arg
- } else if index == 3 {
- regs.R10 = arg
- } else if index == 4 {
- regs.R8 = arg
- } else if index == 5 {
- regs.R9 = arg
- } else {
- return 0, fmt.Errorf("too many arguments for a syscall")
- }
- }
- err = syscall.PtraceSetRegs(p.pid, ®s)
- if err != nil {
- return 0, err
- }
- ip := make([]byte, ptrSize)
- // We only support x86-64 platform now, so using hard coded `LittleEndian` here is ok. 設(shè)置 rip 寄存器
- binary.LittleEndian.PutUint16(ip, 0x050f)
- _, err = syscall.PtracePokeData(p.pid, uintptr(p.backupRegs.Rip), ip)
- if err != nil {
- return 0, err
- }
- err = p.Step() // 單步執(zhí)行
- if err != nil {
- return 0, err
- }
- err = syscall.PtraceGetRegs(p.pid, ®s)
- if err != nil {
- return 0, err
- }
- return regs.Rax, p.Restore() // 獲取返回值,并且恢復(fù)寄存器
- }
參考代碼的注釋,搞過嵌入式的肯定熟悉:保存寄存器現(xiàn)場,設(shè)置新的寄存器值為 syscall number 以及參數(shù),最后設(shè)置指令寄存器 rip 單步執(zhí)行,就完成了讓目標(biāo)進(jìn)程執(zhí)行 mmap 的操作,最后也要恢復(fù)寄存器,還原現(xiàn)場。
這里為什么 rip 寄存器要設(shè)置成 0x050f 呢???其實(shí)這是 syscall 的操作碼
另外 p.WriteSlice 是使用 syscall process_vm_writev 將數(shù)據(jù)寫入目標(biāo)進(jìn)程的內(nèi)存邏輯地址空間。
3. FindSymbolInEntry
- ~# file /tmp/vdso.so
- /tmp/vdso.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=17d65245b85cd032de7ab130d053551fb0bd284a, stripped
- ~# objdump -T /tmp/vdso.so
- /tmp/vdso.so: file format elf64-x86-64
- DYNAMIC SYMBOL TABLE:
- 0000000000000950 w DF .text 00000000000000a1 LINUX_2.6 clock_gettime
- 00000000000008a0 g DF .text 0000000000000083 LINUX_2.6 __vdso_gettimeofday
- 0000000000000a00 w DF .text 000000000000000a LINUX_2.6 clock_getres
- 0000000000000a00 g DF .text 000000000000000a LINUX_2.6 __vdso_clock_getres
- 00000000000008a0 w DF .text 0000000000000083 LINUX_2.6 gettimeofday
- 0000000000000930 g DF .text 0000000000000015 LINUX_2.6 __vdso_time
- 0000000000000930 w DF .text 0000000000000015 LINUX_2.6 time
- 0000000000000950 g DF .text 00000000000000a1 LINUX_2.6 __vdso_clock_gettime
- 0000000000000000 g DO *ABS* 0000000000000000 LINUX_2.6 LINUX_2.6
- 0000000000000a10 g DF .text 000000000000002a LINUX_2.6 __vdso_getcpu
- 0000000000000a10 w DF .text 000000000000002a LINUX_2.6 getcpu
FindSymbolInEntry 函數(shù)很簡單,就是要找到 clock_gettime 在 vdso 中的地址,參考我之前的文章,上面是 dump 出來的符號(hào)表
4. JumpToFakeFunc
- // JumpToFakeFunc writes jmp instruction to jump to fake function
- func (p *TracedProgram) JumpToFakeFunc(originAddr uint64, targetAddr uint64) error {
- instructions := make([]byte, 16)
- // mov rax, targetAddr;
- // jmp rax ;
- instructions[0] = 0x48
- instructions[1] = 0xb8
- binary.LittleEndian.PutUint64(instructions[2:10], targetAddr)
- instructions[10] = 0xff
- instructions[11] = 0xe0
- return p.PtraceWriteSlice(originAddr, instructions)
- }
JumpToFakeFunc[13], 修改 vdso 符號(hào)表中的匯編代碼,使所有調(diào)用 clock_gettime 的都跳轉(zhuǎn)到我們 fakeEntry 的地址,劫持 vdso
FakeImage
- var fakeImage = []byte{
- 0xb8, 0xe4, 0x00, 0x00, 0x00, //mov $0xe4,%eax
- 0x0f, 0x05, //syscall
- 0xba, 0x01, 0x00, 0x00, 0x00, //mov $0x1,%edx
- 0x89, 0xf9, //mov %edi,%ecx
- 0xd3, 0xe2, //shl %cl,%edx
- 0x48, 0x8d, 0x0d, 0x74, 0x00, 0x00, 0x00, //lea 0x74(%rip),%rcx # <CLOCK_IDS_MASK>
- 0x48, 0x63, 0xd2, //movslq %edx,%rdx
- 0x48, 0x85, 0x11, //test %rdx,(%rcx)
- 0x74, 0x6b, //je 108a <clock_gettime+0x8a>
- 0x48, 0x8d, 0x15, 0x6d, 0x00, 0x00, 0x00, //lea 0x6d(%rip),%rdx # <TV_SEC_DELTA>
- 0x4c, 0x8b, 0x46, 0x08, //mov 0x8(%rsi),%r8
- 0x48, 0x8b, 0x0a, //mov (%rdx),%rcx
- 0x48, 0x8d, 0x15, 0x67, 0x00, 0x00, 0x00, //lea 0x67(%rip),%rdx # <TV_NSEC_DELTA>
- 0x48, 0x8b, 0x3a, //mov (%rdx),%rdi
- 0x4a, 0x8d, 0x14, 0x07, //lea (%rdi,%r8,1),%rdx
- 0x48, 0x81, 0xfa, 0x00, 0xca, 0x9a, 0x3b, //cmp $0x3b9aca00,%rdx
- 0x7e, 0x1c, //jle <clock_gettime+0x60>
- 0x0f, 0x1f, 0x40, 0x00, //nopl 0x0(%rax)
- 0x48, 0x81, 0xef, 0x00, 0xca, 0x9a, 0x3b, //sub $0x3b9aca00,%rdi
- 0x48, 0x83, 0xc1, 0x01, //add $0x1,%rcx
- 0x49, 0x8d, 0x14, 0x38, //lea (%r8,%rdi,1),%rdx
- 0x48, 0x81, 0xfa, 0x00, 0xca, 0x9a, 0x3b, //cmp $0x3b9aca00,%rdx
- 0x7f, 0xe8, //jg <clock_gettime+0x48>
- 0x48, 0x85, 0xd2, //test %rdx,%rdx
- 0x79, 0x1e, //jns <clock_gettime+0x83>
- 0x4a, 0x8d, 0xbc, 0x07, 0x00, 0xca, 0x9a, //lea 0x3b9aca00(%rdi,%r8,1),%rdi
- 0x3b, //
- 0x0f, 0x1f, 0x00, //nopl (%rax)
- 0x48, 0x89, 0xfa, //mov %rdi,%rdx
- 0x48, 0x83, 0xe9, 0x01, //sub $0x1,%rcx
- 0x48, 0x81, 0xc7, 0x00, 0xca, 0x9a, 0x3b, //add $0x3b9aca00,%rdi
- 0x48, 0x85, 0xd2, //test %rdx,%rdx
- 0x78, 0xed, //js <clock_gettime+0x70>
- 0x48, 0x01, 0x0e, //add %rcx,(%rsi)
- 0x48, 0x89, 0x56, 0x08, //mov %rdx,0x8(%rsi)
- 0xc3, //retq
- // constant
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //CLOCK_IDS_MASK
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //TV_SEC_DELTA
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //TV_NSEC_DELTA
- }
fakeImage 最后三個(gè)參數(shù)是偏移量,以及傳遞的 CLOCK_IDS_MASK, 這些匯編是什么意思呢???
查看匯編操作碼[14],0xe4 是系統(tǒng)調(diào)用 clock_gettime 的操作碼,后續(xù)都是對(duì)結(jié)果進(jìn)行注入,要么增要么減,制造偏移量 time skew
測試案例
- # git clone https://github.com/chaos-mesh/chaos-mesh
- # cd chaos-mesh; make watchmaker
首先下載 chaos-mesh, 然后編譯 watchmaker, 這是一個(gè)方便注入的小工具。
- package main
- import (
- "fmt"
- "time"
- )
- func main() {
- fmt.Println("start print time")
- for {
- fmt.Printf("now %v\n", time.Now())
- time.Sleep(time.Second * 20)
- }
- }
上面是測試的代碼,每隔 20 打印當(dāng)前時(shí)間,編譯執(zhí)行,同時(shí)用 watchmaker 注入 time skew
- # ./watchmaker -pid 1970 -sec_delta -300
隔一段時(shí)間間后,再次執(zhí)行停止執(zhí)行注入
- # ./watchmaker -pid 1970 -sec_delta 0
- # ./test
- start print time
- now 2021-05-26 03:31:46.701902309 +0000 UTC m=+0.000131483
- now 2021-05-26 03:32:06.702230391 +0000 UTC m=+20.000459585
- now 2021-05-26 03:32:26.702406569 +0000 UTC m=+40.000635793
- now 2021-05-26 03:27:46.702688433 +0000 UTC m=+60.000918297
- ^@now 2021-05-26 03:28:06.702914898 +0000 UTC m=+80.001145022
- now 2021-05-26 03:28:26.703120914 +0000 UTC m=+100.001350878
- now 2021-05-26 03:28:46.703398463 +0000 UTC m=+120.001628357
- ^@now 2021-05-26 03:29:06.703707514 +0000 UTC m=+140.001937468
- now 2021-05-26 03:29:26.704025346 +0000 UTC m=+160.002255480
- now 2021-05-26 03:29:46.704302832 +0000 UTC m=+180.002532766
- ^@now 2021-05-26 03:35:06.704505387 +0000 UTC m=+200.002735491
- now 2021-05-26 03:35:26.704931111 +0000 UTC m=+220.003161235
上面是代碼執(zhí)行的輸出,可以看到 03:32:26 之后時(shí)間變成了 03:27:46, 停止注入后恢復(fù)
Limits
當(dāng)前的實(shí)現(xiàn),停止注入,并不會(huì)還原 vdso 代碼,也就是說 fakeEntry 會(huì)一直存在,每次 clock_gettime 都會(huì)跳轉(zhuǎn),只不過偏移量為 0 而己
由于以上原因的存在,注入及注入之后的 clock_gettime 都是走的 syscall 系統(tǒng)調(diào)用,性能很慢,敏感業(yè)務(wù)需要重啟,細(xì)節(jié)可以參考我之前的文章《時(shí)鐘源為什么會(huì)影響性能》
當(dāng)前注入,只能針對(duì)容器里的主進(jìn)程,那些 fork 出來,派生出來的無做做到注入
以上限制有沒有優(yōu)化空間呢?當(dāng)然有,問題都是用來解決的嘛~
小結(jié)
這次分享就這些,以后面還會(huì)分享更多的內(nèi)容,如果感興趣,可以關(guān)注并點(diǎn)擊左下角的分享轉(zhuǎn)發(fā)哦(:
參考資料
[1]Chaos Mesh - 讓時(shí)間在容器中自由搖擺: https://www.jianshu.com/p/6425050591b7,
[2]官方文檔: https://chaos-mesh.org/docs/chaos_experiments/timechaos_experiment/#limitation,
[3]讓時(shí)間在容器中自由搖擺: https://www.jianshu.com/p/6425050591b7,
[4]時(shí)鐘源為什么會(huì)影響性能: https://mp.weixin.qq.com/s/06SDQLzDprJf2AEaDnX-QQ,
[5]ptrace: https://man7.org/linux/man-pages/man2/ptrace.2.html,
[6]如何實(shí)現(xiàn) go 調(diào)試器: https://studygolang.com/articles/12804,
[7]time_linux_amd64.go: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/time/time_linux_amd64.go#L72,
[8]ptrace_linux_amd64.go: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/ptrace/ptrace_linux_amd64.go#L87,
[9]time_linux_amd64.go#L102 vdso: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/time/time_linux_amd64.go#L102,
[10]program.MmapSlice: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/time/time_linux_amd64.go#L132,
[11]FakeImage: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/time/time_linux_amd64.go#L28,
[12]ptrace.Syscall: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/ptrace/ptrace_linux_amd64.go#L251,
[13]JumpToFakeFunc: https://github.com/chaos-mesh/chaos-mesh/blob/master/pkg/ptrace/ptrace_linux_amd64.go#L480,
[14]匯編操作碼: https://chromium.googlesource.com/chromiumos/docs/+/master/constants/syscalls.md,