Rust 实战练习 - 8. 内存,ASM,外挂 【重磅】

目标:

  • C写一个Demo版本的游戏
  • 由浅入深,了解外挂原理
  • Linux/Android下实现内存读取
  • ptrace实现内存修改(依赖第三方库)

先准备一个C写的小游戏

#include <stdio.h>
#include <string.h>

struct Role
{
    float pos_x; // 位置x
    float pos_y;
    float pos_z;

    int level; // 等级
    long money; // 金钱
};

struct Account
{
    char name[20]; // 名字
    long ID; // ID
    struct Role *role; // 多个角色信息
};

void decMoney(struct Role* r){
    r->money -= rand()%100;
}

void main(){
    printf("GameBoy tester!\r\n");
    struct Account ac;
    strcpy(ac.name, "我是张三啊");
    ac.ID = 20240318;
    // roles
    struct Role *rl = (struct Role*)malloc(sizeof(struct Role)*4);
    ac.role = rl;
    for(int i=0;i<4;i++){
        rl[i].level = i*2+10;
        rl[i].money = 5000;
        rl[i].pos_x = i*1000;
        rl[i].pos_y = i*1100;
        rl[i].pos_z = i*1200;
    }
    
    while (1)
    {
        printf("\r\nInput [l,m,p,c] to change value:");
        char input = getchar();
        switch (input)
        {
        case 'l':
            for(int i=0;i<4;i++){
                rl[i].level += rand()%50/10;
            }
            break;
        case 'm':
            for(int i=0;i<4;i++){
                rl[i].money += rand()%20;
            }
            break;
        case 'p':
            for(int i=0;i<4;i++){
                rl[i].pos_x += rand()%100 * 0.1;
                rl[i].pos_y += rand()%20 * 0.2;
                rl[i].pos_z += rand()%10;
            }
            break;
        case 'c':
            for(int i=0;i<4;i++){
                decMoney(&rl[i]);
            }
            break;
        default:
            break;
        }
        // print
        printf("\r\nAccount: [%ld] => %s\r\n", ac.ID, ac.name);
        for(int i=0;i<4;i++){
            printf("Role[%d] => Level:%d, Money: %ld, Pos:[%.2f,%.2f,%.2f]\r\n", 
                i, rl[i].level, rl[i].money, rl[i].pos_x,rl[i].pos_y,rl[i].pos_z);
        }
        printf("Cheat: Account [0x%lX], ID [0x%lX], Name [0x%lX], role [%lX]\r\n", &ac, &(ac.ID), ac.name, &(ac.role));
        printf("Cheat: Role [0x%lX], pos_x [0x%lX], level [0x%lX], money [%lX]\r\n", rl, &(rl[0].pos_x), &(rl[0].level), &(rl[0].money));
        printf("Cheat: decMoney [0x%lX], main: {0x%lX}\r\n",decMoney, main); 
    }
}


// gcc -o gamebox main.c
// ./gamebox
Input [l,m,p,c] to change value:

只要输入 l/m/p/c 就可以随机改变其中的等级,金钱,位置信息。其中输入c会调用函数,随机减少角色的金钱。

Rust修改gamebox

有个出名的技术叫hook,还有一个技术叫修改内存。这也就是简单的游戏外挂范围的技术。这里我们使用rust去模拟一下,修改上面用C写的gamebox。

在Linux下如何搜索内存找到我们需要的数据,不在这里讨论,我们根据gamebox提供的地址,直接定位。一定要自己找,可以使用 PINCE。(类似CE)

备注:ceserver + wine + CE GUI 可以在Linux下进行搜索。

首先,反推几个重要信息的地址关系: (假设字节对齐)

[BaseAddr+0] => name 首地址, char[20]
[BaseAddr+20] => ID, long
[BaseAddr+20+8] => Role的首地址一个, Role size=3*4+4+8=24

Role = [BaseAddr+20+8]
[[BaseAddr+20+8]+24*i +0] => Role.pos_x, float
[[BaseAddr+20+8]+24*i +4] => Role.pos_y, float
[[BaseAddr+20+8]+24*i +8] => Role.pos_z, float
[[BaseAddr+20+8]+24*i +12] => Role.level, int
[[BaseAddr+20+8]+24*i +16] => Role.money, long

虽然这个地址信息每次启动时会变化,但是他们的关系应该是固定的。

实际上,字节没有对齐,和我们上面预期有差异:

BaseAddr = 0x7FFEE5125640
[BaseAddr+0] => name 首地址, char[20] => 0x7FFEE5125640 实际占用是0x18=24
[BaseAddr+24] => ID, long => 0x7FFEE5125658
[BaseAddr+24+8] => Role的首地址一个, Role size=3*4+4+8=24  => 0x7FFEE5125660 

Role = [BaseAddr+24+8] = [0x7FFEE5125660] = 0x5585F6BFE6B0
[[BaseAddr+24+8]+24*i +0] => Role.pos_x, float => 0x5585F6BFE6B0
[[BaseAddr+24+8]+24*i +4] => Role.pos_y, float
[[BaseAddr+24+8]+24*i +8] => Role.pos_z, float
[[BaseAddr+24+8]+24*i +12] => Role.level, int  => 0x5585F6BFE6BC
[[BaseAddr+24+8]+24*i +16] => Role.money, long => 0x5585F6BFE6C0

准备

https://blog.csdn.net/guojin08/article/details/9454467

https://blog.csdn.net/hhhlizhao/article/details/77930009

https://zhuanlan.zhihu.com/p/348171413

https://zhuanlan.zhihu.com/p/674139021

https://www.52pojie.cn/thread-1355860-1-1.html

Linux上一切皆文件,只要有权限,读写其他程序的内存很简单。

  1. sudo权限,强制读写/proc/ID/mem
  2. 自身为内核空间,直接读写mem
  3. 能获取到proc的mem的物理地址,读写物理内存
  4. dbg相关函数

如果想要注入,修改代码,需要使用ptrace一类的方式。

https://www.52pojie.cn/thread-1568457-1-1.html

Linux和Android的方法都是类似的.

简易内存修改

1.直接读取内存固定位置

读文件方式实现

