!!!注意,这篇文章中提到的性能优化方式基本仅适用于指针链扫描,不适用其它业务。

!!!这篇文章并不是接着上一篇文章写的,但有很多关联,不过很多代码,逻辑,已经发生变化。

反转数据储存方式

上一篇文章中,我们使用如下结构储存所有指针

1
BTreeMap<usize, usize>

其中 key=地址,value=地址所储存的引用地址

这导致了一些问题,首先整个 Map 会变得非常大,性能也会受到影响,并且由于其底层储存方式,占用的内存非常多。

但很容易解决这个问题,只需要 反转 它,使用如下方式储存

1
BTreeMap<usize, Vec<usize>>

key=地址所储存的引用地址,value=对应地址列表

我们从一对一的关系变成了一对多,这极大缩小了整个 Map

Rust 有一个非常方便的API可以帮我们处理这个

1
{BTreeMap<usize, Vec<usize>>}.entry(value).or_default().push(key)

做完这些,现实用例中,大多数情况,整体性能会提升高达 1000% 以上。

之所以会有如此大的提升是因为我们扫描的是指针链,必然会有多个地址储存了同一个引用。这为后续的扫描减少了无数次遍历 Map 的开销。

查找基址模块

计算出指针链的初始地址后,我们需要确定它位于哪个模块上,一般这被称为基址。

从前,我们遍历所有内存范围来确定基址,这很缓慢。

实际上我们可以使用 区间树/线段树 一类的数据结构来快速找到它。

不过多数现有的这类实现并不是很适合我们的需求,它会合并相关范围,例如储存 3..5 和 1..10,它会合并为单个 1..10

我们并不需要这种行为,这种多余的操作会影响一些性能,因为我们知道,内存区域中永远不会存在两个相交的范围。

我们可以自己实现一个

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#[derive(Default)]
struct RangeMap<K, V>(BTreeMap<RangeWrapper<K>, V>);

impl<K, V> RangeMap<K, V> {
    fn iter(&self) -> impl Iterator<Item = (&Range<K>, &V)> {
        self.0.iter().map(|(k, v)| (&k.0, v))
    }

    fn clear(&mut self) {
        self.0.clear()
    }
}

impl<K, V> RangeMap<K, V>
where
    K: Ord + Copy,
{
    fn get_key_value(&self, point: K) -> Option<(&Range<K>, &V)> {
        let start = RangeWrapper(point..point);
        self.0
            .range((Bound::Unbounded, Bound::Included(start)))
            .next_back()
            .filter(|(range, _)| range.0.contains(&point))
            .map(|(range, value)| (&range.0, value))
    }

    fn insert(&mut self, key: Range<K>, value: V) -> Option<V> {
        assert!(key.start <= key.end);
        self.0.insert(RangeWrapper(key), value)
    }
}

其中 key=模块地址范围,value=模块信息

做完这些,现实用例中,大多数情况,整体性能会提升 10% (提升并不多,因为计算基址的时候才会执行这个,并不频繁)

缓慢的二分查找

从前我们使用二分查找来确定它是否已经接近基址模块,然后决定跳过一些扫描。

但是通常由于计算机cpu缓存以及分支预测的存在,对于少量数据,它可能不如线性搜索快速,我保守预计现代计算机中通常阀值在 64 左右,在实际 bench 中,它可能更高,不过对我们来说64已经足够。

分支预测器负责根据历史数据预测结果。分支被错误预测时CPU需要完成其所有工作并重组,这个过程很耗时。

我们实际需要查找的只是 BTreeMap<usize, Vec<usize>>Vec<usize>> 的数据

由于整个Map是由 BTreeMap<usize, usize> 转换而来,可以保证 Vec<usize>> 是有序的

从前的实现

1
let idx = points.binary_search(&min).unwrap_or_else(|x| x);

添加线性搜索

1
let idx = points.iter().position(|x| min.le(x)).unwrap_or(points.len());

现在可以简单检测数据的密集程度,如果其中绝大部分value的长度小于64,则假设它是稀疏的,使用线性查找,否则假设它是密集的,使用二分查找。

1
2
3
4
5
let count = reverse.values().filter(|v| v.len() < 64).count();
let mode = match (reverse.len() - count).checked_mul(64) {
    Some(n) if n < count => ScanMode::Sparse,
    _ => ScanMode::Dense,
};

现实用例中,大部分时候它都应该是稀疏的,应该不会有什么程序中非常很多地址储存了同一个地址。

做完这些,现实用例中,大多数情况,相比纯粹使用二分查找,整体性能会提升 50% 左右。

巨大的偏移

影响指针扫描性能的最主要原因还是 深度/偏移 设定的过高,导致结果过多。

在一些特殊程序中,例如 IOS版植物大战僵尸的阳光指针链,它很短,大概只需要3级深度就够,但是,它的最后一级有一个高达接近三万的偏移。

事实上这类特殊例子并不多,目前只发现 战神 和 IOS版植物大战僵尸 会这样,(战神有一个最后一级高达十万的偏移)。

而除了最后一级,其它偏移都只有几百。

如果使用 深度3,偏移0-30000,这会导致输出非常多指针链,同时速度可能会很慢,因为它为每个深度都执行了大范围扫描。

实际上可以单独为最后一级偏移设定大范围扫描 (PS: 对于中间大的情况根本无需在意,可以当做不存在,只需要单独设定最后一级就够了)

