这篇文章上次修改于 575 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

前言

无论是还原so的某个算法还是要分析so的逻辑,相信大家都会遇到这些场景:

  • 处处都在取偏移,大概率来说,这些都是结构体
  • 再跳转几个函数可能会出现取偏移处的指针,再取偏移,这很可能是结构体嵌套
  • 这里变量取值偏移是8,其他地方有个函数中间变量偏移取值也是8,它们是同一个类型的吗

这就是so中无处不在的结构体,为了能更好地理解反汇编出来的函数,将结构体还原为可读性更高的形式是非常有必要的

可读性提高之后,就可以明确知道:「原来这里是在向xx的yy属性赋值」,而不是:「这里向xx偏移yy处赋值」

更重要的是,引入结构体之后,有了类型可以更容易理解其中的转换逻辑

针对Android平台,本文将以某so为例,结合该so使用到的某个开源三方库源码,使用一种「通用的方案」对结构体进行还原

既然是通用的方案自然也能套用在无源码的情况下,这一点将在文末说明

环境

后文提到的源码均指so所使用的第三方库libevent的源码

你可以选择其他so参考本文实操练习

教程

Q: 有源码的情况下直接导入源码头文件不就行了?
A: 源码简单的情况下没问题,但对于源码复杂的情况,通常存在这些问题:

- 结构体往往多层嵌套
- 结构体定义不一定在头文件
- 跨平台库支持存在诸多宏定义,IDA并不能处理好
- 不能确定so使用的版本
- 不能确定是否进行了**自研优化**改动,如果有那么直接导入结构体可能理解成相反的含义

套路小结

首先要知道的是:IDA中,32位的指针类型是int*,64位的指针类型是__int64*

个人感觉__int64看着不太顺眼,后面将使用int64_t来代替,IDA中没法直接找到这个类型,但是如果使用了这个写法IDA会自动关联

  • 对于常见的数据类型,源码中是什么类型,IDA导入结构体定义时就是什么类型
  • 对于结构体中的struct xxx yyy;

    • 如果yyy是指针(只要带有*,无论数量),比如是*one_struct,对于64位IDA导入结构体定义时写成int64_t *one_struct

      • 关于这一点,其实写成struct ABC *one_struct也可以,IDA可以正常导入,这一点在后续说明,前面还请按照文章来
    • 否则一律写成_BYTE one_struct[200]200这个数请根据实际情况写一个比较大的数即可

还原步骤:

  • 结合so前后信息和源码确定某个参数是源码中的某个类型的结构体
  • 在知道偏移i处是结构体YMM属性MM属性的类型是*AAA,那么在IDA中定义一个这样的结构体

    struct y {
        _BYTE padding1[i];
        int64_t *MM;
        _BYTE padding[200]
    }
  • 假定在后续分析过程中,发现偏移j处(偏移j偏移i之后)是结构体yNN属性NN属性的类型是*BBB,那么将上面的结构体修改如下

    struct Y {
        _BYTE padding1[i];
        int64_t *MM;
        _BYTE padding2[j - i];
        int64_t *NN;
        _BYTE padding[200];
    }
  • 假定NN属性的前面是一个int类型QQ属性,那么我们可以将结构体继续修正如下

    struct Y {
        _BYTE padding1[i];
        int64_t *MM;
        _BYTE padding2[j - i - sizeof(int)];
        int QQ;
        int64_t *NN;
        _BYTE padding[200];
    }
  • 不断重复上面的过程,直到得到全部或者部分的结构体定义
  • 对于结构体AAA结构体BBB也采用相同的方法去定义,在自己认为还原得合适的时候再将结构体Y中对应偏移位置的类型替换即可

    • 即将结构体Y中的int64_t *MM;修改为struct AAA *MM;

上面的说法看起来可能有些不好理解,请看下面实战演示,可以帮助你理解这个过程

实战演示

先用IDA反汇编sample.so定位到sub_121E14这个函数,并且打开libevent源码

通过图中这两个字符串可以定位出来是evdns_nameserver_add_impl_这个函数

  • Addrlen %d too long.
  • Couldn't bind to outgoing address

