2023年4月

引言

打算写写树形数据结构:二叉查找树、红黑树、跳表和 B 树。这些数据结构都是为了解决同一个基本问题:如何快速地对一个大集合执行增删改查。

本篇是第一篇,讲讲搜索树的基础:二叉搜索树。


基本问题

如何在一千万个手机号中快速找到 13012345432 这个号(以及相关联信息,如号主姓名)?


最笨的方案

把一千万个手机号从头到尾遍历一遍,直到找到该手机号,返回对应的姓名。其时间复杂度是 O(n)————当然这肯定不是我们要的方案。


最秀的方案

用散列表,可以在 O(1) 的时间复杂度完成查找。

关于散列表的原理和代码参见
算法(TypeScript 版本)


散列表的问题

散列表的查询性能非常优秀,插入和删除的性能也不赖,但它有什么问题呢?

我们稍微变换一下问题:如何在一千万个手机号中快速找到在 1306666666666661 到 13022222222 之间所有的手机号?

和基本问题不同的是,这是个范围查询。

散列表的本质是通过对关键字(本例中是手机号)执行 hash 运算,将其转换为数组下标,进而可以快速访问。

此处讲的数组是 C 语言意义上的数组,不是 javascript、PHP 等脚本语言中的数组。C 语言的数组是一段连续的内存片段,对数组元素的访问是通过内存地址运算进行的,可在常数时间内访问数组中任意元素。

hash 运算的特点是随机性,这也带来了无序性,我们无法保证 hash(1306666666666661) < hash(1306666666666662)。

无序性使得我们无法在散列表上快速执行范围查找,必须一个一个比较,时间复杂度又降到 O(n)。


基于有序数组的二分搜索

如果这一千万的手机号是排好序的(升序),我们有没有更好的办法实现上面的范围查找呢?

对于排好序的序列,我们如果能快速找到下限(1306666666666661)和上限(13022222222)所在的位置,那么两者之间所有的手机号就都是符合条件的。

如何才能快速找到 1306666666666661 的位置呢?

想想我们是怎么玩猜数字游戏的?

第一次猜 100,发现大了,第二次我们便倾向于猜 50 附近的数————而不是猜 99,如图:

基于二分查找思想的猜数字游戏

这种思想叫二分法————这种方法可以将问题范围成倍地缩小,进而可以至多尝试
\(\log_{2}n\)
次即可找出解。对于一千万个手机号来说,至多只需要比较 24 次即可找出 1306666666666661 的位置。相比于一千万次,简直是天壤之别。

代码如下:

interface Value {
    // 为方便起见,这里限定 key 是数值类型
    key: number;
    val: unknown;
}

/**
 * 二分搜索
 * @param arr - 待搜索数组,必须是按升序排好序的(根据 Value.key)
 * @param key - 搜索关键字
 * @reutrn 搜索到则返回对应的 Value,否则返回 null
 */
function binSearch(arr: Value[], key: number): Value | null {
    if (arr.length === 0) {
        return null
    }

    // 子数组左右游标
    let left = 0
    let right = arr.length - 1

    while (left <= right) {
        // 取中
        const mid = left + Math.floor((right - left) / 2)
        const val = arr[mid]

        if (key === val.key) {
            return val
        }

        // key 小于 val 则在左边找
        if (key < val.key) {
            right = mid - 1
        } else {
            left = mid + 1
        }
    }

    return null
}

所以,如果需要对这一千万个手机频繁地执行范围查找,二分搜索法是个不错的选择:先对一千万个手机号执行排序,然后对排好序的序列执行二分搜索。


二分搜索的问题

二分搜索能很快地执行精确查找和范围查找,但它仍然存在问题。

对一百个元素执行二分搜索,必须能够在常数时间内定位到第 50 个元素————只有数组(C 语言意义上的)这种数据结构才能做到。

也就是说,必须用数组来实现二分搜索。

但数组有个很大的缺陷:对插入和删除操作并不友好,它们都可能会造成数组元素迁移。

比如要往有序数组 arr = [1, 2, 3, 4, 5 ..., 100] 中插入元素 0 且继续保证数组元素的有序性,则必须先将既有的一百个元素都往右移一位,然后将 0 写到 arr[0] 位置。删除元素则会造成后续元素的左移。

倘若插入和删除操作非常频繁,这种迁移(复制)操作会带来很大的性能问题。

可见,对有序数组的查询可以在 O(
\(\log_{2}n\)
) 执行,但其写操作却是 O(n) 复杂度的。

有没有什么数据结构能够让读写操作都能在 O(
\(\log_{2}n\)
) 内完成呢?


二分搜索的启发

二分搜索的优势是能够在一次操作中将问题范围缩小到一半,进而能够在对数的时间复杂度上求得问题的解。不过其劣势是依赖于数组,因而其插入和删除性能低下。

那么,我们现在的目的便是解决二分搜索的写(插入和删除)性能。

要想提高写性能,我们的直觉是摆脱数组这种数据结构的桎梏————是否有别的数据结构代替数组?

一个很自然的想法是链表。链表的优势在于其元素节点之间是通过指针关联的,这使得插入和删除元素时只需要变更指针关系即可,无需实际迁移数据。

// 链表的节点定义
interface Node {
    data: Value;
    next: Node;
}

然而,链表的劣势是查询:它无法像数组那样通过下标访问(而只能从头节点一个一个遍历访问),进而也就无法实现二分法。

如对于数组 arr = [1, 2, 3, 4, 5],我们能直接通过 arr[2] 访问中间元素;但对于链表 link = 1 -> 2 -> 3 -> 4 -> 5,由于不是连续内存地址,无法通过下标访问,只能从头开始遍历。

