動態修改 C 語言函數的實現

關注倉庫,及時得到更新:iOS-Source-Code-Analyzegit

Follow: Draveness · Githubgithub

Objective-C 做爲基於 Runtime 的語言,它有很是強大的動態特性,能夠在運行期間自省、進行方法調劑、爲類增長屬性、修改消息轉發鏈路,在代碼運行期間經過 Runtime 幾乎能夠修改 Objecitve-C 層的一切類、方法以及屬性。shell

真正絕對意義上的動態語言或者靜態語言是不存在的。數組

C 語言每每會給咱們留下不可修改的這一印象;在以前的幾年時間裏,筆者確實也是這麼認爲的,然而最近接觸到的 fishhook 使我對 C 語言的不可修改有了更加深入的理解。數據結構

在文章中涉及到一個比較重要的概念,就是鏡像(image);在 Mach-O 文件系統中,全部的可執行文件、dylib 以及 Bundle 都是鏡像。app

fishhook 簡介

到這裏,咱們該簡單介紹一下今天分享的 fishhook;fishhook 是一個由 facebook 開源的第三方框架,其主要做用就是動態修改 C 語言函數實現框架

這個框架的代碼其實很是的簡單,只包含兩個文件:fishhook.c 以及 fishhook.h;兩個文件全部的代碼加起來也不超過 300 行。ide

不過它的實現原理是很是有意思而且精妙的,咱們能夠從 fishhook 提供的接口中入手。函數

從接口開始

fishhook 提供很是簡單的兩個接口以及一個結構體:oop

struct rebinding {
    const char *name;
    void *replacement;
    void **replaced;
};

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);

int rebind_symbols_image(void *header,
                         intptr_t slide,
                         struct rebinding rebindings[],
                         size_t rebindings_nel);

其中 rebind_symbols 接收一個 rebindings 數組,也就是從新綁定信息,還有就是 rebindings_nel,也就是 rebindings 的個數。

使用 fishhook 修改 C 函數

使用 fishhook 修改 C 函數很容易,咱們使用它提供的幾個範例來介紹它的使用方法。

這裏要修改的是底層的 open 函數的實現,首先在工程中引入 fishhook.h 頭文件,而後聲明一個與原函數簽名相同的函數指針:

#import "fishhook.h"

static int (*origianl_open)(const char *, int, ...);

而後從新實現 new_open 函數:

int new_open(const char *path, int oflag, ...) {
    va_list ap = {0};
    mode_t mode = 0;

    if ((oflag & O_CREAT) != 0) {
        // mode only applies to O_CREAT
        va_start(ap, oflag);
        mode = va_arg(ap, int);
        va_end(ap);
        printf("Calling real open('%s', %d, %d)\n", path, oflag, mode);
        return orig_open(path, oflag, mode);
    } else {
        printf("Calling real open('%s', %d)\n", path, oflag);
        return orig_open(path, oflag, mode);
    }
}

這裏調用的 original_open 其實至關於執行原 open;最後,在 main 函數中使用 rebind_symbols 對符號進行重綁定:

// 初始化一個 rebinding 結構體
struct rebinding open_rebinding = { "open", new_open, (void *)&original_open };

// 將結構體包裝成數組,並傳入數組的大小,對原符號 open 進行重綁定
rebind_symbols((struct rebinding[1]){open_rebinding}, 1);

// 調用 open 函數
__unused int fd = open(argv[0], O_RDONLY);

在對符號進行重綁定以後,全部調用 open 函數的地方實際上都會執行 new_open 的實現,也就完成了對 open 的修改。

fishhook-result

程序運行以後打印了 Calling real open('/Users/apple/Library/Developer/Xcode/DerivedData/Demo-cdnoozusghmqtubdnbzedzdwaagp/Build/Products/Debug/Demo', 0) 說明咱們的對 open 函數的修改達到了預期的效果。

整個 main.m 文件中的代碼在文章的最後面 main.m

fishhook 的原理以及實現

在介紹 fishhook 具體實現原理以前,有幾個很是重要的知識須要咱們瞭解,那就是 dyld、動態連接以及 Mach-O 文件系統。

dyld 與動態連接

dyld 是 the dynamic link editor 的縮寫(筆者並不知道爲何要這麼縮寫)。至於它的做用,簡單一點說,就是負責將各類各樣程序須要的鏡像加載到程序運行的內存空間中,這個過程發生的時間很是早 --- 在 objc 運行時初始化以前