andy@andy-pc:/proc/22093$ cat maps
5593869d4000-5593869d5000 r--p 00000000 08:12 6160449                    /home/andy/Work/rs_prj/d8/C/gamebox
5593869d5000-5593869d6000 r-xp 00001000 08:12 6160449                    /home/andy/Work/rs_prj/d8/C/gamebox
5593869d6000-5593869d7000 r--p 00002000 08:12 6160449                    /home/andy/Work/rs_prj/d8/C/gamebox
5593869d7000-5593869d8000 r--p 00002000 08:12 6160449                    /home/andy/Work/rs_prj/d8/C/gamebox
5593869d8000-5593869d9000 rw-p 00003000 08:12 6160449                    /home/andy/Work/rs_prj/d8/C/gamebox
559386d8f000-559386db0000 rw-p 00000000 00:00 0                          [heap]
7f914db19000-7f914db1c000 rw-p 00000000 00:00 0 
7f914db1c000-7f914db44000 r--p 00000000 08:12 12848718                   /usr/lib/x86_64-linux-gnu/libc.so.6
7f914db44000-7f914dcd9000 r-xp 00028000 08:12 12848718                   /usr/lib/x86_64-linux-gnu/libc.so.6
7f914dcd9000-7f914dd31000 r--p 001bd000 08:12 12848718                   /usr/lib/x86_64-linux-gnu/libc.so.6
7f914dd31000-7f914dd32000 ---p 00215000 08:12 12848718                   /usr/lib/x86_64-linux-gnu/libc.so.6
7f914dd32000-7f914dd36000 r--p 00215000 08:12 12848718                   /usr/lib/x86_64-linux-gnu/libc.so.6
7f914dd36000-7f914dd38000 rw-p 00219000 08:12 12848718                   /usr/lib/x86_64-linux-gnu/libc.so.6
7f914dd38000-7f914dd45000 rw-p 00000000 00:00 0 
7f914dd5e000-7f914dd60000 rw-p 00000000 00:00 0 
7f914dd60000-7f914dd62000 r--p 00000000 08:12 12848709                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f914dd62000-7f914dd8c000 r-xp 00002000 08:12 12848709                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f914dd8c000-7f914dd97000 r--p 0002c000 08:12 12848709                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f914dd98000-7f914dd9a000 r--p 00037000 08:12 12848709                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7f914dd9a000-7f914dd9c000 rw-p 00039000 08:12 12848709                   /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffed5856000-7ffed5877000 rw-p 00000000 00:00 0                          [stack]
7ffed596e000-7ffed5972000 r--p 00000000 00:00 0                          [vvar]
7ffed5972000-7ffed5974000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0                  [vsyscall]

# gamebox
Account: [20240318] => 我是张三啊
Role[0] => Level:14, Money: 4926, Pos:[9.00,3.80,3.00]
Role[1] => Level:15, Money: 4915, Pos:[1002.60,1100.00,1206.00]
Role[2] => Level:17, Money: 4925, Pos:[2007.20,2203.20,2401.00]
Role[3] => Level:20, Money: 4992, Pos:[3006.80,3301.40,3609.00]
Cheat: Account [0x7FFF90D18540], ID [0x7FFF90D18558], Name [0x7FFF90D18540], role [7FFF90D18560]
Cheat: Role [0x558CE6B4F6B0], pos_x [0x558CE6B4F6B0], level [0x558CE6B4F6BC], money [558CE6B4F6C0]
Cheat: decMoney [0x558CE63E11C9]

根据地址信息,数据主要存在 heap和stack上,函数存在代码段。

use std::{
    ffi::{CStr, OsString},
    fs::File,
    io::Read,
    os::unix::fs::FileExt,
    path, u8,
};

// 64 bit program
fn main() {
    let pid = find_id_by_name("gamebox");
    println!("gamebox pid: {:?}", pid);
    if pid.len() > 0 {
        let memf = format!("/proc/{}/mem", pid.first().unwrap());
        println!("gamebox mem: {}", memf);
        // 需要root权限
        if let Ok(f) = &File::open(memf) {
            let base_addr:u64 = 0x7FFF90D18540;
            // account
            let id = read_at_mem(f, base_addr + 24, 8);
            let name = read_at_mem(f, base_addr + 0, 20);
            let role_addr = read_at_mem(f, base_addr + 24 + 8, 8);

            let name2 = unsafe { CStr::from_ptr(name.as_ptr() as *const i8) };
            let role_addr2 = u64::from_le_bytes(role_addr.try_into().unwrap());
            println!(
                "Account => ID: {}, Name: {}, role addr: 0x{:#X}",
                u64::from_le_bytes(id.try_into().unwrap()),
                name2.to_str().unwrap(),
                role_addr2,
            );

            // role
            for i in 0..4 {
                let level = read_at_mem(f, role_addr2 + 24*i + 12, 4);
                let money = read_at_mem(f, role_addr2 + 24*i + 16, 8);
                let pos_x = read_at_mem(f, role_addr2 + 24*i + 0, 4);
                let pos_y = read_at_mem(f, role_addr2 + 24*i + 4, 4);
                let pos_z = read_at_mem(f, role_addr2 + 24*i + 8, 4);
                println!(
                    "  Role[{}] => Level: {}, Money: {}, Pos: [{:.2}, {:.2}, {:.2}]",
                    i,
                    u32::from_le_bytes(level.try_into().unwrap()),
                    u64::from_le_bytes(money.try_into().unwrap()),
                    f32::from_le_bytes(pos_x.try_into().unwrap()),
                    f32::from_le_bytes(pos_y.try_into().unwrap()),
                    f32::from_le_bytes(pos_z.try_into().unwrap()),
                );
            }

        } else {
            println!("need root permission!");
        }
    }
}

// 读取指定大小的内存数据
fn read_at_mem(f: &File, addr: u64, sz: usize) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.resize(sz, 0);
    f.read_at(&mut buf, addr).unwrap();
    return buf;
}