那么,我们如何解决链表的查询问题呢?或者说如何用链表来模拟有序数组的二分法呢?

二分法有两个操作:

  1. 取中。快速定位到中间位置的元素(对于有序序列来说就是中位数)。
  2. 比较。根据第一步取得的元素,决定后续操作:如果相等则返回;如果比目标大,则取左半部子集继续操作;如果比目标小,则取右半部子集继续操作。

那么,如何在链表上实现上面两个操作?

我们先考虑操作二:比较。如果比较结果不相等,则会去左边或者右边继续查找。我们可以改造一下链表节点,用左右指针来表示“左边”和“右边”,左右指针分别指向左子链表和右子链表。改造后的节点定义如下:

// 改进的节点定义
interface Node {
    data: Value;
    // 左指针
    left: Node;
    // 右指针
    right: Node;
}

由于改造后的链表节点有 left 和 right 两个指针,相当于一个节点分了两个叉,故名为二叉。

再考虑操作一:取中。取中将原数组一分为三:当前元素(中间元素)、左子数组、右子数组。

我们可以将它映射到改造后的链表中的当前节点、左(left)子链表、右(right)子链表。查找时,如果当前节点的值小于目标值,则通过 right 指针进入到右子链表中继续查找,反之通过 left 指针进入左子链表查找。

数组转链表

继续分析之前,我们先从直观上考察一下改造后的链表。分叉后,整个结构不再像单条链子,更像一棵树,于是我们不再称之为“二叉链表”,而是称作“二叉树”。对应地,左右子链表也更名为“子树”。

对应数组看,很容易知道,
节点左子树中的所有元素都小于等于节点元素,右子树中的所有元素都大于等于节点元素
————这是二叉搜索树最重要(甚至是唯一重要)的性质。

至此,我们用链表的节点代替数组的元素,用节点的左右指针(或者说左右指针指向的两棵子树)代替左右子数组。

现在还剩下最后一个问题:如何将数组中的每个元素映射到这棵二叉搜索树(或者叫“改造后的链表”)中?

既然二分法是不断地取数组(包括左右子数组)中间位置的元素进行比较,那么我们将取出来的元素从上到下(从树根开始)依次挂到这棵树的节点上即可,如此当我们从树根开始遍历时,拿到的元素的顺序便和从数组中拿到的一致。

我们以数组 arr = [1, 2, 3, 4, 5, 6, 7] 为例,看看如何生成对应的二叉搜索树。

有序数组转成二叉搜索树

如上图:

  1. 先取整个数组中间元素 4,作为二叉树的根节点;
  2. 取左子数组的中间元素 2,作为根节点的左子节点;
  3. 取右子数组的中间元素 6,作为根节点的右子节点;
  4. 依此递归处理,直到取出数组中所有的元素生成二叉树的节点,整棵二叉树生成完成;

我们将上面的过程转换成代码:

// 二叉搜索树
class BinSearchTree {
    // 树根节点
    root: Node;
}

/**
 * 基于已排好序(根据 key)的数组 arr 构建平衡的二叉搜索树
 */
function buildFromOrderdArray(arr: Value[]): BinSearchTree {
    const tree = new BinSearchTree()
    // 从树根开始构建
    tree.root = innerBuild(arr, 0, arr.length - 1)

    return tree
}

/**
 * 基于子数组 arr[start:end] 构建一棵以 node 为根节点的二叉子树,返回根节点 node
 */
function innerBuild(arr: Value[], start: number, end: number): Node {
    if (start > end) {
        // 空
        return null
    } else if (start == end) {
        // 只剩下一个元素了,则直接返回一个节点
        return { data: arr[start], left: null, right: null }
    }

    /**
     * 使用二分思想构建二叉树
     */
     
    // 中间元素
    const mid = start + Math.floor((end - start) / 2)
    // 当前节点
    const curr: Node = { data: arr[mid], left: null, right: null }
    
    /**
     * 递归生成左右子树
     */
    // 左子树
    curr.left = innerBuild(arr, start, mid - 1)
    // 右子树
    curr.right = innerBuild(arr, mid + 1, end)

    return curr
}


二叉搜索树的查找

二叉搜索树是基于二分搜索思想构建的,其搜索逻辑也和二分搜索相同,只不过将左右子数组替换成左右子树。

以搜索元素 13 为例:

二叉搜索树查找元素

上图中搜索步骤:

  1. 从根节点开始比较,15 大于 13,到本节点的左子树继续搜索;
  2. 节点 6 小于 13,到本节点的右子树继续搜索;
  3. 节点 7 小于 13,到本节点的右子树继续搜索;
  4. 节点 13 等于 13,找到目标节点,结束;

对比二分搜索可以发现,二叉搜索树中的 left 和 right 子树就是对应二分搜索中左右子数组,两者的搜索逻辑本质上是一致的。

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 在以 node 为根的子树中搜索关键字为 key 的节点并返回该节点
     * 如果没有找到则返回 null
     */
    search(key: unknown, node: Node = undefined): Node {
        // 默认取根
        node = node === undefined ? this.root : node
        
        // 遇到 null 节点,说明没搜到,返回 null
        if (!node) {
            return null
        }
        
        // 先判断当前节点
        if (node.data.key === key) {
            // 找到,即可返回
            return node
        }
        
        // 没有找到,则视情况继续搜索左右子树
        if (key < node.data.key) {
            // 目标值小于当前节点,到左子树中搜索
            return this.search(key, node.left)
        }
        
        // 目标值大于等于当前节点,到右子树中搜索
        return this.search(key, node.right)
    }
}