结合evdns_nameserver_add_impl_源码,先对参数和部分函数进行重命名

结合源码可以知道这个函数主要涉及两个结构体,evdns_basenameserver

  • evdns_base结构体定义

  • nameserver结构体定义

相比之下nameserver更简单一点,先尝试对nameserver结构体进行还原

通过对比源码,可以找到一部分nameserver属性所对应的偏移

nameserver结构体定义如下:

struct nameserver {
    evutil_socket_t socket;
    struct tcp_connection *connection;
    struct sockaddr_storage address;
    ev_socklen_t addrlen;
    int failed_times;
    int timedout;
    struct event event;
    struct nameserver *next, *prev;
    struct event timeout_event;
    struct evdns_request *probe_request;
    char state;
    char choked;
    char write_waiting;
    struct evdns_base *base;
    int requests_inflight;
};

检索源码,可以知道evutil_socket_t类型其实是int

#ifdef _WIN32
#define evutil_socket_t intptr_t
#else
#define evutil_socket_t int
#endif

ev_socklen_t的类型是socklen_t

#ifdef _WIN32
#define ev_socklen_t int
#elif defined(EVENT__socklen_t)
#define ev_socklen_t EVENT__socklen_t
#else
#define ev_socklen_t socklen_t
#endif

而根据AOSP的源码,可以知道__socklen_t64位上实际类型是uint32_t

但是IDA的类型是int32_t,我们最好修改为uint32_t,正确的类型是非常有必要的

因为对于复杂的结构体有时候会影响到偏移计算

#if !defined(__LP64__)
/* This historical accident means that we had a signed socklen_t on 32-bit architectures. */
typedef int32_t __socklen_t;
#else
/* LP64 still has a 32-bit socklen_t. */
typedef uint32_t __socklen_t;
#endif
typedef __socklen_t socklen_t;

按照前面的套路,先写出下面的结构体,和原结构体相比变化如下:

  • evutil_socket_t 替换为 int

    • 因为我们确定其类型就是int
  • struct tcp_connection *connection 替换为 int64_t *connection

    • 因为我们还不知道tcp_connection结构体啥样,暂时指明这是一个指针即可
  • struct sockaddr_storage address 替换为 _BYTE address[200]

    • 因为这是一个嵌套结构体,我们还不知道在so中实际上多大,所以先定一个200字节大小
  • ev_socklen_t 替换为 socklen_t

    • 因为前面在IDA中修改了__socklen_t类型为uint32_t,这里使用更符合场景的socklen_t进行替换
struct nameserver {
    int socket;
    int64_t *connection;
    _BYTE address[200];
    socklen_t addrlen;
};

打开Local Types

  • IDA菜单 -> View -> Open subviews -> Local Types

右键Insert,然后粘贴上面的结构体,选择OK保存

这个时候选中nameserver右键点击Edit,可以看到现在编辑框里面IDA给你计算好了结构体各个属性的偏移

现在我们回到反汇编窗口,选择之前改好的伪代码中的_ns,右键选择Convert to struct *...

然后在弹出的窗口中选择刚才定义的nameserver结构体,选中后点击OK或者双击即可

前面我们已经对比过源码了,现在找到ns->addrlen这个地方,查看真实的偏移情况

可以看到在当前设置的结构体下,addrlen应该在address偏移128处,而address在结构体中的偏移是0x10,那么addrlen在结构体中实际偏移是0x10 + 128 => 0x90

查看汇编窗口的代码,可以确定addrlen在原始结构体中偏移就是0x90

Q: 这里为什么要这样操作一番呢,最开始的反汇编代码不就有这些值吗?
A: 因为要动态修改结构体定义,总不能把最开始的反汇编结果记下来或者截图吧,修改数量太多的时候就必须要有一种可靠的方案去确定相对结构体的正确偏移是多少。所以这里提供了两种方法来做:

- 一种是查看信息计算偏移
- 一种是根据汇编中的内容来确定,不过这个时候你需要确定哪个寄存器是结构体,这样才能找对偏移

不过还有一种更简单的方案,就是把结构体的类型改回去,即直接右键选择Reset pointer type。方法多多益善,前面就当可选项了~~