1
2
3
4
5
let (min, max) = if curr != CURR {
    (addr.saturating_sub(srange.1), addr.saturating_add(srange.0))
} else {
    (addr.saturating_sub(lrange.1), addr.saturating_add(lrange.0))
};

这样可以使用参数 深度3,偏移0:2000, 尾部偏移 0:30000 执行扫描。这大大减少了指针链计算的次数,几乎一瞬间就可以扫描完成。这类特殊用例的性能会提升一个数量级。

垃圾区域

通常情况下,我们将全部区域都允许作为基址,然而实际上我们根本不需要关心这么多。

系统库,加载的媒体资源(视频,字体)等等,即便是模糊扫描也几乎根本不可能把它们作为基址。

我们可以在dump阶段就排除它

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#[inline]
fn check_region<Q: VirtualQuery + vmmap::macos::VirtualQueryExt>(page: &Q) -> bool {
    use std::fs::File;

    if !page.is_read() || page.is_reserve() {
        return false;
    }

    let Some(name) = page.name() else {
        return matches!(page.tag(), |1..=9| 11 | 30 | 33 | 60 | 61);
    };
    let path = Path::new(name);
    if path.starts_with("/usr") {
        return false;
    }
    let mut buf = [0; 8];
    File::open(path)
        .and_then(|mut f| f.read_exact(&mut buf))
        .is_ok_and(|_| match buf[0..4] {
            [width, 0xfa, 0xed, 0xfe] if width == 0xcf || width == 0xce => true,
            [0xfe, 0xed, 0xfa, width] if width == 0xcf || width == 0xce => true,
            [0xca, 0xfe, 0xba, 0xbe] => u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]) < 45,
            _ => false,
        })
}

在扫描时,我们也几乎不可能把区域名字为空的模块作为地址,我们只需要有名字的。

使用 BTreeSet<usize> 储存所有相关地址。

然后扫描的时候我们可以通过指定一个模块来决定排除哪些垃圾。

1
2
3
4
5
6
let points = &self
            .index
            .iter()
            .flat_map(|(Range { start, end }, _)| forward.range((Bound::Included(start), Bound::Included(end))))
            .copied()
            .collect::<Vec<_>>();

这部分的性能提升无法估计,取决于实际排除了多少垃圾区域,如果一个都没有,它会拖慢速度,但微乎其微。而一旦正确排除了垃圾区域,速度会得到非常大大提升。

指针链过滤

我们扫描到的指针链并不总是有效的,它可能只是一个 碰巧存在的幽灵指针链,进程重启后它可能就失效了,所以我们需要过滤出真正的指针链。

从前,对于例如长度为 7 的每一条指针链,它需要执行7个系统调用才能获取它的数据,以验证是否有效。

然而系统调用非常昂贵,对于大量数据它可能耗时非常久。

如果数量超过某个阀值,我们可以创建一个内存快照,以加速它的访问。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
pub struct Snap {
    pub map: RangeMap<usize, Range<usize>>,
    pub mmap: MmapMut,
}

impl Snap {
    pub fn create_snapshot<'a, P, I, V>(proc: &P, iter: I) -> Result<Self>
    where
        P: VirtualMemoryRead,
        V: VirtualQuery + 'a,
        I: Iterator<Item = &'a V>,
    {
        let file = tempfile::Builder::new().append(true).tempfile_in(".")?.into_file();
        let mut bufwriter = BufWriter::with_capacity(0x200000, &file);

        let mut buf = vec![0; BUF_SIZE];
        let mut map = RangeMap::new();

        let mut e_seek = 0;
        for vq in iter {
            let mut i_seek = 0;
            let (start, size) = (vq.start(), vq.size());
            for off in (0..size).step_by(BUF_SIZE) {
                let size = proc.read_at(&mut buf, start + off)?;
                bufwriter.write_all(&buf[..size])?;
                i_seek += size;
            }

            let (real_start, real_end) = (vq.start(), vq.end());
            let (virt_start, virt_end) = (e_seek, e_seek + i_seek);

            map.insert(real_start..real_end, virt_start..virt_end);

            e_seek += i_seek;
        }

        let mmap = unsafe { MmapMut::map_mut(&file)? };

        Ok(Self { map, mmap })
    }

    #[inline]
    pub fn read_exact_at(&self, buf: &mut [u8], offset: usize) -> Option<()> {
        let (k, v) = self.map.get_key_value(offset)?;
        let offset = v.start + (offset - k.start);
        let last = offset + buf.len();
        if last > v.end {
            None
        } else {
            let data = self.mmap.get(offset..last)?;
            buf.copy_from_slice(data);
            Some(())
        }
    }
}

我们基于 mmap 来实现这个,它可以极大减少内存占用。

Snap.map 中 key=真实地址范围, value=虚拟地址范围(相对于文件中的地址)

使用 read_exact_at 方法将真实地址转换为虚拟地址并读取它的内容。

然后我们可以并发调用它,这对于需要验证大量指针链,性能又提升了非常多,不过这并不是最好的实现方式,极小概率可能负提升(当地址转换的开销大于系统调用时)

总结

整个指针扫描流程有点类似于病毒溯源,我们先找到感染人群,然后将人群归类,找到他们分别去了哪里,最后确定病毒源头。

综合来说,相比以前的暴力扫描,我们在优化数据结构并改变扫描逻辑后,在不影响结果准确度的情况下,将整体性能提升了至少二十倍以上。

更改的扫描逻辑不在本文描述,放在这里: https://github.com/kekeimiku/PointerSearcher-X/blob/main/ptrsx/src/lib.rs