从图中可见,对于任何元素的搜索,搜索次数不可能大于从根到所有叶节点的最长路径中节点个数(上图中是 5)。如果用这条路径的边来表达的话,搜索次数不可能超过最长路径边数加 1。

这个最长路径的边数即是整棵树的高。

对于一颗完美的平衡二叉树来说,这个高 h =
\(\log_{2}n\)
,其中 n 是节点数量。因而说二叉搜索树的查询时间复杂度是 O(
\(\log_{2}n\)
),和二分搜索是一致的。

注意上面说的是完美的平衡二叉树,但二叉搜索树并不是天生平衡的,所以才引出了各种平衡方案,诸如 2-3 树、红黑树、B 树等。


特殊查找:最小元素

由于二叉搜索树中的任意一个节点,其左边元素总小于该节点,所以要找最小元素,就是从根节点开始一直往左边找。

如图:

最小元素

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 查找以 node 为根的子树的最小节点并返回
     */
    min(node: Node = undefined): Node {
        // 默认取根节点
        node = node === undefined ? this.root : node
        
        if (node === null || !node.left) {
            // 如果是空子树,或者 node.left 是空节点,则返回
            return node
        }
        
        // 存在左子树,继续往左子树中找
        return this.min(node.left)
    }
}

相对应的是最大值,也就是递归地往右边找,此处略。


按序遍历

对于有序数组,很容易通过循环从头到尾按序遍历数组中元素,对应地,如何按序遍历二叉搜索树呢?

二叉搜索树是根据二分法递归生成的,所以同样可以用二分法来解决此问题。

对于一棵树来说,它分为三部分:树根节点、左子树、右子树,其中大小关系是:左子树 <= 树根节点 <= 右子树,所以我们以这个顺序遍历整棵树,便可以按序输出。

这种遍历方式,由于是在中间步骤操作树根节点,又称之为
中序遍历

相应地,按“树根节点 -> 左子树 -> 右子树”的顺序遍历称之为
先序遍历
,按“左子树 -> 右子树 -> 树根节点”的顺序遍历称之为
后序遍历

中序遍历代码:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 中序遍历
     */
    inorder(): Node[] {
        const arr: Node[] = []
        this.innerInorder(this.root, arr)

        return arr
    }

    /**
     * 对 x 子树执行中序遍历,遍历的节点放入 arr 中
     */
    innerInorder(x: Node, arr: Node[]) {
        if (!x) {
            return
        }

        // 先遍历左子树
        this.innerInorder(x.left, arr)
        // 自身
        arr.push(x)
        // 右子树
        this.innerInorder(x.right, arr)
    }
}


范围查询

如何在二叉搜索树上执行范围查询?

问题:按序返回二叉搜索树中所有大于等于 start 且小于等于 end 的节点集合(即返回所有节点 x,x 满足:start <= x <= end)。

上面的中序遍历其实就是一种特殊的范围查询:min <= x <= max。所以范围查询的思路和中序遍历一样,只不过在遍历时加上范围限制。

具体来说,什么时候需要去查左子树呢?当左子树有可能存在符合条件的元素时需要去查。如果当前节点 x 的值小于范围下限(start),而 x 的左子树的值都小于等于 x 的,说明此时其左子树中不可能存在符合条件的节点,无需查询;或者,如果 x 的值大于范围上限(end),而 x 的右子树的值都大于等于 x 的,说明此时其右子树中不可能存在符合条件的节点,也无需查询。其他情况则需要查询。

范围查询

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 按序返回所有大于等于 start 且小于等于 end 的节点集合
     */
    range(start: unknown, end: unknown): Node[] {
        const arr: Node[] = []
        this.innerRange(this.root, start, end, arr)
        return arr
    }
    
    /**
     * 在 x 子树中查找所有大于等于 start 且小于等于 end 的节点并放入 arr 中
     */
    innerRange(x: Node, start: unknown, end: unknown, arr: Node[]) {
        if (!x) {
            return
        }
        
        // 比较节点 x 和 start、end 之间的大小关系
        const greaterThanStart = x.data.key >= start
        const smallerThanEnd = x.data.key <= end

        // 如果当前节点大于等于 start,则需要搜索其左子树
        if (greaterThanStart) {
            this.innerRange(x.left, start, end, arr)
        }
        
        // 如果 x 在 start 和 end 之间,则符合条件,存入 arr
        if (greaterThanStart && smallerThanEnd) {
            arr.push(x)
        }
        
        // 如果当前节点小于等于 end,则需要搜索其右子树
        if (smallerThanEnd) {
            this.innerRange(x.right, start, end, arr)   
        }
    }
}


插入操作

对于二叉树来说,新节点总是被插入到 null 节点(末端)处。

还是以上图为例,插入新节点 14:

插入元素

如图所示,插入操作分两步:

  1. 搜索。这一步和查找操作一样,相当于是搜索这个新节点 14,结果没搜到,遇到了 null 节点(Node(13).right);
  2. 插入。生成新节点 14 并插入到节点 13 的右侧(Node(13).right = Node(14));

很明显,插入操作的时间复杂度也是 O(
\(\log_{2}n\)
),完美!

插入操作的代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 将元素 data 插入到树中
     */
    insert(data: Value) {
        // 从根节点开始处理
        // 插入完成后,将新根赋值给 root
        this.root = this.innerInsert(data, this.root)
    }
    
    /**
     * 将元素 data 插入到以 node 为根的子树中
     * 返回插入元素后的子树的根节点
     */
    innerInsert(data: Value, node: Node): Node {
        if (node === null) {
            // 遇到了 null 节点,说明需要插入到该位置
            return { data: data, left: null, right: null }
        }
        
        // 比较 data 和 node 的值,视情况做处理
        if (data.key < node.key) {
            // 待插入的元素小于当前节点,需要插入到当前节点的左子树中
            node.left = this.innerInsert(data, node.left)
        } else {
            // 插入到右子树中
            node.right = this.innerInsert(data, node.right)
        }
        
        // 插入完成后,需返回当前节点
        return node
    }
}