这个时候我们去修改nameserver结构体定义,调整address的大小,让addrlen可以刚好处于偏移0x90的位置

在修改的时候,IDA会实时计算每个属性的起始偏移,结果显示在左侧,同时也会有每个属性的大小

所以我们不需要去手动计算0x90 - 0x10(addrlen的正确偏移 - address的正确偏移)应该等于多少,只需要稍微做加减,像调整游标卡尺那样慢慢调整就行(不用费脑子)

然后再去反汇编窗口F5一下就好了

简单小结一下就是:

  • 只要知道了某个偏移处是什么属性,且它前面还有不确定的属性,先用最靠前的一个确定属性,类型设置为_BYTE,然后通过设置长度占坑,让这个属性处于正确偏移即可
  • 如果它前面就是一个已知的属性,那就直接接着写即可,比如对于failed_times来说,它前面是addrlen,现在这个值的偏移确定下来了,那么我们直接接着写定义就行了
    evutil_socket_t socket;
    struct tcp_connection *connection;
    struct sockaddr_storage address;
    ev_socklen_t addrlen;
    int failed_times;
    int timedout;
    struct event event;

那么现在再次修改结构体如下,然后刷新反汇编窗口

struct nameserver {
    int socket;
    int64_t *connection;
    _BYTE address[128];
    socklen_t addrlen;
    int failed_times;
    int timedout;
    _BYTE event[200];
};

我们继续对比源码和反汇编代码,可以发现432ns->base424ns->state

先看看nameserver结构体的部分定义,按照最开始的套路做出修改

    struct event event;
    struct nameserver *next, *prev;
    struct event timeout_event;
    struct evdns_request *probe_request;
    char state;
    char choked;
    char write_waiting;
    struct evdns_base *base;
    int requests_inflight;

对于常见类型,比如intchar就不用修改了,这里改了这几个点:

  • struct evdns_request *probe_request; => int64_t *probe_request;
  • struct evdns_base *base; => int64_t *base;

其实没什么新鲜的,就是套路中的第二条规则,把还不知道具体结构体定义的改成int64_t *

event*probe_request之间到底间隔多少先不管,填个数占坑即可

struct nameserver {
    int socket;
    int64_t *connection;
    _BYTE address[128];
    socklen_t addrlen;
    int failed_times;
    int timedout;
    _BYTE event[200];
    int64_t *probe_request;
    char state;
    char choked;
    char write_waiting;
    int64_t *base;
    int requests_inflight;
};

已经知道state的偏移是424 => 0x1A8,简单调整event占坑长度后得到下面的结构体定义

struct nameserver {
    int socket;
    int64_t *connection;
    _BYTE address[128];
    socklen_t addrlen;
    int failed_times;
    int timedout;
    _BYTE event[254];
    int64_t *probe_request;
    char state;
    char choked;
    char write_waiting;
    int64_t *base;
    int requests_inflight;
};

现在得到的反汇编代码可读性更好了

已知event的偏移是160 => 0xA0,不过这个时候我们发现实际上在IDA中定义的结构体的event偏移是0x9C

那么是不是什么地方错误了呢,我们看向event前一个属性timedout,IDA中给出的偏移是0x98 => 152

简单检索源码,可以发现在nameserver_read中有对timedout进行赋值,通过字符串特征可以定位到是sub_1273C4

将对应的参数转换为nameserver结构体

可以看到直接出现了timedout,说明timedout的偏移肯定没问题,偏移是152 => 0x98

libevent源码中nameserver结构体定义,timedoutevent之间并没有其他的属性了

这里只能暂时猜测是可能存在一个0xA0 - 0x9C = 4字节大小的属性

添加_BYTE unknow_field_padding[4];以及补充剩下的属性,然后调整event占坑长度后得到下面的结构体定义

确定event长度的时候,还是和之前一样,找个已知偏移的属性做比较,比如state的偏移是0x1A8