// 忽略大小写进行进程查找
fn find_id_by_name(name: &str) -> Vec<String> {
    // 遍历和读取 /proc/xxx/comm
    let root = path::Path::new("/proc/");
    let mut pids: Vec<_> = Vec::new();
    for sub in root.read_dir().unwrap() {
        if let Ok(id_dir) = sub {
            let fp = id_dir.path();
            if fp.is_dir() {
                let comm = fp.join("comm");
                if comm.exists() {
                    let txt = file_read_content(comm.to_str().unwrap());
                    // println!("try file: {:?}, {}", comm, txt);
                    if txt.to_ascii_lowercase() == name.to_ascii_lowercase() {
                        let pid = OsString::from(fp.file_name().unwrap())
                            .into_string()
                            .unwrap();
                        pids.push(pid);
                    }
                }
            }
        }
    }
    pids
}

fn file_read_content(filepath: &str) -> String {
    let mut txt = String::new();
    if let Ok(mut f) = File::open(filepath) {
        f.read_to_string(&mut txt).unwrap();
    }
    // 默认内容有换行
    txt = txt.trim().to_string();
    return txt;
}

2.寻找基地址,真正外挂

基地址的本质是全局变量,所以我们原本的代码无法实现。

修改一下, 重新编译:

struct Account ac;

void main(){
    printf("GameBoy tester!\r\n");   
    // struct Account ac; // 移动这个到全局变量
    strcpy(ac.name, "我是张三啊");
    ac.ID = 20240318;
    ...
}

另外,程序每次加载到内存中并不一定是固定地址的。

  • 在Windows系统中,xp,win7时代是这样,每次固定加载在固定的地址上,所以每次程序运行的地址都是固定的。
  • 到了后来,windows系统和Linux都使用了动态基地址的flag, 每次的初始地址不固定,所以寻找基地址变得麻烦。

思路:

  • 程序加载到内存时,有导出表或者maps信息,可以看到code的初始地址。
  • main函数相对程序初始地址一般是固定的,与第一步地址一起计算可以得到main的动态地址。
  • 程序全局变量等的地址,相对main是固定的,所以就可以动态计算出来当前变量的地址。(即动态的基地址)

Linux上的获取基地址方法:

  1. 启动gamebox得到全局变量偏移信息
Account: [20240318] => 我是张三啊
Role[0] => Level:10, Money: 5000, Pos:[0.00,0.00,0.00]
Role[1] => Level:12, Money: 5000, Pos:[1000.00,1100.00,1200.00]
Role[2] => Level:14, Money: 5000, Pos:[2000.00,2200.00,2400.00]
Role[3] => Level:16, Money: 5000, Pos:[3000.00,3300.00,3600.00]
Cheat: Account [0x55AF86AE7040], ID [0x55AF86AE7058], Name [0x55AF86AE7040], role [55AF86AE7060]
Cheat: Role [0x55AF87D676B0], pos_x [0x55AF87D676B0], level [0x55AF87D676BC], money [55AF87D676C0]
Cheat: decMoney [0x55AF86AE41C9], main: {0x55AF86AE421E}

所以,Account Offset = Account[0x55AF86AE7040] - main[0x55AF86AE421E] = 0x2E22

  1. 通过gamebox进程maps信息获取main偏移
55af86ae3000-55af86ae4000 r--p 00000000 08:12 6160449               /home/andy/Work/rs_prj/d8/C/gamebox
55af86ae4000-55af86ae5000 r-xp 00001000 08:12 6160449               /home/andy/Work/rs_prj/d8/C/gamebox
55af86ae5000-55af86ae6000 r--p 00002000 08:12 6160449               /home/andy/Work/rs_prj/d8/C/gamebox
55af86ae6000-55af86ae7000 r--p 00002000 08:12 6160449               /home/andy/Work/rs_prj/d8/C/gamebox
55af86ae7000-55af86ae8000 rw-p 00003000 08:12 6160449               /home/andy/Work/rs_prj/d8/C/gamebox
55af87d67000-55af87d88000 rw-p 00000000 00:00 0                     [heap]

所以,main Offset = main[0x55AF86AE421E] - gamebox[0x55af86ae3000] = 0x121E

  1. 计算基地址偏移

BaseAddr = gamebox 基地址 + 0x121E + 0x2E22

  1. Code
use std::{
    ffi::{CStr, OsString},
    fs::File,
    io::{BufRead, BufReader, Cursor, Read},
    os::unix::fs::FileExt,
    path, u8,
};

// 64 bit program
fn main() {
    let pids = find_id_by_name("gamebox");
    println!("gamebox pid: {:?}", pids);
    if pids.len() > 0 {
        let pid = pids.first().unwrap();
        let memf = format!("/proc/{}/mem", pid);
        let mut base_addr:u64 = process_get_base_addr(pid);
        if base_addr == 0 {
            println!("gamebox find base addr failed: {}", pid);
            return;
        }
        base_addr += 0x121E + 0x2E22; // Account ac 全局变量偏移

        println!("gamebox mem: {}, base addr: {:X}", memf, base_addr);
        // 需要root权限
        if let Ok(f) = &File::open(memf) {            
            // account
            let id = read_at_mem(f, base_addr + 24, 8);
            let name = read_at_mem(f, base_addr + 0, 20);
            let role_addr = read_at_mem(f, base_addr + 24 + 8, 8);

            let name2 = unsafe { CStr::from_ptr(name.as_ptr() as *const i8) };
            let role_addr2 = u64::from_le_bytes(role_addr.try_into().unwrap());
            println!(
                "Account => ID: {}, Name: {}, role addr: 0x{:#X}",
                u64::from_le_bytes(id.try_into().unwrap()),
                name2.to_str().unwrap(),
                role_addr2,
            );

            // role
            for i in 0..4 {
                let level = read_at_mem(f, role_addr2 + 24*i + 12, 4);
                let money = read_at_mem(f, role_addr2 + 24*i + 16, 8);
                let pos_x = read_at_mem(f, role_addr2 + 24*i + 0, 4);
                let pos_y = read_at_mem(f, role_addr2 + 24*i + 4, 4);
                let pos_z = read_at_mem(f, role_addr2 + 24*i + 8, 4);
                println!(
                    "\tRole[{}] => Level: {}, Money: {}, Pos: [{:.2}, {:.2}, {:.2}]",
                    i,
                    u32::from_le_bytes(level.try_into().unwrap()),
                    u64::from_le_bytes(money.try_into().unwrap()),
                    f32::from_le_bytes(pos_x.try_into().unwrap()),
                    f32::from_le_bytes(pos_y.try_into().unwrap()),
                    f32::from_le_bytes(pos_z.try_into().unwrap()),
                );
            }

        } else {
            println!("need root permission!");
        }
    }
}