删除操作

删除操作需要分几种情况。

情况一:删除叶子节点,该节点的 left 和 right 都是 null。

这种情况很简单,直接删掉该元素即可。如删除节点 9:

删除情况一

情况二:待删除的节点只有一个子节点,用该子节点代替该节点即可。如删除节点 13:

删除情况二

以上两种情况都比较简单,第三种情况则稍微复杂。

情况三:待删除的节点有左右两个子节点。如图中节点 6。将 6 删除后,我们无法简单的用其 left 或 right 子节点替代它————因为这会造成两棵子树一系列的变动。

前面说过,二叉搜索树本质上是由有序数组演化而来,那么我们不妨以数组的角度看是否有所启示。

上图用数组表示:arr = [2, 3, 4, 6, 7, 9, 13, 15, 18, 17, 20]。该数组中删掉元素 6 后,如何才能让数组中其他元素调整次数最少(这里不考虑迁移,因为二叉树不存在迁移开销)?

自然是直接用 6 的前一个(4)或者后一个(7)元素替代 6 的位置。其中 4 恰恰是 6 左边子数组中的最大值,而 7 恰恰是其右边子数组中的最小值。

我们不妨用右边子数组中最小值(7)来替代 6————映射到二叉搜索树中便是节点 6 的右子树的最小节点。

前面已经讨论过二叉搜索树中最小值的求解逻辑:顺着节点左子树(left)一直递归查找,直到 node.left 等于 null,该 node 便是最小值————也就是说,一棵树的最小节点不可能有左子节点,即最小节点最多有一个子节点,这便是情况一或者情况二。

那么能否用右子树中最小节点(7)替代当前节点(6)呢?无论从有序数组还是从二叉搜索树本身角度看,都很容易证明是可以的(替换后仍然符合二叉搜索树的性质)。

因而,情况三可以作如下处理:

  1. 将右子树中最小节点的 data 赋值给当前节点;
  2. 删除右子树中最小节点;

删除情况三

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    // ...
    
    /**
     * 删除 key 对应的节点
     */
    delete(key: unknown) {
        const node = this.search(key, this.root)
        if (!node) {
            // key 不存在
            return
        }
        
        this.root = this.innerDelete(this.root, node)
    }
    
     /**
     * 删除子树 current 中 del 节点,并返回操作完成后的子树根节点
     */
    innerDelete(current: Node, del: Node): Node {
        /**
         * 当前节点即为待删除节点
         */
        if (current === del) {
            // 情况一:当前节点没有任何子节点,直接删除
            if (!current.left && !current.right) {
                return null
            }
            
            // 情况二:只有一个子节点
            if (current.left && !current.right) {
                // 只有左子节点,用左子节点替换当前节点
                return current.left
            }
            
            if (current.right && !current.left) {
                // 只有右子节点,用右子节点替换当前节点
                return current.right
            }
            
            // 情况三:有两个子节点
            // 取右子树的最小节点
            const minNode = this.min(current.right)
            // 用最小节点的值替换当前节点的
            current.data = minNode.data
            // 删除右子树中的最小节点
            current.right = this.innerDelete(current.right, minNode)

            return current
        }
        
        /**
         * 当前节点不是待删除节点,视情况递归从左或右子树中删除
         */
        if (del.data.key < current.data.key) {
            // 待删除节点小于当前节点,从左子树删除
            current.left = this.innerDelete(current.left, del)
        } else {
            // 待删除节点大于当前节点,继续从右子树删除
            current.right = this.innerDelete(current.right, del)
        }
        
        return current
    }
}

很容易证明,删除操作的时间复杂度也是 O(
\(\log_{2}n\)
)。


二叉搜索树的问题

由上面的插入操作可知,二叉搜索树新增节点时总是往末端增加新节点,这种操作方式有个问题:当我们一直朝同一个方向(一直向左或者一直向右)插入时,便很容易出现倾斜。

比如我们向一棵空树中依次插入 1, 2, 3, 4, 5:

const tree = new BinSearchTree()
tree.insert({ key: 1, val: 1 })
tree.insert({ key: 2, val: 2 })
tree.insert({ key: 3, val: 3 })
tree.insert({ key: 4, val: 4 })
tree.insert({ key: 5, val: 5 })

得到的二叉树如下:
倾斜二叉树

二叉树退化成了普通链表,所有的操作退化为 O(n) 复杂度!

所以在插入和删除二叉树节点时,需要执行额外的操作来维护树的平衡性,后面将要介绍的红黑树和 B 树就是两种非常著名的解决方案。


总结

  1. 散列表能很好地解决精确查询(O(1) 复杂度),但无法解决范围查询(必须 O(n) 复杂度);
  2. 基于有序数组的二分搜索能很好地解决精确查询和范围查询(O(
    \(\log_{2}n\)
    ) 复杂度),但无法解决插入和删除(必须 O(n) 复杂度);
  3. 基于二分搜索思想改进的链表(二叉搜索树)能很好地解决查询(包括范围查询)、插入和删除,所有的操作都是 O(
    \(\log_{2}n\)
    ) 的时间复杂度;
  4. 二叉搜索树中以任意节点作为根的子树仍然是一棵二叉搜索树,这个特点很重要,它是递归操作的关键;
  5. 二叉搜索树存在节点倾斜问题,会降低操作性能,极端情况下会退化成普通链表,所有的操作都退化到 O(n) 复杂度;
  6. 为解决二叉搜索树的倾斜问题,实际应用中需引入相关平衡方案,本系列的后序文章将介绍三种常见的方案:红黑树、跳表和 B 树;