struct nameserver {
    int socket;
    int64_t *connection;
    _BYTE address[128];
    socklen_t addrlen;
    int failed_times;
    int timedout;
    _BYTE unknow_field_padding[4];
    _BYTE event[120];
    struct nameserver *next;
    struct nameserver *prev;
    _BYTE timeout_event[120];
    int64_t *probe_request;
    char state;
    char choked;
    char write_waiting;
    int64_t *base;
    int requests_inflight;
};

另外补充一点,在evdns_nameserver_add_impl_函数中,ns是会经历初始化的,所以可以看到这个结构体的实际大小,这里是448 => 0x1C0

根据这个还可以验证我们的结构体大小是否正确,如果发现不匹配那也能帮助我们检查出其中的错误

不过对于一些巨复杂的结构体可能没那么容易一下就找到,这只是辅助我们检查的手段之一

现在反汇编代码阅读起来是不是更加清晰了呢

简单小结一下就是:

  • 不知道的地方先占坑,根据对比分析逐步确定一些属性的偏移,一步步完善
  • 如果有偏移对不上的地方,另外设置一个属性占坑即可

在这个不断修正的过程也能发现一些改动,比如这里这个_BYTE unknow_field_padding[4];,就是源码中不存在的属性

看到这里,想必你已经知道怎么应对相对复杂的结构体了

不过这里我还想延伸一点,在源码中*base完整定义是struct evdns_base *base;,以evdns_base再做一点分析

这个结构体中有下面这种定义,一是TAILQ_HEAD这种像是函数运算的,一是根据宏定义决定有没有lock这个属性

前者可以找到TAILQ_HEAD的定义搞清楚是怎么回事,后者则按前面的思路进行分析便可确定有没有这个属性了

    TAILQ_HEAD(hosts_list, hosts_entry) hostsdb;
#ifndef EVENT__DISABLE_THREAD_SUPPORT
    void *lock;
#endif

其中TAILQ_HEAD定义如下

#ifndef TAILQ_HEAD
#define EVENT_DEFINED_TQHEAD_
#define TAILQ_HEAD(name, type) \
struct name {                  \
    struct type *tqh_first;    \
    struct type **tqh_last;    \
}
#endif

我简化为下面这样,在IDA中添加定义,但是添加之后就找不到了QAQ

#define TAILQ_HEAD(name, type) struct name {struct type *tqh_first;struct type **tqh_last;}

不过结合evdns_base中的TAILQ_HEAD(hosts_list, hosts_entry) hostsdb;这个定义,可以知道其实是等效于下面这个定义

struct hosts_list {
    struct hosts_entry *tqh_first;
    struct hosts_entry **tqh_last;
}

也就是说上面的TAILQ_HEAD相当于是一个模板,另外这个hosts_list结构体IDA可以直接添加,因为两个属性都是指针

如果你尝试设置一个非指针类型的,那么就无法添加,因为现在并没有真的存在hosts_entry

需要我们先手动添加定义才能添加这种非指针类型的struct xxx定义

下面是evdns_base结构体的完整定义

struct evdns_base {
    struct request **req_heads;
    struct request *req_waiting_head;
    struct nameserver *server_head;
    int n_req_heads;
    struct event_base *event_base;
    int global_good_nameservers;
    int global_requests_inflight;
    int global_requests_waiting;
    int global_max_requests_inflight;
    struct timeval global_timeout;
    int global_max_reissues;
    int global_max_retransmits;
    int global_max_nameserver_timeout;
    int global_randomize_case;
    u16 global_max_udp_size;
    struct timeval global_nameserver_probe_initial_timeout;
    u16 global_tcp_flags;
    struct timeval global_tcp_idle_timeout;
    struct sockaddr_storage global_outgoing_address;
    ev_socklen_t global_outgoing_addrlen;
    struct timeval global_getaddrinfo_allow_skew;
    int so_rcvbuf;
    int so_sndbuf;
    int getaddrinfo_ipv4_timeouts;
    int getaddrinfo_ipv6_timeouts;
    int getaddrinfo_ipv4_answered;
    int getaddrinfo_ipv6_answered;
    struct search_state *global_search_state;
    TAILQ_HEAD(hosts_list, hosts_entry) hostsdb;
#ifndef EVENT__DISABLE_THREAD_SUPPORT
    void *lock;
#endif
    int disable_when_inactive;
    int ns_max_probe_timeout;
    int ns_timeout_backoff_factor;
};