在 dyld 加載鏡像時,會執行註冊過的回調函數;固然,咱們也可使用下面的方法註冊自定義的回調函數,同時也會爲全部已經加載的鏡像執行回調

extern void _dyld_register_func_for_add_image(
    void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide)
);

對於每個已經存在的鏡像,當它被動態連接時,都會執行回調 void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide),傳入文件的 mach_header 以及一個虛擬內存地址 intptr_t

須要注意的是,dyld 只負責將一些須要動態連接的庫加載進來,好比說 C 語言標準庫,或者 Foundation 這些 ObjC 提供的庫;而咱們本身在程序中寫的代碼是不會經過 dyld 進行加載的。

以一個最簡單的 Hello World 程序爲例:

#include <stdio.h>

int main(int argc, const char * argv[]) {
    printf("Hello, World!\n");
    return 0;
}

代碼中只引用了一個 stdio 庫中的函數 printf;咱們若是 Build 這段代碼,生成可執行文件以後,使用下面的命令 nm

$ nm -nm HelloWorld

nm 命令能夠查看可執行文件中的符號(對 nm 不熟悉的讀者能夠在終端中使用 man nm 查看手冊):

(undefined) external _printf (from libSystem)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000f50 (__TEXT,__text) external _main

在可執行文件中的符號列表中,_printf 這個符號是未定義(undefined)的,換句話說,編譯器還不知道這個符號對應什麼東西。

可是,若是在文件中加入一個 C 函數 hello_world

#include <stdio.h>

void hello_world() {
    printf("Hello, World!\n");
}

int main(int argc, const char * argv[]) {
    printf("Hello, World!\n");
    return 0;
}

在構建以後,一樣使用 nm 查看其中的符號:

(undefined) external _printf (from libSystem)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000f30 (__TEXT,__text) external _hello_world
0000000100000f50 (__TEXT,__text) external _main

咱們的符號 _hello_world 並非未定義的(undefined),它包含一個內存地址以及 __TEXT 段。也就是說手寫的一些函數,在編譯以後,其地址並非未定義的,這一點對於以後分析 fishhook 有所幫助。

使用 nm 打印出的另外一個符號 dyld_stub_binder 對應另外一個同名函數。dyld_stub_binder 會在目標符號(例如 printf)被調用時,將其連接到指定的動態連接庫 libSystem,再執行 printf 的實現(printf 符號位於 __DATA 端中的 lazy 符號表中):

fishhook-symbo

每個鏡像中的 __DATA 端都包含兩個與動態連接有關的表,其中一個是 __nl_symbol_ptr,另外一個是 __la_symbol_ptr

  • __nl_symbol_ptr 中的 non-lazy 符號是在動態連接庫綁定的時候進行加載的

  • __la_symbol_ptr 中的符號會在該符號被第一次調用時,經過 dyld 中的 dyld_stub_binder 過程來進行加載

0000000100001010    dq  0x0000000100000f9c  ; XREF=0x1000002f8, imp___stubs__printf

地址 0x0000000100000f9c 就是 printf 函數打印字符串實現的位置:

fishbook-printf

在上述代碼調用 printf 時,因爲符號是沒有被加載的,就會經過 dyld_stub_binder 動態綁定符號。

Mach-O

因爲文章中會涉及一些關於 Mach-O 文件格式的知識,因此在這裏會簡單介紹一下 Mach-O 文件格式的結構。

每個 Mach-O 文件都會被分爲不一樣的 Segments,好比 __TEXT, __DATA, __LINKEDIT

fishhook-mach-o

這也就是 Mach-O 中的 segment_command(32 位與 64 位不一樣):

struct segment_command_64 { /* for 64-bit architectures */
    uint32_t    cmd;        /* LC_SEGMENT_64 */
    uint32_t    cmdsize;    /* includes sizeof section_64 structs */
    char        segname[16];    /* segment name */
    uint64_t    vmaddr;        /* memory address of this segment */
    uint64_t    vmsize;        /* memory size of this segment */
    uint64_t    fileoff;    /* file offset of this segment */
    uint64_t    filesize;    /* amount to map from the file */
    vm_prot_t    maxprot;    /* maximum VM protection */
    vm_prot_t    initprot;    /* initial VM protection */
    uint32_t    nsects;        /* number of sections in segment */
    uint32_t    flags;        /* flags */
};

而每個 segment_command 中又包含了不一樣的 section