本文中的示例代码采用 TypeScript 语言实现,且用递归法写的,完整代码参见
二叉搜索树(递归法)
。非递归法代码参见
二叉搜索树(循环法)

• ICMP隧道攻击工具特征分析


一、原理

由于ICMP报文自身可以携带数据,而且ICMP报文是由系统内核处理的,不占用任何端口,因此具有很高的隐蔽性。

通过改变操作系统默认填充的Data,替换成自己构造的数据,这就是ICMP隐蔽隧道的原理。

通常ICMP隧道技术采用ICMP的ICMP_ECHO和ICMP_ECHOREPLY两种报文,把数据隐藏在ICMP数据包的数据域中,利用ping命令建立隐蔽通道。

进行隐蔽隧道传输的时候,被控端(防火墙内部)运行并接受外部攻击端的ICMP_ECHO数据包,攻击端把需要执行的命令隐藏在ICMP_ECHO数据包中,被控端接收到该数据包,解出其中隐藏的命令,并在防火墙内部主机上执行,再把执行结果隐藏在ICMP_ECHOREPLY数据包中,发送给外部攻击端。(本质上就是利用防火墙不禁止ICMP协议的安全漏洞,通过ICMP的请求和应答数据包,伪造Ping命令的数据包形式,实现绕过防火墙和入侵检测系统的阻拦。)

优点:

  1. 通常防火墙对ICMP_ECHO数据包是放行的,并且内部主机不会检查ICMP数据包所携带的数据内容,隐蔽性高

缺点:

  1. ICMP隐蔽传输是无连接的,传输不是很稳定,而且隐蔽通道的带宽很低
  2. 利用隧道传输时,需要接触更低层次的协议 ,这就需要高级用户权限

二、隐蔽隧道工具使用及流量特征分析

1、icmpsh建立隧道及数据包分析

这一工具简单并且便携。受控端(客户端)使用C语言实现。只能运行在目标Windows机器上,而主控端(服务端)由于已经有C和Perl实现的版本,而且之后又移植到了Python上,因此可以运行在任何平台的攻击者机器中。

攻击机:172.16.159.129/24

靶机:172.16.159.153/24

攻击机执行命令建立隐蔽隧道连接

靶机执行命令建立隐蔽隧道连接

执行" whoami "命令抓包查看

Wireshark抓包分析

2、icmptunnel建立隧道及数据包分析

  • icmptunnel通过创建一个虚拟的隧道接口来工作
  • 客户端主机上的所有用户流量都路由到虚拟网卡tun0
  • icmptunnel在此接口上侦听IP数据包
  • 这些数据包封装在ICMP回显数据包中

攻击机:172.16.159.2/24

靶机:172.16.159.3/24

整体架构:

攻击机执行命令建立隐蔽隧道连接

观察路由表

靶机执行命令建立隐蔽隧道连接

观察路由表

此时攻击机和靶机之间通过虚拟网卡tun0建立连接,IP地址为:

攻击机:10.0.1.1/24

靶机:10.0.1.2/24

在攻击机执行ssh登陆靶机

抓包物理网卡eth0,可以发现通讯连接全部变成ICMP协议,所有通讯流量都被封装在ICMP协议中传输

抓包虚拟网卡tun0,流量仍然为正常的协议通讯

3、ptunnel建立隧道及数据包分析

ptunnel 全称 PingTunnel,Kali下自带该工具

假设场景,当前已经拿下了一台外网 Web Linux 服务器,想通过它利用 ICMP 协议连接内网的一台已经开启远程桌面的 Windows ,网络结构简化如下:

PC 本机||              
||
||Kali 攻击机172.16.159.2(外网)||              
||
||Linux Web 跳板机172.16.159.3(外网)||               172.16.30.3(内网)||
||Win RDP 目标机172.16.30.2 (内网)

在 Kali 攻击机上执行以下命令

ptunnel -p 172.16.159.3 -lp 3389 -da 172.16.30.2 -dp 3389 -x pass

-p 指定跳板机外网IP

-lp 指定本机的监听端口

-da 指定目标机的内网IP

-dp 指定目标机的端口

-x 设置隧道密码

在 Linux Web 跳板机上执行以下命令

ptunnel -x pass

之后访问 Kali 攻击机 172.16.159.2 的 3389 端口就会连接到 Windows RDP 目标机 192.168.30.2 的 3389 端口了,不过实测发现此ICMP隧道建立的通讯速度慢且不够稳定

抓取 Linux Web 跳板机和 Windows RDP 目标机之间的流量,可以发现流量传输的是TPKT协议

(TPKT协议是一个传输服务协议,我们常用的RDP协议(Remote Desktop Protocol,Windows的远程桌面协议)就是基于TPKT)

抓取 Kali Linux 攻击机和 Linux Web 跳板机之间的流量,可以发现流量传输已经变成ICMP协议

三、HW行动之ICMP隧道攻击的应用

2022年HW病毒木马在反连C2过程中使用的协议呈现出多样化的趋势,不再局限于经典的TCP、HTTP和HTTPS,少量样本开始使用ICMP隧道和DNS隧道进行通信。

样本描述
样本信息
Sha256 d145e9a2a6e9e904aa2984ae9282d384631f757a978adf24a09dd2600011834a
SHA1 44bacb493e84a14f9f0dc384b0f9648b50dade8e
MD5 70804a1efac34e4f0e24fd0af5286692
文件类型 EXE
文件大小 4.67MB
文件名称 投诉举报证据.dоcxㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ                        ...ㅤㅤ       ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ    .exe
功能描述 后门木马
技术特点 Go语言加载器,会将后门样本释放本地执行,样本可进行icmp通信