fn read_at_mem(f: &File, addr: u64, sz: usize) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.resize(sz, 0);
    f.read_at(&mut buf, addr).unwrap();
    return buf;
}

// 忽略大小写进行进程查找
fn find_id_by_name(name: &str) -> Vec<String> {
    // 遍历和读取 /proc/xxx/comm
    let root = path::Path::new("/proc/");
    let mut pids: Vec<_> = Vec::new();
    for sub in root.read_dir().unwrap() {
        if let Ok(id_dir) = sub {
            let fp = id_dir.path();
            if fp.is_dir() {
                let comm = fp.join("comm");
                if comm.exists() {
                    let txt = file_read_content(comm.to_str().unwrap());
                    // println!("try file: {:?}, {}", comm, txt);
                    if txt.to_ascii_lowercase() == name.to_ascii_lowercase() {
                        let pid = OsString::from(fp.file_name().unwrap())
                            .into_string()
                            .unwrap();
                        pids.push(pid);
                    }
                }
            }
        }
    }
    pids
}

fn file_read_content(filepath: &str) -> String {
    let mut txt = String::new();
    if let Ok(mut f) = File::open(filepath) {
        f.read_to_string(&mut txt).unwrap();
    }
    // 默认内容有换行
    txt = txt.trim().to_string();
    return txt;
}

// 通过 /proc/pid/maps 查找 program 地址
fn process_get_base_addr(pid:&str) -> u64{
    let mut addr = 0u64;
    if let Ok(f) = File::open(format!("/proc/{}/maps", pid)){
        let mut txt = String::new();
        let mut br = BufReader::new(f);
        br.read_line(&mut txt).unwrap();
        
        //55af86ae3000-55af86ae4000 r--p 00000000 08:12 6160449      /xx/gamebox        
        if let Some(pos) = txt.find('-') {
            addr = u64::from_str_radix(&txt[..pos], 16).unwrap();
        }
    }
    return addr;
}
cargo build
sudo ./target/debug/d8

gamebox pid: ["7586"]
gamebox mem: /proc/7586/mem, base addr: 55AF86AE7040
Account => ID: 20240318, Name: 我是张三啊, role addr: 0x0x55AF87D676B0
        Role[0] => Level: 10, Money: 5000, Pos: [0.00, 0.00, 0.00]
        Role[1] => Level: 12, Money: 5000, Pos: [1000.00, 1100.00, 1200.00]
        Role[2] => Level: 14, Money: 5000, Pos: [2000.00, 2200.00, 2400.00]
        Role[3] => Level: 16, Money: 5000, Pos: [3000.00, 3300.00, 3600.00]

然后,尝试关闭gamebox,然后再重启,继续使用程序读取一下,依然有效。

到此,一个只读取gamebox的外挂做好了。

3.修改程序调用逻辑

如果,我们想把代码中decMoney函数的减少钱的逻辑,改成增加钱的逻辑怎么办?

  • root权限,强制改写 decMoney 函数的汇编代码,可以实现简单功能
  • root权限,在内存中新写一个 函数, 替代decMoney,实现任意功能。这个难度很高。

我们做一个简单的版本:

// decMoney 函数汇编代码
.text:00000000000011C9                               public decMoney
.text:00000000000011C9                               decMoney proc near            ; CODE XREF: main+4E3↓p
.text:00000000000011C9                                                             ; DATA XREF: main+68E↓o
.text:00000000000011C9
.text:00000000000011C9                               var_8= qword ptr -8
.text:00000000000011C9
.text:00000000000011C9                               ; __unwind {
.text:00000000000011C9 F3 0F 1E FA                   endbr64
.text:00000000000011CD 55                            push    rbp
.text:00000000000011CE 48 89 E5                      mov     rbp, rsp
.text:00000000000011D1 48 83 EC 10                   sub     rsp, 10h
.text:00000000000011D5 48 89 7D F8                   mov     [rbp+var_8], rdi
.text:00000000000011D9 B8 00 00 00 00                mov     eax, 0
.text:00000000000011DE E8 ED FE FF FF                call    _rand
.text:00000000000011DE
.text:00000000000011E3 48 63 D0                      movsxd  rdx, eax
.text:00000000000011E6 48 69 D2 1F 85 EB 51          imul    rdx, 51EB851Fh
.text:00000000000011ED 48 C1 EA 20                   shr     rdx, 20h
.text:00000000000011F1 C1 FA 05                      sar     edx, 5
.text:00000000000011F4 89 C1                         mov     ecx, eax
.text:00000000000011F6 C1 F9 1F                      sar     ecx, 1Fh
.text:00000000000011F9 29 CA                         sub     edx, ecx
.text:00000000000011FB 6B CA 64                      imul    ecx, edx, 64h ; 'd'
.text:00000000000011FE 29 C8                         sub     eax, ecx 
.text:0000000000001200 89 C2                         mov     edx, eax
.text:0000000000001202 48 8B 45 F8                   mov     rax, [rbp+var_8]
.text:0000000000001206 48 8B 40 10                   mov     rax, [rax+10h]
.text:000000000000120A 48 63 CA                      movsxd  rcx, edx
.text:000000000000120D 48 29 C8                      sub     rax, rcx ; 核心是这里减法处理 
.text:0000000000001210 48 89 C2                      mov     rdx, rax ; v1
.text:0000000000001213 48 8B 45 F8                   mov     rax, [rbp+var_8]
.text:0000000000001217 48 89 50 10                   mov     [rax+10h], rdx ; 最终v1赋值
.text:000000000000121B 90                            nop
.text:000000000000121C C9                            leave
.text:000000000000121D C3                            retn
.text:000000000000121D                               ; } // starts at 11C9
.text:000000000000121D
.text:000000000000121D                               decMoney endp

// 反汇编的C代码
__int64 __fastcall decMoney(__int64 a1)
{
  __int64 v1; // rdx
  __int64 result; // rax

  v1 = *(_QWORD *)(a1 + 16) - rand() % 100;  // 需要修改这里的减法操作
  result = a1;
  *(_QWORD *)(a1 + 16) = v1;
  return result;
}