我们做出修改,结果如下:

struct evdns_base {
    struct request **req_heads;
    struct request *req_waiting_head;
    struct nameserver *server_head;
    int n_req_heads;
    struct event_base *event_base;
    int global_good_nameservers;
    int global_requests_inflight;
    int global_requests_waiting;
    int global_max_requests_inflight;
    struct timeval global_timeout;
    int global_max_reissues;
    int global_max_retransmits;
    int global_max_nameserver_timeout;
    int global_randomize_case;
    uint16_t global_max_udp_size;
    struct timeval global_nameserver_probe_initial_timeout;
    uint16_t global_tcp_flags;
    struct timeval global_tcp_idle_timeout;
    _BYTE global_outgoing_address[128];
    socklen_t global_outgoing_addrlen;
    struct timeval global_getaddrinfo_allow_skew;
    int so_rcvbuf;
    int so_sndbuf;
    int getaddrinfo_ipv4_timeouts;
    int getaddrinfo_ipv6_timeouts;
    int getaddrinfo_ipv4_answered;
    int getaddrinfo_ipv6_answered;
    struct search_state *global_search_state;
    struct hosts_list hostsdb;
    void *lock;
    int disable_when_inactive;
    int ns_max_probe_timeout;
    int ns_timeout_backoff_factor;
};

既然我们知道了,对于定义的是指针类型的struct xxx,没有实际定义xxx也是可以的,那么这一类的定义就不需要使用改为int64_t *的方式替换

上面的struct xxx中:

  • nameserver 已经定义了
  • event_base 注意这不是结构体本身,结构体是evdns_base不要看走眼了
  • timeval IDA已经定义了
  • sockaddr_storagenameserver它的定义是_BYTE address[128];,沿用即可
  • hosts_list 刚才也已经定义了

运气真不错,没有出现未知的嵌套struct xxx,其他修改:

  • u16 替换为 uint16_t
  • ev_socklen_t 替换为 socklen_t
  • lock 先假定存在

于是这次看起来可以直接完整导入IDA,那么会不会这么顺利呢?

回到evdns_nameserver_add_impl_,也就是sub_121E14这个函数

根据源码我们知道参数一就是evdns_base,将参数一转为evdns_base类型结构体

看起来是不是很顺眼,好像非常完美,让我们往下翻一点,可以看到其中引用的一些属性和字符串的信息不匹配呀!

再看一眼刚才的结构体定义,似乎挑不出什么毛病,那么问题出在哪儿呢?

难道是不应该存在lock

不,显然不是!

因为图中指出的这些属性不管是getaddrinfo_ipv6_answered还是hostsdb它们都在lock之前定义

也就是说就算没有lock,也不会对当前反汇编这部分有什么影响

让我们的目光向lock之前的属性定义看,发现好几处都是timeval结构体

难道IDA还能把timeval定义搞错?

把IDA的结构体定义和关联的定义提取出来看一下,显然按IDA中的定义,timeval大小应该是8

typedef int __kernel_long_t;
typedef __kernel_long_t __kernel_time_t;
typedef __kernel_time_t __time_t;

typedef __kernel_long_t __kernel_suseconds_t;
typedef __kernel_suseconds_t __suseconds_t;

struct timeval {
    __time_t tv_sec;
    __suseconds_t tv_usec;
};

把AOSP中的定义也提取出来看一下,显然timeval大小应该是0x10

#ifndef __kernel_long_t
typedef long __kernel_long_t;
#endif

typedef __kernel_long_t __kernel_old_time_t;

#ifndef __kernel_suseconds_t
typedef __kernel_long_t __kernel_suseconds_t;
#endif

struct timeval {
  __kernel_old_time_t tv_sec;
  __kernel_suseconds_t tv_usec;
};

很明显两者的差异在于__kernel_long_t,那么我们应该手动修改__kernel_long_t的定义为long来尝试修正目前遇到的问题

修改后刷新下反汇编代码,这次的结果显然说明确实是应该这样修正