这是一个使用ICMP协议进行通信的样本。样本外层使用Go语言编写的加载器,负责将诱饵Word文档和木马模块释放执行。

之后,木马将在C:\Users\Public目录下释放名为svchost.exe木马并启动执行。

样本利用ICMP协议同C2进行通信,ICMP协议中的data字段可写入任意自定义数据,样本会将上线数据填充1024个字节放在ICMP协议的data字段,并且返回的ICMP数据包的data字段也是固定的1024个字节。

C2会对接收的ICMP流量进行判断,如果data数据不符合其格式,将不会发回响应包。在接收到到响应后,样本会基于C2返回的数据执行不同的功能。

编程的方法往往不止一种,比如怎么把一个Python种的列表拆成N个子列表,我们可以很容易找到N种方法,也许这就是编程的魅力所在。

一、列表表达式法

这种方法最为简洁,不过可读性差一些

这个方法中,即使原始列表的数量无法被N整除,也不会出错,其实那是因为使用列表的切片功能访问列表时,只要切片中首位不越界,末位无所谓,这是python的一大亮点。但是看上例来说,如果我们足够吹毛求疵,会发现它还有点不完美。拆分后的子列表,第一个和第二个子列表的长度都为3,最后一个子列表只有一个元素了,拆分不够均匀。

要是有一种算法能实现,当列表长度无法被N整除时,每个列表的长度都均匀少一点,每个子列表的总长度都相差不超过1,该有多好啊?这就要提到我们的方法二了。


二、利用贪心算法

永远往最短子列表中添加元素法

先构造一个列表,内部构造N个子空列表:[[],[],[],[]] .遍历原始列表的每个元素,当把这个元素往结果列表的哪个子列表中追加时,永远用min方法看哪个子列表的长度最短,谁的长度最短,说明给它append的子元素最少,我们就优先给它插入新元素,思路很巧妙,代码如下:

但是仔细一看,这个方法还是有缺点,idx = res.index(min(res, key=len))  这句(找到长度最短的子列表的下标)方法很浪费性能,也许我们不用每次都用min方法逐个判断子列表长度来确定待追加元素的子列表。

三、发扑克牌法

一开始每个子列表的元素本就为空,我们永远依次给每个子列表追加元素,一轮接着一轮,这样大家的长度就应该接近。就像发扑克牌一样的道理。算法上应该是这样写:

作为完美主义者来讲,这样拆分列表依然有瑕疵。原始的列表为l = [1,2,3,4,5,6,7],拆分后的output=[[1, 4, 7], [2, 5], [3, 6]],怎么感觉两者长得不像呢?

有没有一种拆分列表的算法,可以把方法一的【保留列表元素顺序】和方法三【子列表数量相差不超过1】 这两者的优点有效结合呢?

必须有啊。

四、动态确定长度+列表切片

我们只需要在发牌前,先计算出每个子列表的理论长度,再从原始列表中根据切片拿到特定长度的元素给到每个子列表,不就大功告成了
图片
。有了思路,代码那叫一个Easy。

方法四,如果我们进一步用列表表达式来优化,还可以更简洁,比如就像这样:

过分的简洁有时候也会导致更差的可读性。屏幕前的你,更接受用上面哪种方法来拆分列表呢?

快来关注本公众号 获取更多爬虫、数据分析的知识!