函数 DecMoney 的偏移为 00000000000011C9,sub rax, rcx的偏移为 000000000000120D。offset=0x44.

; https://shell-storm.org/online/Online-Assembler-and-Disassembler/
sub rax, rcx  ; 48 29 c8
add rax, rcx  ; 48 01 c8

所以,修改 函数地址加偏移 0x44 + 1 的值, 从 29 改为 01 就可以啦。

fn_ofset = 0x55AF86AE41C9 − 0x55AF86AE421E = -0x55

说明函数地址在main函数地址之前。

fn_addr = program + 0x121E - 0x55

理论代码如下:

use std::{
    ffi::{CStr, OsString},
    fs::File,
    io::{self, BufRead, BufReader, Cursor, Read},
    os::unix::fs::FileExt,
    path, u8,
};

// 64 bit program
fn main() {
    let pids = find_id_by_name("gamebox");
    println!("gamebox pid: {:?}", pids);
    if pids.len() > 0 {
        let pid = pids.first().unwrap();
        let memf = format!("/proc/{}/mem", pid);
        let mut fn_addr = 0u64;
        let mut base_addr:u64 = process_get_base_addr(pid);
        if base_addr == 0 {
            println!("gamebox find base addr failed: {}", pid);
            return;
        }
        fn_addr = base_addr + 0x121E - 0x55;  // decMoney 地址
        base_addr += 0x121E + 0x2E22; // Account ac 全局变量偏移
        
        println!("gamebox mem: {}, base addr: {:X}", memf, base_addr);
        // 需要root权限
        if let Ok(f) = &File::options().write(true).open(memf) {            
            // account
            let id = read_at_mem(f, base_addr + 24, 8);
            let name = read_at_mem(f, base_addr + 0, 20);
            let role_addr = read_at_mem(f, base_addr + 24 + 8, 8);

            let name2 = unsafe { CStr::from_ptr(name.as_ptr() as *const i8) };
            let role_addr2 = u64::from_le_bytes(role_addr.try_into().unwrap());
            println!(
                "Account => ID: {}, Name: {}, role addr: 0x{:#X}",
                u64::from_le_bytes(id.try_into().unwrap()),
                name2.to_str().unwrap(),
                role_addr2,
            );

            // role
            for i in 0..4 {
                let level = read_at_mem(f, role_addr2 + 24*i + 12, 4);
                let money = read_at_mem(f, role_addr2 + 24*i + 16, 8);
                let pos_x = read_at_mem(f, role_addr2 + 24*i + 0, 4);
                let pos_y = read_at_mem(f, role_addr2 + 24*i + 4, 4);
                let pos_z = read_at_mem(f, role_addr2 + 24*i + 8, 4);
                println!(
                    "\tRole[{}] => Level: {}, Money: {}, Pos: [{:.2}, {:.2}, {:.2}]",
                    i,
                    u32::from_le_bytes(level.try_into().unwrap()),
                    u64::from_le_bytes(money.try_into().unwrap()),
                    f32::from_le_bytes(pos_x.try_into().unwrap()),
                    f32::from_le_bytes(pos_y.try_into().unwrap()),
                    f32::from_le_bytes(pos_z.try_into().unwrap()),
                );
            }
            let mut buf = String::new();
            print!("Input any key to modify decMoney function ....");
            io::stdin().read_line(&mut buf).unwrap();

            // modify function decMoney
            let asm_addr = fn_addr + 0x45; 
            // sub rax, rcx => add rax, rcx
            let asm = [0x01u8,];
            f.write_at(&asm, asm_addr).unwrap();

        } else {
            println!("need root permission!");
        }
    }
}

fn read_at_mem(f: &File, addr: u64, sz: usize) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.resize(sz, 0);
    f.read_at(&mut buf, addr).unwrap();
    return buf;
}

// 忽略大小写进行进程查找
fn find_id_by_name(name: &str) -> Vec<String> {
    // 遍历和读取 /proc/xxx/comm
    let root = path::Path::new("/proc/");
    let mut pids: Vec<_> = Vec::new();
    for sub in root.read_dir().unwrap() {
        if let Ok(id_dir) = sub {
            let fp = id_dir.path();
            if fp.is_dir() {
                let comm = fp.join("comm");
                if comm.exists() {
                    let txt = file_read_content(comm.to_str().unwrap());
                    // println!("try file: {:?}, {}", comm, txt);
                    if txt.to_ascii_lowercase() == name.to_ascii_lowercase() {
                        let pid = OsString::from(fp.file_name().unwrap())
                            .into_string()
                            .unwrap();
                        pids.push(pid);
                    }
                }
            }
        }
    }
    pids
}

fn file_read_content(filepath: &str) -> String {
    let mut txt = String::new();
    if let Ok(mut f) = File::open(filepath) {
        f.read_to_string(&mut txt).unwrap();
    }
    // 默认内容有换行
    txt = txt.trim().to_string();
    return txt;
}

// 通过 /proc/pid/maps 查找 program 地址
fn process_get_base_addr(pid:&str) -> u64{
    let mut addr = 0u64;
    if let Ok(f) = File::open(format!("/proc/{}/maps", pid)){
        let mut txt = String::new();
        let mut br = BufReader::new(f);
        br.read_line(&mut txt).unwrap();
        
        //55af86ae3000-55af86ae4000 r--p 00000000 08:12 6160449      /xx/gamebox        
        if let Some(pos) = txt.find('-') {
            addr = u64::from_str_radix(&txt[..pos], 16).unwrap();
        }
    }
    return addr;
}

看起来似乎可行,实际执行发现,即便是root权限也无法直接修改其他进程的内存信息。

其他思路有:

  • ptrace 接口,dbg方式修改
  • 内核空间修改
  • 能找到对应的物理内存地址,修改物理内存

上面几种方法,只有第一种难度最低,后续研究这种。

4.ptrace 版本

ptrace 提供了一种机制使得父进程可以观察和控制子进程的执行过程,ptrace 还可以检查和修改子进程的可执行文件在内存中的image及子进程所使用的寄存器中的值。通常来说,主要用于实现对进程插入断点和跟踪子进程的系统调用。

我们在/proc/xxx/mem 可以root读取,但是写入失败。但是ptrace可以实现这个写入功能。