现在还有两个问题,一是hostsdb那样设置的类型是正确的吗,一是lock存在还是不存在

对于第二个问题,我们对evdns_nameserver_add_impl_交叉引用,点第一个引用的位置,反汇编结果如下

图中已经是转换过类型的了,可以看到出现了lock

根据图中的字符串结合源码可以定位到是evdns_base_nameserver_ip_add这个函数

可以看到源码中evdns_nameserver_add_impl_附近的代码是这样的

EVDNS_LOCK(base);
res = evdns_nameserver_add_impl_(base, sa, len);
EVDNS_UNLOCK(base);

EVDNS_LOCKEVDNS_UNLOCK的定义如下,和反汇编得到的结果一致,那么说明lock这个属性确实存在

#ifdef EVENT__DISABLE_THREAD_SUPPORT
#define EVDNS_LOCK(base)  EVUTIL_NIL_STMT_
#define EVDNS_UNLOCK(base) EVUTIL_NIL_STMT_
#define ASSERT_LOCKED(base) EVUTIL_NIL_STMT_
#else
#define EVDNS_LOCK(base)            \
    EVLOCK_LOCK((base)->lock, 0)
#define EVDNS_UNLOCK(base)            \
    EVLOCK_UNLOCK((base)->lock, 0)
#define ASSERT_LOCKED(base)            \
    EVLOCK_ASSERT_LOCKED((base)->lock)
#endif

在源码中检索hostsdb,发现在evdns_base_new中有使用到,根据字符串定位到是sub_122124

修改对应的变量类型,可以看到基本和源码的相差无几

源码中可以找到TAILQ_INIT定义如下,IDA的结果确实一致,说明hostsdb部分也是没问题的

#define TAILQ_INIT(head) do {                 \
    (head)->tqh_first = NULL;                 \
    (head)->tqh_last = &(head)->tqh_first;    \
} while (0)

另外可以发现这里在初始化evdns_base结构体,大小是360 => 0x168

再看一眼我们在IDA中定义的结构体大小,也是0x168,至此可以确定evdns_base结构体定义没有问题

还记得前面提到的nameserver_read吗,在这个函数里面还存在一些看起来没完成的偏移确定

现在我们将原先nameserver结构体定义设置的int64_t *base;修改为struct evdns_base *base;

再刷新下nameserver_read的反汇编代码,可以看到和源码中也是能对应正确的

简单小结一下就是:

  • 结构体定义有宏的,找到宏定义代码,带入替换
  • 对于不确定是否存在的属性,可以先假定再根据已有信息验证
  • 定义完善但是偏移对不上,要检查IDA内置定义是否正确
  • 如果结构体内struct xxx定义的是指针,那么IDA也能导入识别,不需要改为int64_t *进行替换

总结

总之呢,要还原结构体无非是做以下几个工作:

  • 如果结构体嵌套,改成_BYTE类型数组占坑,然后根据其他确定的属性进行修正
  • 如果struct xxx定义的是指针,不需要改为int64_t *进行替换,即无需修改
  • 如果定义完善但是偏移对不上,差异较大,要检查IDA有关内置定义是否正确
  • 综合分析,确定偏移处的属性,不断调整那些占坑的结构体,即那些_BYTE数组

Q: 对于那些开发者自己编译的,无源码的部分,怎么分析呢?
A: 如果so有用到开源库,我们首先在开源库基础上进行做一定程度的还原,再查看交叉引用,对部分结构体的部分属性进行还原;而对于完全没有源码的部分,套路还是类似的,只不过从原来和源码对比确定某处偏移是某某属性,变成了结合so前后信息、hook结果等操作推断某处偏移应当是某某属性。

我们在实际分析中往往不需要特别完整的结构体信息,只需要知道是谁在嵌套谁,哪个地方的哪个偏移应当是什么属性就行了

对于so中信息很少的情况,即使不能知道某处偏移的属性应该叫什么,但只要取个名字,在其他函数中能够分辨出来即可

能分辨出来了,也就不会出现「这里变量取值偏移是8,其他地方有个函数中间变量偏移取值也是8,它们是同一个类型的吗」这样的疑惑了