全文快读

  • 论文题目:Reinforcement learning with multi-fidelity simulators,是 14 年的 ICRA 会议的论文。师兄说是 robotics 顶会,但中稿率蛮高的。
  • 链接:
    https://ieeexplore.ieee.org/document/6907423
  • main contribution:把 multi-fidelity optimization 拓展到 sequential decision 场景。
  • 主要内容:
    • 目标:real-world sample 数量最少。
    • 定义 optimistic multi-fidelity simulator chain:一大串 multi-fidelity 的 simulator。
    • KWIK 技术:很 naive 的技术,就是,如果我们看到过足够多这种情况,就根据经验给出 reward / transition 估计,否则,给出最大 reward 作为估计(鼓励 exploration)。
    • MF-bandit 算法:
      • 假设 state 不变,我们要找到最优 action;
      • 目前在尝试 a* = argmax Reward(最大化 reward 估计值)这个动作,在 level d 的 fidelity 尝试了这个动作,得到其 reward,用这个 reward 更新 reward 估计值。
        • reward 估计值的更新:如果在最 low-fidelity 的 simulator,估计值 = R_max;否则,用 fidelity 低一层的 reward 估计值,作为当前 fidelity reward 估计的 heuristic,估计 = R_{d-1} + β。
      • 然后,我们继续做 a* = argmax R,因为 reward 估计值变了嘛,所以这个 a* 很可能跟上一轮的 a* 不是同一个。
        • 如果是同一个,那我们就认为 policy 在 level d 的 fidelity 收敛了,去 level d+1 继续做 MF-bandit。
        • 如果不是同一个:如果这个 a* 在 level d-1 上并没有估计值,即,我们没法拿 d-1 的 reward 估计做 d 层 reward 估计的 heuristic 了,那么回退到 d-1 层,先把 d-1 层 reward 估计算出来。
    • MFRL 算法:跟 MF-bandit 基本一样。
      • 我们需要估计的有:1. reward,2. env transition。
      • 要通过 argmax Q function 取 action。
      • 每次 1. reward,2. env transition 有所更新(根据 KWIK,即出现 (s,a,r,s') 次数足够多),就重新计算 Q function。并且,如果 1 2 有所更新,把数据同步到所有更 low-fidelity 的 KWIK learner。
  • 局限性:我还没太想好,如果用 DRL 来做,具体该怎么做。

0 abstract

  • 提出一个 RL 框架:在有多个保真度(fidelity)不断降低的 simulator 的情况下。
  • 我们的框架允许 agent 选择仍能提供信息的最 low-fidelity 的 simulator,来限制 high-fidelity simulator 的样本数量。
  • 该方法基于 know-what-it-knows 技术,将 low-fidelity 的 Q function 作为 high-fidelity RL 的 heuristic。与迁移学习(transfer learning)或无模拟器学习相比,real-world sample 数量更少。
  • 给出了关于 sample conplexity 的理论证明,并在一辆有 multi-fidelity simulator 的遥控汽车上做实验。

1 intro

  • 迁移学习:将策略从 simulator 转移到 real-world。

  • 多保真 RL(MFRL):


    • 结合多保真优化 [6] 和 model-based RL,"面对不确定性的乐观主义 "启发式方法,RL [7] 的 "知道它知道什么"(know what it knows,KWIK)model-learning framework。
    • 1 在 coarse model 里探索,2 用 fine model 更新 coarse model。
  • 与只向现实世界的 agent 传递一次 heuristics 的单向方法不同[5],MFRL 框架还规定了 agent 何时应该向上移动到高保真模拟器,以及在更昂贵的模拟中进行过度探索之前,向下移动保真度的规则。有理论上的收敛、采样效率的保证。

  • 具体来说,该框架


    • (1)不会在高水平上运行已被证明为次优的行动,
    • (2)(在一定条件下)将最高 fidelity 的样本数量降到最低,
    • (3)限制 sample 总数。
    • 此外,可以证明 MFRL without resets 在最高 fidelity 的 simulator 上的样本数(最坏情况)不比“单向传输”方法多。
  • main contributions:


    • (1)介绍 MFRL framework;
    • (2)对该框架的 sample complexity 进行了理论分析;
    • (3)实验。
  • RL:1 在 simulator 里学,然后直接在 real-world 跑,2 一直在 real-world 里跑,但用 low-fidelty simulator 算 policy gradient。

  • 已经有监督学习的 multi-fidelty 工作了。

  • 使用 low-fidelty model 生成的东西,作为指导 exploration 的 heuristic。


    • 不是 用上一个环境训出来的 policy 指导当前环境学习的迁移学习(transfer learning,TL)。
    • 不是 在 action space 不同的环境间的 TL [10],因为 env 的 dynamics 和 rewards 也不一样。
  • 类似的方法是 transferred delayed Q-learning(TDQL)[5]。可以证明我们的方法在 highest-fidelity env 上的 sample 数量 ≤ TDQL。

  • 我们的工作将多保真优化(MFO)[6] 扩展到顺序决策(sequential decision-making)问题,借鉴 MFO 的经验,用 high-fidelity 数据更新模型,并根据 low-fidelity 结果作为 RL exploration 的 constraint。

3 背景 & 假设

3.1 RL & KWIK(know what it knows)的背景

  • KWIK:是一种 standardize RL sample complexity 的 framework。sample complexity 就是次优步骤的数量。
  • 如果 agent 对 (s,a) 的预测 (s', r) 有把握,即 || prediction - ground truth || ≤ ε,则使用预测值 (s', r),否则 agent 给出⊥,表示它不知道 (s,a) 会发生什么。
  • KWIK-Rmax RL:于是,使用预测的 s' 和 real env 的 reward 建立近似 MDP。如果 agent 给出了⊥,则乐观的将 reward 设成 (1-γ)R_max。
  • 它保证了多项式的 sample complexity。
  • (并没有听懂)

3.2 问题定义

  • 用 Σ 表示 MDP simulator。
  • 貌似,假设 low-fidelity 是 high-fidelity 的一种状态集结,用 |Q(s, a) - Q(ρ[s], a)| 来定义 fidelity f(Σi, Σj, β),其中 ρ 是 Si → Sj 的 state mapping,Σi 的 fidelity<Σj。(见公式)
    • 所以,Σi 对 Σj 的 fidelity 与它们最优 Q function 的误差成(负的)正比,前提是 low-fidelity Σi 对 Q function 的低估(还是高估)不超过 β,否则就是负无穷。
    • 合理性解释:在汽车模拟器中,low-fidelity Σi 假设行动会有完美的结果,然而在 higher-fidelity 中,这些乐观的 Q function 被更现实的 value function 所取代。
  • Definition 1: optimistic multi-fidelity simulator chain:一系列 Σ1 .. ΣD,其中 ΣD 是 real world,并且对于一个特定的 ρi 和 βi,有 fidelity(Σi, Σ_{i+1}, βi) 不是负无穷。
  • Assumption 1: 假设对于 low-fidelity Σi 和 high-fidelity Σ_{i+1},在后者上模拟一步的 cost 是在前者上模拟多项式步(polynomial)的 cost。
  • Assumption 2: 只能通过连续 trajectory 的方式使用模拟器,不能随机访问 (s,a) 或直接读参数。
  • objectives:
    • 尽量减少 ΣD(real-world)的 sample 数量。
    • sample 数量是多项式的约束?
    • switch simulator 次数是多项式的约束。

4 Multi-Fidelity Bandit Optimization

考虑最简单的 setting:一个带有随机性的、只包含一个 state、k 个 action(称为 arm)的 MDP,使用 MF 优化寻找最优 arm。

4.1 MF 寻找最优 arm 的算法(MF-bandit)

变量与初始化:

  • 首先维护一个 reward 集合 R_d(a),用于存储尝试各种 action(arm)的经验。
    • 如果 reward 经验集合 R_d(a) 里关于某 action a 的经验超过 m 条,则取这些经验 reward 的平均值为 reward 估计值 U^_{d,a};
    • 否则给出⊥即“我不知道”,并将 R_max 作为估计值(乐观估计)。
  • 维护 bool 变量 closed_d,表示 Σ_d 的 action 是否收敛。
  • 维护采取某 action 后的 reward 的 upper bound,U_{d,a}。
  • 维护 bool 变量 con_{d,a},表示 d 层的 action a 我是否了解透了。
  • 维护 bool 变量 change_d,表示 d 层的 a* = argmax R_d(a) 是不是要变了。

算法:

  • 首先取 a* = argmax U_{d,a},即 reward 上界最大的 action。
  • 更新 closed_d = con_{d,a*} 或者 a* 肯定是 near optimal,表示 Σ_d 的 action 收敛了。
  • 如果 closed_d == false,即 Σ_d 中 action 的 reward 还没收敛,则执行 a*,得到 reward r,更新 reward 经验集合 R_d(a)。
    • 然后,更新 reward upper bound U_{d,a*} = min(U_{d,a*}}, U^_{d,a*})。
      • 最初的 U_{d,a} = U_{d-1,a} + β_{d-1} 是来自 low-fidelity 的 heuristic,是 low-fidelity simulator 的 heuristic 加上高估的极限 β。
    • 如果 R_d(a) 能够给出对于 a 的 reward 估计(即经验超过 m 条)了,则
      • con_{d,a*} = true,表示 a* 我了解透了;
      • change_d = true,表示 既然我获得了基于真实经验的 reward 估计值,可能 d 层的 a* = argmax R_d(a) 要换一换了。
  • 如果 closed_d == true,即 Σ_d 中 action 的 reward 收敛了,则 d += 1。
    • 同时更新 heuristic U_{d+1,a} = U_{d,a} + β_d,changed_{d+1} = false。
  • 如果 con_{d-1,a*} == false(目前给出的 a* 还没了解透)&& change_d == true(上一轮得到了一个 action 的真实 reward 估计,所以这一轮换 argmax action 了),则代表 a* = argmax R 换了个 action,但这个 action 在 low-fidelity 中还没理解透(也就是所谓的 low-fidelity 给出的最佳 action 在 high-fidelity 表现不好),要回溯到 low-fidelity simulator,d -= 1。
    • 更新 changed_{d-1} = false。
    • for 所有的 action a:如果 con_{d,a} == true(表示 action a 在 simulator d 研究透了),then
      • 把 high-fidelity 的经验 R_{d} 拷贝到 low-fidelity 经验集合 R_{d-1},
      • 设置 con_{d-1,a} == true(既然上层研究透了,下层也不用做研究了),
      • change_{d-1} = true。


真抽象。

在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对 Java 堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)。