long ptrace(enum __ptrace_request request,pid_t pid,void *addr,void *data);
/*
PTRACE_TRACEME,   本进程被其父进程所跟踪。其父进程应该希望跟踪子进程
PTRACE_PEEKTEXT,  从内存地址中读取一个LONG长度数据,内存地址由addr给出
PTRACE_PEEKDATA,  同上
PTRACE_PEEKUSER,  可以检查用户态内存区域(USER area),从USER区域中读取一个字节,偏移量为addr
PTRACE_POKETEXT,  往内存地址中写入一个LONG长度数据。内存地址由addr给出
PTRACE_POKEDATA,  往内存地址中写入一个LONG长度数据。内存地址由addr给出
PTRACE_POKEUSER,  往USER区域中写入一个LONG长度数据,偏移量为addr
PTRACE_GETREGS,    读取寄存器
PTRACE_GETFPREGS,  读取浮点寄存器
PTRACE_SETREGS,  设置寄存器
PTRACE_SETFPREGS,  设置浮点寄存器
PTRACE_CONT,    重新运行
PTRACE_SYSCALL,  重新运行
PTRACE_SINGLESTEP,  设置单步执行标志
PTRACE_ATTACH,追踪指定pid的进程
PTRACE_DETACH,  结束追踪
*/

// ptrace Demo 
#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
int main(int argc, char* argv[])
{
	pid_t attack_pid = -1;
	long  val        = 66;
 
	if (argc < 2 || argv[1] <= 0)
	{
		printf("usage: ./main pid(pid > 0)\n");
		return 0;
	}
	attack_pid = strtoul(argv[1], 0, 10);
        if (ptrace(PTRACE_ATTACH, attack_pid, NULL, NULL) < 0)
	{
		printf("attach failed\n");
		return 0;
	}
    //读取数据
	printf("global1 %d\n", ptrace(PTRACE_PEEKDATA , attack_pid, (void*)0x804a028, NULL));
	printf("stack_var %d\n", ptrace(PTRACE_PEEKDATA , attack_pid, (void*)0xbfa4195c, NULL));
    //修改数据
	ptrace(PTRACE_POKEDATA , attack_pid, (void*)0x804a028, val);
	ptrace(PTRACE_POKEDATA , attack_pid, (void*)0xbfa4195c, val);
 
	ptrace (PTRACE_DETACH, attack_pid, NULL, NULL);
	waitpid(attack_pid, NULL, WUNTRACED);
	return 0;
}
 
 
//main.c被改写的进程
#include <stdio.h>
 
int global1 = 11; // 
 
int main(void)
{
  long stack_var = 10;
  char c = 'a';
  while(1)
  {
    printf("global1 addrss 0x%lx, global1=%d\n", &global1, global1);
    printf("stack_var addrss 0x%lx, stack_var=%d\n", &stack_var, stack_var);
    scanf("%c", &c);
    getchar();
    if (c != 'c')
    {
        break;
    }
  }
  return 0;
}

https://dev59.com/unix/s3VD5IYBdhLWcg3wWaRh

完整的Rust版本的ptrace代码如下:

use std::{
    ffi::{c_void, CStr, OsString},
    fs::File,
    io::{self, BufRead, BufReader, Read, Write},
    os::unix::fs::FileExt,
    path, u8,
};

use nix::{sys::{ptrace, wait::waitpid}, unistd::Pid};

// 64 bit program
fn main() {
    let pids = find_id_by_name("gamebox");
    println!("gamebox pid: {:?}", pids);
    if pids.len() > 0 {
        let pid = pids.first().unwrap();
        let mut fn_addr = 0u64;
        let mut base_addr: u64 = process_get_base_addr(pid);
        if base_addr == 0 {
            println!("gamebox find base addr failed: {}", pid);
            return;
        }
        fn_addr = base_addr + 0x121E - 0x55; // decMoney 地址
        base_addr += 0x121E + 0x2E22; // Account ac 全局变量偏移

        println!(
            "gamebox base addr: {:#X}, fn addr: {:#X}",
            base_addr, fn_addr
        );
        // 需要root权限
        let ppid = Pid::from_raw(i32::from_str_radix(pid, 10).unwrap());

        if let Ok(_) = ptrace::attach(ppid) {
            waitpid(ppid, None).unwrap();
        
            // account
            let id = ptrace_read_at(ppid, base_addr + 24, 8);
            let name = ptrace_read_at(ppid, base_addr + 0, 20);
            let role_addr = ptrace_read_at(ppid, base_addr + 24 + 8, 8);

            let name2 = unsafe { CStr::from_ptr(name.as_ptr() as *const i8) };
            let role_addr2 = u64::from_le_bytes(role_addr.try_into().unwrap());
            println!(
                "Account => ID: {}, Name: {:?}, role addr: 0x{:#X}",
                u64::from_le_bytes(id.try_into().unwrap()),
                name2.to_str(),
                role_addr2,
            );

            // role
            for i in 0..4 {
                let level = ptrace_read_at(ppid, role_addr2 + 24 * i + 12, 4);
                let money = ptrace_read_at(ppid, role_addr2 + 24 * i + 16, 8);
                let pos_x = ptrace_read_at(ppid, role_addr2 + 24 * i + 0, 4);
                let pos_y = ptrace_read_at(ppid, role_addr2 + 24 * i + 4, 4);
                let pos_z = ptrace_read_at(ppid, role_addr2 + 24 * i + 8, 4);
                println!(
                    "\tRole[{}] => Level: {}, Money: {}, Pos: [{:.2}, {:.2}, {:.2}]",
                    i,
                    u32::from_le_bytes(level.try_into().unwrap()),
                    u64::from_le_bytes(money.try_into().unwrap()),
                    f32::from_le_bytes(pos_x.try_into().unwrap()),
                    f32::from_le_bytes(pos_y.try_into().unwrap()),
                    f32::from_le_bytes(pos_z.try_into().unwrap()),
                );
            }
            

            // modify function decMoney
            let asm_addr = fn_addr + 0x44;
            // ptrace 每次写入也是 8 字节的数据, 所以先读取,然后再写入
            let mut old_asm = ptrace_read_at(ppid, asm_addr, 8);
            println!("old_asm: {:?}", old_asm);

            // 修改 sub rax, rcx => add rax, rcx
            old_asm[1] = 0x01;

            let new_val = u64::from_ne_bytes(old_asm.try_into().unwrap());
            unsafe { 
                ptrace::write(ppid, asm_addr as *mut c_void, new_val as *mut c_void).unwrap();
             };

            let old_asm2 = ptrace_read_at(ppid, asm_addr, 8);
            println!("old_asm2: {:?}", old_asm2);
            
            ptrace::detach(ppid, None).unwrap();
        } else {
            println!("need root permission!");
        }
    }
}