struct section_64 { /* for 64-bit architectures */
    char        sectname[16];    /* name of this section */
    char        segname[16];    /* segment this section goes in */
    uint64_t    addr;        /* memory address of this section */
    uint64_t    size;        /* size in bytes of this section */
    uint32_t    offset;        /* file offset of this section */
    uint32_t    align;        /* section alignment (power of 2) */
    uint32_t    reloff;        /* file offset of relocation entries */
    uint32_t    nreloc;        /* number of relocation entries */
    uint32_t    flags;        /* flags (section type and attributes)*/
    uint32_t    reserved1;    /* reserved (for offset or index) */
    uint32_t    reserved2;    /* reserved (for count or sizeof) */
    uint32_t    reserved3;    /* reserved */
};

你只須要對這幾個概念有一個簡單的瞭解,知道它們有怎樣的包含關係,當文章中挑出這個名字時,對它不是一無所知就足夠了,這裏並不會涉及太多相關的知識。

fishhook 的原理

到目前爲止,咱們對 dyld 以及 Mach-O 有了一個初步的瞭解,而 fishhook 使用了前面章節提到的 _dyld_register_func_for_add_image 註冊了一個回調,在每次加載鏡像到程序中執行回調,動態修改 C 函數實現。

在具體分析其源代碼以前,先爲各位讀者詳細地介紹它的實現原理:

dyld 經過更新 Mach-O 二進制文件 __DATA 段中的一些指針來綁定 lazy 和 non-lazy 的符號;而 fishhook 先肯定某一個符號在 __DATA 段中的位置,而後保存原符號對應的函數指針,並使用新的函數指針覆蓋原有符號的函數指針,實現重綁定。

整個過程能夠用這麼一張圖來表示:

fishhook-before-afte

原理看起來仍是很簡單的,其中最複雜的部分就是從二進制文件中尋找某個符號的位置,在 fishhook 的 README 中,有這樣一張圖:

fishhook-imp

這張圖初看很複雜,不過它演示的是尋找符號的過程,咱們根據這張圖來分析一下這個過程:

  1. __DATA 段中的 lazy 符號指針表中查找某個符號,得到這個符號的偏移量 1061,而後在每個 section_64 中查找 reserved1,經過這兩個值找到 Indirect Symbol Table 中符號對應的條目

  2. 在 Indirect Symbol Table 找到符號表指針以及對應的索引 16343 以後,就須要訪問符號表

  3. 而後經過符號表中的偏移量,獲取字符串表中的符號 _close

fishhook 的實現

上面梳理了尋找符號的過程,如今,咱們終於要開始分析 fishhook 的源代碼,看它是如何一步一步替換原有函數實現的。

對實現的分析會 rebind_symbols 函數爲入口,首先看一下函數的調用棧:

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);
└── extern void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide));

static void _rebind_symbols_for_image(const struct mach_header *header, intptr_t slide)
└── static void rebind_symbols_for_image(struct rebindings_entry *rebindings, const struct mach_header *header, intptr_t slide)
    └── static void perform_rebinding_with_section(struct rebindings_entry *rebindings, section_t *section, intptr_t slide, nlist_t *symtab, char *strtab, uint32_t *indirect_symtab)

其實函數調用棧很是簡單,由於整個庫中也沒有幾個函數,rebind_symbols 做爲接口,其主要做用就是註冊一個函數並在鏡像加載時回調:

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
    int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
    if (retval < 0) return retval;

    if (!_rebindings_head->next) {
        _dyld_register_func_for_add_image(_rebind_symbols_for_image);
    } else {
        uint32_t c = _dyld_image_count();
        for (uint32_t i = 0; i < c; i++) {
            _rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
        }
    }
    return retval;
}

rebind_symbols 最開始執行時,會先調用一個 prepend_rebindings 的函數,將整個 rebindings 數組添加到 _rebindings_head 這個私有數據結構的頭部:

static int prepend_rebindings(struct rebindings_entry **rebindings_head,
                              struct rebinding rebindings[],
                              size_t nel) {
    struct rebindings_entry *new_entry = malloc(sizeof(struct rebindings_entry));
    if (!new_entry) {
        return -1;
    }
    new_entry->rebindings = malloc(sizeof(struct rebinding) * nel);
    if (!new_entry->rebindings) {
        free(new_entry);
        return -1;
    }
    memcpy(new_entry->rebindings, rebindings, sizeof(struct rebinding) * nel);
    new_entry->rebindings_nel = nel;
    new_entry->next = *rebindings_head;
    *rebindings_head = new_entry;
    return 0;
}

也就是說每次調用的 rebind_symbols 方法傳入的 rebindings 數組以及數組的長度都會以 rebindings_entry 的形式添加到 _rebindings_head 這個私有鏈表的首部:

struct rebindings_entry {
    struct rebinding *rebindings;
    size_t rebindings_nel;
    struct rebindings_entry *next;
};

static struct rebindings_entry *_rebindings_head;

這樣能夠經過判斷 _rebindings_head->next 的值來判斷是否爲第一次調用,而後使用 _dyld_register_func_for_add_image_rebind_symbols_for_image 註冊爲回調或者爲全部存在的鏡像單獨調用 _rebind_symbols_for_image

static void _rebind_symbols_for_image(const struct mach_header *header, intptr_t slide) {
    rebind_symbols_for_image(_rebindings_head, header, slide);
}

_rebind_symbols_for_image 只是對另外一個名字很是類似的函數 rebind_symbols_for_image 的封裝,從這個函數開始,就到了重綁定符號的過程;不過因爲這個方法的實現比較長,具體分析會分紅三個部分並省略一些不影響理解的代碼:

static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
                                     const struct mach_header *header,
                                     intptr_t slide) {
    segment_command_t *cur_seg_cmd;
    segment_command_t *linkedit_segment = NULL;
    struct symtab_command* symtab_cmd = NULL;
    struct dysymtab_command* dysymtab_cmd = NULL;

    uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
        cur_seg_cmd = (segment_command_t *)cur;
        if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
            if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
                linkedit_segment = cur_seg_cmd;
            }
        } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
            symtab_cmd = (struct symtab_command*)cur_seg_cmd;
        } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
            dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
        }
    }

    ...
}

這部分的代碼主要功能是從鏡像中查找 linkedit_segment symtab_commanddysymtab_command;在開始查找以前,要先跳過 mach_header_t 長度的位置,而後將當前指針強轉成 segment_command_t,經過對比 cmd 的值,來找到須要的 segment_command_t

在查找了幾個關鍵的 segment 以後,咱們能夠根據幾個 segment 獲取對應表的內存地址:

static void rebind_symbols_for_image(struct rebindings_entry *rebindings, const struct mach_header *header, intptr_t slide) {
    ...

    uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
    char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);

    uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
    
    ...
}

linkedit_segment 結構體中得到其虛擬地址以及文件偏移量,而後經過一下公式來計算當前 __LINKEDIT 段的位置:

slide + vmaffr - fileoff

相似地,在 symtab_command 中獲取符號表偏移量和字符串表偏移量,從 dysymtab_command 中獲取間接符號表(indirect symbol table)偏移量,就可以得到_符號表_、_字符串表_以及_間接符號表_的引用了。

  • 間接符號表中的元素都是 uint32_t *,指針的值是對應條目 n_list 在符號表中的位置

  • 符號表中的元素都是 nlist_t 結構體,其中包含了當前符號在字符串表中的下標

    struct nlist_64 {
        union {
            uint32_t  n_strx; /* index into the string table */
        } n_un;
        uint8_t n_type;        /* type flag, see below */
        uint8_t n_sect;        /* section number or NO_SECT */
        uint16_t n_desc;       /* see <mach-o/stab.h> */
        uint64_t n_value;      /* value of this symbol (or stab offset) */
    };
  • 字符串表中的元素是 char 字符

該函數的最後一部分就開啓了遍歷模式,查找整個鏡像中的 SECTION_TYPES_LAZY_SYMBOL_POINTERS 或者 S_NON_LAZY_SYMBOL_POINTERS 的 section,而後調用下一個函數 perform_rebinding_with_section 來對 section 中的符號進行處理:

static void perform_rebinding_with_section(struct rebindings_entry *rebindings, section_t *section, intptr_t slide, nlist_t *symtab, char *strtab, uint32_t *indirect_symtab) {
    uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
    void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
    for (uint i = 0; i < section->size / sizeof(void *); i++) {
        uint32_t symtab_index = indirect_symbol_indices[i];
        uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
        char *symbol_name = strtab + strtab_offset;

        struct rebindings_entry *cur = rebindings;
        while (cur) {
            for (uint j = 0; j < cur->rebindings_nel; j++) {
                if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
                    if (cur->rebindings[j].replaced != NULL &&
                        indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
                        *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
                    }
                    indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
                    goto symbol_loop;
                }
            }
            cur = cur->next;
        }
    symbol_loop:;
    }
}

該函數的實現的核心內容就是將符號表中的 symbol_namerebinding 中的名字 name 進行比較,若是出現了匹配,就會將原函數的實現傳入 origian_open 函數指針的地址,並使用新的函數實現 new_open 代替原實現:

if (cur->rebindings[j].replaced != NULL &&
    indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
    *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i]; // 將原函數的實現傳入 original_open 函數指針的地址
}
indirect_symbol_bindings[i] = cur->rebindings[j].replacement; // 使用新的函數實現 new_open 替換原實現

若是你理解了上面的實現代碼,該函數的其它代碼就很好理解了:

  1. 經過 indirect_symtab + section->reserved1 獲取 indirect_symbol_indices *,也就是符號表的數組

  2. 經過 (void **)((uintptr_t)slide + section->addr) 獲取函數指針列表 indirect_symbol_bindings

  3. 遍歷符號表數組 indirect_symbol_indices * 中的全部符號表中,獲取其中的符號表索引 symtab_index

  4. 經過符號表索引 symtab_index 獲取符號表中某一個 n_list 結構體,獲得字符串表中的索引 symtab[symtab_index].n_un.n_strx

  5. 最後在字符串表中得到符號的名字 char *symbol_name

到這裏比較前的準備工做就完成了,剩下的代碼會遍歷整個 rebindings_entry 數組,在其中查找匹配的符號,完成函數實現的替換:

while (cur) {
    for (uint j = 0; j < cur->rebindings_nel; j++) {
        if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
            if (cur->rebindings[j].replaced != NULL &&
                indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
                *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
            }
            indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
            goto symbol_loop;
        }
    }
    cur = cur->next;
}

在以後對某一函數的調用(例如 open),當查找其函數實現時,都會查找到 new_open 的函數指針;在 new_open 調用 origianl_open 時,一樣也會執行原有的函數實現,由於咱們經過 *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i] 將原函數實現綁定到了新的函數指針上。

實驗

fishhook 在 dyld 加載鏡像時,插入了一個回調函數,交換了原有函數的實現;可是 fishhook 可否修改非動態連接庫,好比開發人員本身手寫的函數呢?咱們能夠作一個很是簡單的小實驗,下面是咱們的 main.m 文件:

#import <Foundation/Foundation.h>
#import "fishhook.h"

void hello() {
    printf("hello\n");
}

static void (*original_hello)();

void new_hello() {
    printf("New_hello\n");
    original_hello();
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        struct rebinding open_rebinding = { "hello", new_hello, (void *)&original_hello };
        rebind_symbols((struct rebinding[1]){open_rebinding}, 1);
        hello();
    }
    return 0;
}

這裏的函數實現很是的簡單,相信也不須要筆者過多解釋了,咱們直接運行這份代碼:

fishhook-hello

代碼中只打印了 hello,說明 fishhook 對這種手寫的函數是沒有做用的,若是在下面這裏打一個斷點:

fishhook-hello-breakpoint

代碼並不會進這裏,由於 hello 這個函數並不在任何的鏡像中存在,這也符合在最開始咱們研究 dyld 時得出的結論:

dyld 只負責將一些須要動態連接的庫加載進來,好比說 C 語言標準庫,或者 Foundation 這些 ObjC 提供的庫;而咱們本身在程序中寫的代碼是不會經過 dyld 進行加載的,也就沒法修改其實現。

小結

fishhook 的實現很是的巧妙,可是它的使用也有必定的侷限性,在接觸到 fishhook 以前,從沒有想到過能夠經過一種方式修改 C 函數的實現,在筆者的印象中,C 語言做爲靜態語言。

Reference

其它

main.m

#import <Foundation/Foundation.h>

#import "fishhook.h"

static int (*original_open)(const char *, int, ...);

int new_open(const char *path, int oflag, ...) {
    va_list ap = {0};
    mode_t mode = 0;

    if ((oflag & O_CREAT) != 0) {
        // mode only applies to O_CREAT
        va_start(ap, oflag);
        mode = va_arg(ap, int);
        va_end(ap);
        printf("Calling real open('%s', %d, %d)\n", path, oflag, mode);
        return original_open(path, oflag, mode);
    } else {
        printf("Calling real open('%s', %d)\n", path, oflag);
        return original_open(path, oflag, mode);
    }
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        struct rebinding open_rebinding = { "open", new_open, (void *)&original_open };
        rebind_symbols((struct rebinding[1]){open_rebinding}, 1);
        __unused int fd = open(argv[0], O_RDONLY);
    }
    return 0;
}

關注倉庫,及時得到更新:iOS-Source-Code-Analyze

Follow: Draveness · Github

原文連接: http://draveness.me/fishhook/

相關文章
相關標籤/搜索