有两种判断对象是否存活的算法:引用计数算法、可达性分析算法。

  • 引用计数算法
    判断对象是否存活的基本思路是:在对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计数器为零的对象就是不可能再被使用的对象。
  • 可达性分析算法
    判断对象是否存活的基本思路是:通过一系列被称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为 “引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可能再被使用的对象。

引用计数算法

引用计数算法(Reference Counting)判断对象是否存活的基本思路是:在对象中添加一个引用计数器,每当有一个地方引用该对象时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计数器为零的对象就是不可能再被使用的对象。


客观地说,引用计数算法虽然占用了一些额外的内存空间来进行计数,但引用计数算法的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用案例, 例如微软 COM(Component Object Model)技术、使用 ActionScript3 的 FlashPlayer、Python 语言以及在游戏脚本领域得到许多应用的 Squirrel 中都使用了引用计数算法进行内存管理。

但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法进行内存管理,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量的额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子,请看代码清单 3-1 的 testGC() 方法:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是因为它们互相引用着对方, 导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

代码清单 3-1 引用计数算法的缺陷

/**
 * testGC()方法执行后, objA和objB会不会被GC呢?
 *
 * @author zzm
 */
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    /**
     * 这个成员属性的唯一意义就是占点内存, 以便能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
		// 假设在这行发生GC, objA和objB是否能被回收?
        System.gc();
    }
}

可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至古老的 Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判断对象是否存活。

可达性分析算法判断对象是否存活的基本思路是:通过一系列被称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为 “引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可能再被使用的对象。

如下图所示,对象 object 5、object 6、object 7 虽然互有关联,但是它们到 GC Roots 是不可达的,因此它们将会被判定为是可回收的对象。

image-20230222100921648.png

在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:

  • Java 虚拟机栈(栈帧中的本地变量表) 中引用的对象,譬如各个线程调用的方法堆栈中使用到的参数变量(方法定义时声明的变量)引用的对象、局部变量(定义在方法中的变量)引用的对象、临时对象(没有变量引用的对象)等。

  • 本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象(非 Java 代码中的对象)。

  • 方法区中引用的对象:


    • 方法区中类的静态属性(static 关键字)引用的对象,譬如 Java 类的引用类型静态变量。
    • 方法区中常量(static 和 final 关键字)引用的对象,譬如字符串常量池(String Table)里的引用。
  • 所有被同步锁(synchronized 关键字)持有的对象。

  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、 OutOfMemoryError)等,还有系统类加载器。

  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象 “临时性” 地加入,共同构成完整的 GC Roots 集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这个时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,这样才能保证可达性分析的正确性。

参考资料

《深入理解 Java 虚拟机》第 3 章:垃圾收集器与内存分配策略 3.2 对象已死?