fn read_at_mem(f: &File, addr: u64, sz: usize) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.resize(sz, 0);
    f.read_at(&mut buf, addr).unwrap();
    return buf;
}

fn ptrace_read_at(ppid: Pid, addr: u64, sz: usize) -> Vec<u8> {
    let mut buf = Vec::new();
    buf.resize(sz, 0);
    // println!("Begin read {:#X}, sz={}", addr, sz);
    let mut sz2 = sz/8;
    
    if sz%8 != 0 {
        sz2 += 1;
    }

    for i in 0..sz2 {
        let paddr = (addr + (i*8) as u64) as *mut c_void;
        // ptrace 每次读取 8 个字节的数据
        let val = ptrace::read(ppid, paddr).unwrap();
        //println!("read: [{}] = {:#X}", addr + (i*8) as u64,val);
        let mut idx = i*8;
        for b in val.to_ne_bytes(){
            if idx < sz {
                buf[idx] = b;
                idx += 1;
            }else{
                break;
            }            
        }
    }
    return buf;
}

// 忽略大小写进行进程查找
fn find_id_by_name(name: &str) -> Vec<String> {
    // 遍历和读取 /proc/xxx/comm
    let root = path::Path::new("/proc/");
    let mut pids: Vec<_> = Vec::new();
    for sub in root.read_dir().unwrap() {
        if let Ok(id_dir) = sub {
            let fp = id_dir.path();
            if fp.is_dir() {
                let comm = fp.join("comm");
                if comm.exists() {
                    let txt = file_read_content(comm.to_str().unwrap());
                    // println!("try file: {:?}, {}", comm, txt);
                    if txt.to_ascii_lowercase() == name.to_ascii_lowercase() {
                        let pid = OsString::from(fp.file_name().unwrap())
                            .into_string()
                            .unwrap();
                        pids.push(pid);
                    }
                }
            }
        }
    }
    pids
}

fn file_read_content(filepath: &str) -> String {
    let mut txt = String::new();
    if let Ok(mut f) = File::open(filepath) {
        f.read_to_string(&mut txt).unwrap();
    }
    // 默认内容有换行
    txt = txt.trim().to_string();
    return txt;
}

// 通过 /proc/pid/maps 查找 program 地址
fn process_get_base_addr(pid: &str) -> u64 {
    let mut addr = 0u64;
    if let Ok(f) = File::open(format!("/proc/{}/maps", pid)) {
        let mut txt = String::new();
        let mut br = BufReader::new(f);
        br.read_line(&mut txt).unwrap();

        //55af86ae3000-55af86ae4000 r--p 00000000 08:12 6160449      /xx/gamebox
        if let Some(pos) = txt.find('-') {
            addr = u64::from_str_radix(&txt[..pos], 16).unwrap();
        }
    }
    return addr;
}

/*
gamebox pid: ["101595"]
gamebox base addr: 0x55698A849040, fn addr: 0x55698A8461C9
Account => ID: 20240318, Name: Ok("我是张三啊"), role addr: 0x0x55698B1156B0
        Role[0] => Level: 10, Money: 5000, Pos: [0.00, 0.00, 0.00]
        Role[1] => Level: 12, Money: 5000, Pos: [1000.00, 1100.00, 1200.00]
        Role[2] => Level: 14, Money: 5000, Pos: [2000.00, 2200.00, 2400.00]
        Role[3] => Level: 16, Money: 5000, Pos: [3000.00, 3300.00, 3600.00]
old_asm: [72, 41, 200, 72, 137, 194, 72, 139]
Input any key to modify decMoney function ....
old_asm2: [72, 1, 200, 72, 137, 194, 72, 139]
*/

proc

https://blog.csdn.net/murphy_ma123456/article/details/16117577
https://blog.csdn.net/m0_37315653/article/details/82693108
https://zhuanlan.zhihu.com/p/378388389

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/499264.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Nodejs 16与 gitbook搭建属于你自己的书本网站-第一篇

最近想重新搭建一个网站来存放自己的相关知识点&#xff0c;并向网络公开&#xff0c;有个hexo博客其实也不错的&#xff0c;但是总感觉hexo很多花里胡哨的玩意&#xff0c;导致挂载的博客异常卡&#xff0c;这样反而不利于我自己回顾博客了&#xff0c;于是我就开始钻研这个鬼…

Android逆向-数据修改逻辑修改视图修改

目录 0x00 相关工具及环境 0x01 APP逆向 - 数据修改 0x02 APP逆向 - 逻辑修改 0x03 APP逆向 - 视图修改 希望和各位大佬一起学习&#xff0c;如果文章内容有错请多多指正&#xff0c;谢谢&#xff01; 个人博客链接&#xff1a;CH4SER的个人BLOG – Welcome To Ch4sers B…

Git Fork后的仓库内容和原仓库保持一致

Git Fork后的仓库内容和原仓库保持一致 ①Fork原仓库内容到自己仓库 ②将项目内容下载到本地 ③使用git命令获取原仓库内容&#xff0c;将原仓库的最新内容合并到自己的分支上并推送 下面从第三步开始演示~ 这里以码云上的若依项目为演示项目 ③使用git命令获取原仓库内容 …

什么裤型的裤子最百搭?男生比较好看的裤子品牌分享

很多男生每隔一段都会选择一些新的裤子&#xff0c;但是现在市面上的裤子种类和风格太多&#xff0c;并且有不少材质劣质、细节设计差的品牌混杂在其中&#xff0c;大家一不小心就选到质量不好的裤子。 所以如何选择到合适、质量好的裤子确实是一个让人头疼的问题&#xff0c;…

AcWing 4609:火柴棍数字 ← 贪心算法

【题目来源】 https://www.acwing.com/problem/content/4612/【题目描述】 给定 n 个火柴棍&#xff0c;你可以用它们摆出数字 0∼9。 摆出每个数字所需要的具体火柴棍数量如下图所示&#xff1a; 请你用这些火柴棍摆成若干个数字&#xff0c;并把这些数字排成一排组成一个整数…

Netty学习——源码篇5 EventLoop 备份

1 Reactor线程模型 Reactor线程模型 中对Reactor的三种线程模型——单线程模型、多线程模型、主从多线程模型做了介绍&#xff0c;这里具体分析Reactor在Netty中的应用。 1.1单线程模型 单线程模型处理流程如下图&#xff1a; 单线程模型&#xff0c;即Accept的处理和Handler…

(科研篇)如何做科研

1.科研周期&#xff1a; 2.CCF列表 1.搜索论文&#xff08;顶会&#xff09; 2.谷歌学术检索 3.如何阅读文献 最重要的部分是abstract introduction 和related work&#xff0c;要明白某个东西的历史&#xff0c;从而进一步发现的缺陷&#xff0c;然后通过实现实验去证明。 通…

HubSpot出海CRM的团队协作与流程优化

在数字化营销日益盛行的今天&#xff0c;团队协作与流程优化已成为企业获取竞争优势的关键因素。HubSpot出海CRM不仅提供了强大的客户管理工具&#xff0c;更在团队协作与流程优化方面展现出卓越的能力。 一、团队协作在营销中的重要性 团队协作在营销中的重要性不言而喻。一…

光伏智慧管理平台:全周期全流程光伏业务管理

随着光伏技术的快速发展和光伏电站规模的不断扩大&#xff0c;光伏业务的管理变得越来越复杂。为了提高管理效率、降低运营成本并提升光伏电站的运行效益&#xff0c;光伏智慧管理平台应运而生。本文将重点介绍光伏智慧管理平台的功能及其在全周期全流程光伏业务管理中的应用。…

最长有效括号(C语言)

题目链接&#xff1a;. - 力扣&#xff08;LeetCode&#xff09; 这道题&#xff0c;我看了一种解法&#xff0c;觉得很好&#xff0c;来分享一下 这道题主要是 思考 当前 ) 与之匹配 ( 在哪里 &#xff0c;记录下来&#xff0c;最后比较最大值 例子&#xff1a; 第…

浅谈 kafka

引言 同事在公司内部分享了关于 kafka 技术一些相关的内容&#xff0c;所以有了这篇文章&#xff1b;部分图片选自网络摘抄&#xff1b; 1 Kafka概述 1.1 定义 Kafka传统定义&#xff1a;kafka是一个分布式的基于发布/订阅模式的消息队列。 Kafka最新定义&#xff1a;kafka…

【Frida】【Android】05_Objection实战

&#x1f6eb; 系列文章导航 【Frida】【Android】01_手把手教你环境搭建 https://blog.csdn.net/kinghzking/article/details/136986950【Frida】【Android】02_JAVA层HOOK https://blog.csdn.net/kinghzking/article/details/137008446【Frida】【Android】03_RPC https://bl…

Kibana操作Elasticsearch教程

文章目录 简介ES文档操作创建索引查看索引创建映射字段查看映射关系字段属性详解typeindexstore 字段映射设置流程 新增数据新增会随机生成id新增自定义id智能判断 修改数据删除数据查询基本查询查询所有&#xff08;match_all&#xff09;匹配查询多字段查询词条匹配多词条精确…

HarmonyOS 应用开发之创建PageAbility

开发者需要重写app.js/app.ets中的生命周期回调函数&#xff0c;开发者通过DevEco Studio开发平台创建PageAbility时&#xff0c;DevEco Studio会在app.js/app.ets中默认生成onCreate()和onDestroy()方法&#xff0c;其他方法需要开发者自行实现。接口说明参见前述章节&#xf…

maven 依赖机制

安全工程师为啥关注maven依赖 log 4j事件之后&#xff0c;大家开始更加关注开源组件安全漏洞这个事。纷纷引入SCA 软件成分分析工具来识别项目中存在的开源组件和漏洞。 在sca工具扫描之后&#xff0c;会报出一大堆组件&#xff0c;review这个事就是安全团队投入时间来研判了…

【Linux多线程】线程的同步与互斥

【Linux多线程】线程的同步与互斥 目录 【Linux多线程】线程的同步与互斥分离线程Linux线程互斥进程线程间的互斥相关背景概念问题产生的原因&#xff1a; 互斥量mutex互斥量的接口互斥量实现原理探究对锁进行封装(C11lockguard锁) 可重入VS线程安全概念常见的线程不安全的情况…

是谁?阻止CXL在AI场景大展身手~

CXL虽然被视为业内新宠&#xff0c;但好像在AI场景的应用反而没有得到广泛的响应。 AI场景对内存带宽、容量以及数据一致性有着极高需求&#xff0c;特别是在深度学习训练和推理过程中&#xff0c;大量数据需要在CPU、GPU、加速器以及内存之间快速、高效地流动。CXL作为一种新…

Java基础入门day24

day24 abstract 抽象&#xff1a;似是而非&#xff0c;像又不是&#xff0c;具备某种对象的特征&#xff0c;但不完整 生活中的抽象&#xff1a;动物&#xff0c;并不真实存在的事物 程序中的抽象&#xff1a;不应该被创建的对象&#xff0c;动物近视一种会吃会睡的对象&#…

Netty核心原理剖析与RPC实践16-20

Netty核心原理剖析与RPC实践16-20 16 IO 加速&#xff1a;与众不同的 Netty 零拷贝技术 今天的课程我们继续讨论 Netty 实现高性能的另一个高阶特性——零拷贝。零拷贝是一个耳熟能详的词语&#xff0c;在 Linux、Kafka、RocketMQ 等知名的产品中都有使用&#xff0c;通常用于…

【单调栈】力扣84.柱状图中最大的矩形

上篇文章我们介绍了使用 无重复值 单调栈代码解决 含有重复值 的问题&#xff0c;在文章的最后&#xff0c;留下了一道考察相同思想的题目&#xff0c;今天我们来看看如何套路解决该题。 &#xff08;还没看过前几篇介绍的小伙伴赶快关注&#xff0c;在 「单调栈」 集合里查看…