-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeed.xml
More file actions
4022 lines (3186 loc) · 483 KB
/
feed.xml
File metadata and controls
4022 lines (3186 loc) · 483 KB
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Storm Spirit</title>
<description>这是我的中文博客,记录自己在技术、生活等方面的一些感想。博客叫 Storm Spirit, 因为我特别喜欢 Dota 里蓝猫这个英雄,可惜玩得不好…… 希望自己能够通过博客,得到成长。
</description>
<link>https://wulfric.me/</link>
<atom:link href="https://wulfric.me/feed.xml" rel="self" type="application/rss+xml" />
<pubDate>Sun, 19 Apr 2026 12:11:48 +0000</pubDate>
<lastBuildDate>Sun, 19 Apr 2026 12:11:48 +0000</lastBuildDate>
<generator>Jekyll v4.4.1</generator>
<rights>wulfric © 2013~2026. All rights reserved.</rights>
<item>
<title>理解 Python 进程注入:从 ptrace 到 PEP 768</title>
<description><p>你有一个正在运行的 Python 进程——也许是一个生产环境中的 Web 服务,也许是一个跑了三小时的数据管道。它变慢了,或者出了某种诡异的 bug。你不能停掉它、不能重启它、不能加 print 语句。</p>
<p>怎么办?</p>
<p>答案是<strong>进程注入</strong>(process injection):在不重启目标进程的前提下,将诊断代码注入到一个正在运行的 Python 解释器中。这正是 <a href="https://github.com/wwulfric/peeka">peeka</a>、<a href="https://github.com/bloomberg/memray">memray</a>、<a href="https://github.com/lmacken/pyrasite">pyrasite</a> 等工具的核心能力。</p>
<p>本文将从底层原理到工程实践,系统地拆解 Python 进程注入的三代技术方案。</p>
<hr />
<h2 id="1-进程注入的本质问题">1. 进程注入的本质问题</h2>
<p>要理解进程注入,先要理解我们面临的约束:</p>
<ul>
<li><strong>地址空间隔离</strong>:每个进程有独立的虚拟地址空间,A 进程无法直接读写 B 进程的内存</li>
<li><strong>GIL(全局解释器锁)</strong>:Python 的 C API 调用几乎都需要持有 GIL</li>
<li><strong>执行时机</strong>:目标进程可能正处于任何状态——malloc 中途、GC 扫描中、持有 import 锁……</li>
</ul>
<p>所以,进程注入需要回答三个问题:</p>
<ol>
<li><strong>如何进入目标进程的地址空间?</strong>(跨进程控制)</li>
<li><strong>如何安全地执行 Python 代码?</strong>(GIL 获取)</li>
<li><strong>何时执行才不会崩溃?</strong>(时机选择)</li>
</ol>
<p>不同的工具对这三个问题给出了不同的答案。</p>
<hr />
<h2 id="2-第一代pyrasite-与-gdb-直接调用-c-api">2. 第一代:pyrasite 与 GDB 直接调用 C API</h2>
<p><a href="https://github.com/lmacken/pyrasite">pyrasite</a>(2011 年)是最早的 Python 进程注入工具之一。它的方案简单直接:</p>
<h3 id="原理">原理</h3>
<p>利用 <span class="codespan">ptrace</span> 系统调用暂停目标进程,然后通过 GDB 直接调用 Python 的 C API 执行任意 Python 代码。</p>
<h3 id="核心代码">核心代码</h3>
<p>pyrasite 的注入逻辑只有几行:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># pyrasite/injector.py(简化)
</span><span class="n">gdb_cmds</span> <span class="o">=</span> <span class="p">[</span>
<span class="sh">'</span><span class="s">call (int) PyGILState_Ensure()</span><span class="sh">'</span><span class="p">,</span>
<span class="sh">'</span><span class="s">call (int) PyRun_SimpleString(</span><span class="sh">"</span><span class="s">exec(open(</span><span class="se">\\</span><span class="sh">'</span><span class="o">%</span><span class="n">s</span>\\<span class="sh">'</span><span class="s">).read())</span><span class="sh">"</span><span class="s">)</span><span class="sh">'</span> <span class="o">%</span> <span class="n">filename</span><span class="p">,</span>
<span class="sh">'</span><span class="s">call (void) PyGILState_Release($1)</span><span class="sh">'</span><span class="p">,</span>
<span class="p">]</span>
<span class="n">subprocess</span><span class="p">.</span><span class="nc">Popen</span><span class="p">(</span>
<span class="sh">'</span><span class="s">gdb -p %d -batch %s</span><span class="sh">'</span> <span class="o">%</span> <span class="p">(</span><span class="n">pid</span><span class="p">,</span> <span class="sh">'</span><span class="s"> </span><span class="sh">'</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span>
<span class="p">[</span><span class="sh">"</span><span class="s">-eval-command=</span><span class="sh">'</span><span class="s">call %s</span><span class="sh">'"</span> <span class="o">%</span> <span class="n">cmd</span> <span class="k">for</span> <span class="n">cmd</span> <span class="ow">in</span> <span class="n">gdb_cmds</span><span class="p">]</span>
<span class="p">)),</span>
<span class="n">shell</span><span class="o">=</span><span class="bp">True</span>
<span class="p">)</span>
</code></pre></div></div>
<p>即:</p>
<ol>
<li>GDB 通过 <span class="codespan">ptrace(PTRACE_ATTACH)</span> 暂停目标进程</li>
<li>在<strong>暂停点</strong>直接调用 <span class="codespan">PyGILState_Ensure()</span> 获取 GIL</li>
<li>调用 <span class="codespan">PyRun_SimpleString()</span> 执行一段 Python 代码</li>
<li>调用 <span class="codespan">PyGILState_Release()</span> 释放 GIL</li>
<li>GDB detach,目标进程恢复执行</li>
</ol>
<h3 id="ptrace-是什么">ptrace 是什么?</h3>
<p><span class="codespan">ptrace</span> 是 Linux 内核提供的进程调试接口。GDB、strace、lldb 的底层都依赖它。</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>调试器进程 目标进程
│ │
│── ptrace(ATTACH, pid) ───&gt;│ 暂停目标
│ │ (SIGSTOP)
│── ptrace(PEEKTEXT) ──────&gt;│ 读内存
│── ptrace(POKETEXT) ──────&gt;│ 写内存
│── ptrace(GETREGS) ───────&gt;│ 读寄存器
│── ptrace(SETREGS) ───────&gt;│ 改寄存器 -&gt; 修改执行流
│── ptrace(CONT) ──────────&gt;│ 恢复执行
│── ptrace(DETACH) ────────&gt;│ 脱离
</code></pre></div></div>
<p>GDB 正是利用 ptrace 的能力,在目标进程的上下文中"调用"C 函数。具体来说,GDB 每执行一条 <span class="codespan">call</span> 命令,都会经历以下 6 步:</p>
<ol>
<li>保存目标进程当前的寄存器状态</li>
<li>将函数参数写入寄存器/栈(遵循 ABI 调用约定)</li>
<li>将指令指针(<span class="codespan">rip</span>)设为目标函数地址</li>
<li>设置断点用于在函数返回后接管控制</li>
<li>恢复执行</li>
<li>函数执行完毕后,GDB 命中断点,恢复原始寄存器</li>
</ol>
<p>也就是说,pyrasite 的三条 GDB 命令(<span class="codespan">call PyGILState_Ensure()</span>、<span class="codespan">call PyRun_SimpleString(...)</span>、<span class="codespan">call PyGILState_Release($1)</span>)各自独立地经历这 6 步,而非 6 步整体对应 3 个函数。每次 <span class="codespan">call</span> 都是一次完整的"保存-&gt;篡改-&gt;执行-&gt;恢复"循环。</p>
<h3 id="为什么这个方案危险">为什么这个方案危险?</h3>
<p>问题出在<strong>时机</strong>上。当 GDB attach 并暂停目标进程时,目标可能正处于任何状态:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>目标进程的执行时间线:
... -&gt; malloc() -&gt; [GDB 在这里暂停] -&gt; PyGILState_Ensure() -&gt; 💥
问题:malloc 内部持有 heap lock
PyRun_SimpleString 可能也要调 malloc
-&gt; 死锁
</code></pre></div></div>
<p>更具体地说:</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>暂停时的状态</th>
<th>调用 C API 的后果</th>
</tr>
</thead>
<tbody>
<tr>
<td>malloc/free 中途</td>
<td>堆锁重入 -&gt; 死锁或内存损坏</td>
</tr>
<tr>
<td>GC 扫描中</td>
<td>对象引用计数不一致 -&gt; 段错误</td>
</tr>
<tr>
<td>持有 GIL</td>
<td><span class="codespan">PyGILState_Ensure()</span> 死锁</td>
</tr>
<tr>
<td>持有 import 锁</td>
<td>注入代码中的 import 死锁</td>
</tr>
</tbody>
</table>
</div>
<p>pyrasite 本质上是在"碰运气"——大多数时候目标进程不在这些危险状态,所以注入成功。但在生产环境中,这种不确定性是不可接受的。</p>
<hr />
<h2 id="3-第二代memraypeeka-的-dlopen--pthread-方案">3. 第二代:memray/peeka 的 dlopen + pthread 方案</h2>
<p><a href="https://github.com/bloomberg/memray">memray</a>(Bloomberg 开源的内存分析器)和 <a href="https://github.com/wwulfric/peeka">peeka</a> 在 Python 3.8-3.13 上采用了相似的改进方案。核心思路是:<strong>不在 ptrace 暂停点直接执行 Python 代码,而是注入一个 C 扩展,由 C 扩展在安全的时机执行代码</strong>。</p>
<h3 id="原理概览">原理概览</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>调试器(GDB/LLDB) 目标进程
│ │
│── ptrace ATTACH ────────────&gt;│ 暂停
│── 等待安全断点命中 ────────────&gt;│ malloc/PyMem_* 返回后
│ │
│── dlopen("_inject.so") ─────&gt;│ 加载 C 扩展到目标地址空间
│── call peeka_spawn_agent() ─&gt;│ 创建新 pthread
│── ptrace DETACH ────────────&gt;│ 恢复执行
│ │
│ │ [新 pthread]
│ │ ├─ connect() 回连调试器
│ │ ├─ recv() 接收 agent 脚本
│ │ ├─ PyGILState_Ensure() ← 等待安全时机
│ │ ├─ PyEval_EvalCode() 执行 agent
│ │ └─ PyGILState_Release()
</code></pre></div></div>
<p>关键改进有三点:<strong>安全断点</strong>、<strong>dlopen 注入</strong>、<strong>独立线程</strong>。</p>
<h3 id="31-安全断点选择正确的时机">3.1 安全断点——选择正确的时机</h3>
<p>pyrasite 在<strong>任意位置</strong>暂停后就执行 C API,而 memray/peeka 等到<strong>安全位置</strong>才注入。</p>
<p>以 peeka 的 GDB 脚本为例:</p>
<pre><code class="language-gdb"># peeka/core/_attach.gdb
b malloc
b calloc
b realloc
b free
b PyMem_Malloc
b PyMem_Calloc
b PyMem_Realloc
b PyMem_Free
b PyErr_CheckSignals
b PyCallable_Check
commands 1-10
disable breakpoints
delete breakpoints
call (void*)dlopen($peeka_injector, $peeka_rtld_now)
call (int)peeka_spawn_agent($peeka_port)
end
continue
</code></pre>
<p>解读:</p>
<ol>
<li>在 <span class="codespan">malloc</span>、<span class="codespan">PyMem_Malloc</span> 等函数的<strong>入口</strong>设置断点</li>
<li>调用 <span class="codespan">Py_AddPendingCall(&amp;PyCallable_Check, 0)</span> 安排一个 pending call(确保 CPython 的 eval loop 会调用 <span class="codespan">PyCallable_Check</span>,从而命中我们的断点)</li>
<li>恢复执行(<span class="codespan">continue</span>),等待目标进程<strong>自然地</strong>调用这些函数</li>
<li>断点命中时,我们知道当前位置是<strong>函数入口</strong>——没有持有 heap lock、没有处于 GC 中途</li>
<li>此时再执行 dlopen 和 spawn</li>
</ol>
<p>这比 pyrasite 安全得多:我们不是在任意位置注入,而是在一个已知的安全点。</p>
<blockquote>
<p><strong>Py_AddPendingCall 的作用</strong>:如果目标进程处于纯 Python 代码的 tight loop 中(不调用任何 C 函数),我们的 malloc/PyMem 断点可能永远不会命中。<span class="codespan">Py_AddPendingCall</span> 注册的回调会在 Python eval loop 的下一个"检查点"被调用,确保断点一定会触发。</p>
</blockquote>
<h3 id="32-dlopen将-c-扩展加载到目标进程">3.2 dlopen——将 C 扩展加载到目标进程</h3>
<p><span class="codespan">dlopen</span> 是 POSIX 系统的动态链接器接口。通过 GDB 在目标进程中调用 <span class="codespan">dlopen("_inject.abi3.so", RTLD_NOW)</span>,我们将一个编译好的 C 扩展加载到目标进程的地址空间中。</p>
<p>这比 <span class="codespan">PyRun_SimpleString</span> 强大的关键在于:<strong>C 代码可以创建线程,可以操作底层系统资源,不受 GIL 约束</strong>。</p>
<p>peeka 在 macOS 上使用 LLDB 完成相同的工作:</p>
<pre><code class="language-lldb"># peeka/core/_attach.lldb
expr auto $dlsym = (void* (*)(void*, const char*))&amp;::dlsym
expr auto $dlopen = $dlsym($rtld_default, "dlopen")
expr auto $dll = ((void*(*)(const char*, int))$dlopen)($libpath, $rtld_now)
expr auto $spawn = $dlsym($dll, "peeka_spawn_agent")
p ((int(*)(int))$spawn)($port) ? "FAILURE" : "SUCCESS"
</code></pre>
<h3 id="33-独立线程解耦注入与执行">3.3 独立线程——解耦注入与执行</h3>
<p>dlopen 之后,GDB 调用 C 扩展的 <span class="codespan">peeka_spawn_agent()</span> 函数。这个函数做了什么?</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// peeka/core/_inject.c(核心逻辑)</span>
<span class="n">__attribute__</span><span class="p">((</span><span class="n">visibility</span><span class="p">(</span><span class="s">"default"</span><span class="p">)))</span>
<span class="kt">int</span> <span class="nf">peeka_spawn_agent</span><span class="p">(</span><span class="kt">int</span> <span class="n">port</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">pthread_t</span> <span class="kr">thread</span><span class="p">;</span>
<span class="k">return</span> <span class="n">pthread_create</span><span class="p">(</span><span class="o">&amp;</span><span class="kr">thread</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">thread_body</span><span class="p">,</span> <span class="p">(</span><span class="kt">void</span><span class="o">*</span><span class="p">)(</span><span class="kt">uintptr_t</span><span class="p">)</span><span class="n">port</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>
<p>它<strong>不执行任何 Python 代码</strong>。它只是创建一个 pthread,然后立即返回。这样 GDB 可以快速 detach,目标进程恢复正常执行。</p>
<p>真正的工作在新线程中完成:</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 新线程的执行流程</span>
<span class="k">static</span> <span class="kt">void</span> <span class="nf">run_client</span><span class="p">(</span><span class="kt">uint16_t</span> <span class="n">port</span><span class="p">)</span>
<span class="p">{</span>
<span class="c1">// 1. 通过 TCP 回连调试器,接收 agent 脚本</span>
<span class="kt">int</span> <span class="n">sock</span> <span class="o">=</span> <span class="n">connect_client</span><span class="p">(</span><span class="n">port</span><span class="p">);</span>
<span class="n">recvall</span><span class="p">(</span><span class="n">sock</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">script</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">script_len</span><span class="p">);</span>
<span class="c1">// 2. 安全地执行 Python 代码</span>
<span class="n">run_script</span><span class="p">(</span><span class="n">script</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">errmsg</span><span class="p">);</span>
<span class="p">}</span>
<span class="k">static</span> <span class="kt">int</span> <span class="nf">run_script</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">script</span><span class="p">,</span> <span class="kt">char</span><span class="o">**</span> <span class="n">errmsg</span><span class="p">)</span>
<span class="p">{</span>
<span class="c1">// 检查 Python 是否已初始化</span>
<span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">Py_IsInitialized</span><span class="p">())</span> <span class="p">{</span>
<span class="o">*</span><span class="n">errmsg</span> <span class="o">=</span> <span class="n">copy_string</span><span class="p">(</span><span class="s">"Python is not initialized"</span><span class="p">);</span>
<span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// 获取 GIL —— 这里会等待,直到安全</span>
<span class="n">PyGILState_STATE</span> <span class="n">gstate</span> <span class="o">=</span> <span class="n">PyGILState_Ensure</span><span class="p">();</span>
<span class="c1">// 编译并执行 agent 脚本</span>
<span class="kt">int</span> <span class="n">ret</span> <span class="o">=</span> <span class="n">run_script_impl</span><span class="p">(</span><span class="n">script</span><span class="p">,</span> <span class="n">errmsg</span><span class="p">);</span>
<span class="n">PyGILState_Release</span><span class="p">(</span><span class="n">gstate</span><span class="p">);</span>
<span class="k">return</span> <span class="n">ret</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">static</span> <span class="kt">int</span> <span class="nf">run_script_impl</span><span class="p">(</span><span class="k">const</span> <span class="kt">char</span><span class="o">*</span> <span class="n">script</span><span class="p">,</span> <span class="kt">char</span><span class="o">**</span> <span class="n">errmsg</span><span class="p">)</span>
<span class="p">{</span>
<span class="c1">// 构建干净的 globals 字典</span>
<span class="n">PyObject</span><span class="o">*</span> <span class="n">builtins</span> <span class="o">=</span> <span class="n">PyImport_ImportModule</span><span class="p">(</span><span class="s">"builtins"</span><span class="p">);</span>
<span class="n">PyObject</span><span class="o">*</span> <span class="n">globals</span> <span class="o">=</span> <span class="n">PyDict_New</span><span class="p">();</span>
<span class="n">PyDict_SetItemString</span><span class="p">(</span><span class="n">globals</span><span class="p">,</span> <span class="s">"__builtins__"</span><span class="p">,</span> <span class="n">builtins</span><span class="p">);</span>
<span class="c1">// 编译 + 执行</span>
<span class="n">PyObject</span><span class="o">*</span> <span class="n">code</span> <span class="o">=</span> <span class="n">Py_CompileString</span><span class="p">(</span><span class="n">script</span><span class="p">,</span>
<span class="s">"_peeka_attach_hook.py"</span><span class="p">,</span>
<span class="n">Py_file_input</span><span class="p">);</span>
<span class="n">PyObject</span><span class="o">*</span> <span class="n">mod</span> <span class="o">=</span> <span class="n">PyEval_EvalCode</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="n">globals</span><span class="p">,</span> <span class="n">globals</span><span class="p">);</span>
<span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div>
<p><strong>为什么独立线程更安全?</strong></p>
<p>在 pyrasite 方案中,<span class="codespan">PyGILState_Ensure()</span> 在 GDB 暂停目标进程时被调用——如果暂停的线程恰好持有 GIL,这里会死锁。</p>
<p>在 dlopen + pthread 方案中:</p>
<ol>
<li>GDB 执行 dlopen -&gt; spawn -&gt; <strong>立刻返回</strong> -&gt; GDB detach -&gt; 目标进程恢复</li>
<li>新线程调用 <span class="codespan">PyGILState_Ensure()</span> 时,目标进程已经在正常运行</li>
<li><span class="codespan">PyGILState_Ensure()</span> 会<strong>等待</strong> GIL 可用,而不是在暂停状态下强行获取</li>
</ol>
<p>这消除了 GIL 死锁的主要原因。</p>
<h3 id="34-反向连接agent-代码的传输">3.4 反向连接——agent 代码的传输</h3>
<p>一个有趣的设计细节是 agent 代码的传输。peeka 不是把代码内容写入 GDB 命令(字符串转义会很痛苦),而是采用<strong>反向连接</strong>:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[1] 调试器启动一个 TCP 服务,等待连接
[2] GDB 将端口号传给 peeka_spawn_agent(port)
[3] C 扩展中的新线程连接这个端口
[4] 调试器将 agent.py 的完整内容通过 TCP 发送过去
[5] C 扩展接收、编译、执行
</code></pre></div></div>
<p>这样做的好处是:agent 代码可以任意长、包含任意字符,不受 GDB 命令行的转义限制。</p>
<h3 id="35-legacy-回退没有-c-扩展时怎么办">3.5 Legacy 回退——没有 C 扩展时怎么办?</h3>
<p>如果 C 扩展不可用(比如没有编译、或者 GLIBC 版本不兼容),peeka 会回退到类似 pyrasite 的方式,但做了改进:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># peeka/core/attach.py — _inject_via_gdb_legacy()
</span><span class="n">bootstrap</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">'</span><span class="s">_c = open(</span><span class="sh">"</span><span class="si">{</span><span class="n">agent_script</span><span class="si">}</span><span class="sh">"</span><span class="s">).read(); exec(_c);</span><span class="sh">'</span>
<span class="n">gdb_commands</span> <span class="o">=</span> <span class="p">[</span>
<span class="sh">'</span><span class="s">call (int) PyGILState_Ensure()</span><span class="sh">'</span><span class="p">,</span>
<span class="sa">f</span><span class="sh">'</span><span class="s">call (int) PyRun_SimpleString(</span><span class="sh">"</span><span class="si">{</span><span class="n">bootstrap</span><span class="si">}</span><span class="sh">"</span><span class="s">)</span><span class="sh">'</span><span class="p">,</span>
<span class="sh">'</span><span class="s">call (void) PyGILState_Release($1)</span><span class="sh">'</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>
<p>通过 <span class="codespan">exec()</span> 执行文件内容而非直接内联代码,减少了转义问题。但本质上仍然有 pyrasite 的时机安全隐患,只是作为最后的回退方案存在。</p>
<hr />
<h2 id="4-第三代pep-768-与-sysremote_exec">4. 第三代:PEP 768 与 sys.remote_exec</h2>
<p>Python 3.14 引入了 <a href="https://peps.python.org/pep-0768/">PEP 768</a>,从<strong>解释器层面</strong>提供了原生的进程注入支持。这是一个根本性的范式转变。</p>
<h3 id="核心思想">核心思想</h3>
<p>前两代方案都是从<strong>外部</strong>强行进入目标进程(通过 ptrace/GDB/LLDB),然后在一个"可能安全"的位置执行代码。PEP 768 的思路则是:<strong>告诉解释器你要执行什么,让解释器自己在安全的时候执行</strong>。</p>
<h3 id="运作机制">运作机制</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>外部进程 目标 Python 解释器
│ │
│ (1) 读 /proc/pid/maps │
│ 定位 PyRuntime 地址 │
│ │
│ (2) 读 _Py_DebugOffsets │
│ 获取内部结构偏移量 │
│ │
│ (3) 写入脚本路径到 │
│ tstate-&gt;debugger_ │
│ script_path │
│ │
│ (4) 设置 pending call 标志 │
│ tstate-&gt;debugger_ │
│ pending_call = 1 │
│ │
│ (5) 设置 eval breaker │
│ eval_breaker |= │
│ PLEASE_STOP_BIT │
│ │
│ 完成,无需等待 │
│ │
│ │ ... 正常执行字节码 ...
│ │
│ │ eval loop 检查 eval_breaker
│ │ ↓
│ │ 发现 pending_call 标志
│ │ ↓
│ │ _PyRunRemoteDebugger()
│ │ ↓
│ │ 读取 script_path
│ │ ↓
│ │ fopen + PyRun_AnyFile
│ │ ↓
│ │ 执行脚本 ✅
</code></pre></div></div>
<p>CPython 内部的关键代码:</p>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Python/ceval_gil.c</span>
<span class="kt">int</span> <span class="nf">_PyRunRemoteDebugger</span><span class="p">(</span><span class="n">PyThreadState</span> <span class="o">*</span><span class="n">tstate</span><span class="p">)</span>
<span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">remote_debug</span> <span class="o">==</span> <span class="mi">1</span>
<span class="o">&amp;&amp;</span> <span class="n">tstate</span><span class="o">-&gt;</span><span class="n">remote_debugger_support</span><span class="p">.</span><span class="n">debugger_pending_call</span> <span class="o">==</span> <span class="mi">1</span><span class="p">)</span>
<span class="p">{</span>
<span class="n">tstate</span><span class="o">-&gt;</span><span class="n">remote_debugger_support</span><span class="p">.</span><span class="n">debugger_pending_call</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="c1">// 复制脚本路径(避免 race condition)</span>
<span class="kt">char</span> <span class="n">script_path</span><span class="p">[</span><span class="n">pathsz</span><span class="p">];</span>
<span class="n">memcpy</span><span class="p">(</span><span class="n">script_path</span><span class="p">,</span>
<span class="n">tstate</span><span class="o">-&gt;</span><span class="n">remote_debugger_support</span><span class="p">.</span><span class="n">debugger_script_path</span><span class="p">,</span>
<span class="n">pathsz</span><span class="p">);</span>
<span class="c1">// 审计事件(可被安全策略拦截)</span>
<span class="n">PySys_Audit</span><span class="p">(</span><span class="s">"cpython.remote_debugger_script"</span><span class="p">,</span> <span class="s">"s"</span><span class="p">,</span> <span class="n">script_path</span><span class="p">);</span>
<span class="c1">// 执行脚本</span>
<span class="kt">FILE</span><span class="o">*</span> <span class="n">f</span> <span class="o">=</span> <span class="n">fopen</span><span class="p">(</span><span class="n">script_path</span><span class="p">,</span> <span class="s">"r"</span><span class="p">);</span>
<span class="n">PyRun_AnyFile</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="n">script_path</span><span class="p">);</span>
<span class="n">fclose</span><span class="p">(</span><span class="n">f</span><span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<h3 id="每个-pythreadstate-中的数据结构">每个 PyThreadState 中的数据结构</h3>
<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Include/cpython/pystate.h</span>
<span class="cp">#define _Py_MAX_SCRIPT_PATH_SIZE 512
</span>
<span class="k">typedef</span> <span class="k">struct</span> <span class="p">{</span>
<span class="kt">int32_t</span> <span class="n">debugger_pending_call</span><span class="p">;</span>
<span class="kt">char</span> <span class="n">debugger_script_path</span><span class="p">[</span><span class="n">_Py_MAX_SCRIPT_PATH_SIZE</span><span class="p">];</span>
<span class="p">}</span> <span class="n">_PyRemoteDebuggerSupport</span><span class="p">;</span>
</code></pre></div></div>
<p>每个线程状态增加约 516 字节,但运行时开销几乎为零——只是 eval loop 中一次分支预测极大概率命中的 <span class="codespan">if</span> 检查。</p>
<h3 id="使用方式">使用方式</h3>
<p>peeka 的实现非常简洁:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># peeka/core/attach.py
</span><span class="k">def</span> <span class="nf">_attach_pep768</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="n">agent_code</span> <span class="o">=</span> <span class="nf">_read_agent_code</span><span class="p">()</span>
<span class="n">self</span><span class="p">.</span><span class="n">agent_script</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_create_agent_script</span><span class="p">(</span><span class="n">agent_code</span><span class="p">)</span>
<span class="c1"># 一行搞定
</span> <span class="n">sys</span><span class="p">.</span><span class="nf">remote_exec</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">pid</span><span class="p">,</span> <span class="n">self</span><span class="p">.</span><span class="n">agent_script</span><span class="p">)</span>
<span class="c1"># 等待 agent 就绪
</span> <span class="n">self</span><span class="p">.</span><span class="nf">_wait_for_agent_ready</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">READY_TIMEOUT_PEP768</span><span class="p">)</span>
<span class="k">return</span> <span class="bp">True</span>
</code></pre></div></div>
<p>不需要 GDB、不需要 ptrace、不需要 C 扩展、不需要 dlopen。只需要调用者和目标进程是同一用户(或拥有 <span class="codespan">CAP_SYS_PTRACE</span>),并且目标进程没有禁用远程调试。</p>
<h3 id="安全保障">安全保障</h3>
<p>PEP 768 在安全性上的设计非常审慎:</p>
<ol>
<li><strong>仅接受文件路径</strong>:<span class="codespan">sys.remote_exec</span> 写入的是一个<strong>文件路径</strong>,不是代码内容。这意味着攻击者不仅需要跨进程写内存的权限,还需要在目标进程可读的文件系统路径上放置恶意脚本</li>
<li><strong>审计钩子</strong>:执行前触发 <span class="codespan">cpython.remote_debugger_script</span> 审计事件,安全策略可以拦截</li>
<li><strong>可禁用</strong>:<span class="codespan">PYTHON_DISABLE_REMOTE_DEBUG</span> 环境变量或 <span class="codespan">-X disable-remote-debug</span> 启动参数</li>
<li><strong>编译时可移除</strong>:<span class="codespan">./configure --without-remote-debug</span></li>
</ol>
<h3 id="为什么-pep-768-是本质性的进步">为什么 PEP 768 是本质性的进步?</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th> </th>
<th>ptrace/GDB 注入</th>
<th>PEP 768</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>执行时机</strong></td>
<td>取决于暂停点/断点位置</td>
<td>解释器自己选择安全检查点</td>
</tr>
<tr>
<td><strong>GIL 处理</strong></td>
<td>外部强制获取</td>
<td>自然持有(在 eval loop 中)</td>
</tr>
<tr>
<td><strong>运行时开销</strong></td>
<td>无(仅 attach 时)</td>
<td>接近零(一次分支检查)</td>
</tr>
<tr>
<td><strong>所需权限</strong></td>
<td>ptrace + GDB/LLDB</td>
<td>同 UID 或 process_vm_writev</td>
</tr>
<tr>
<td><strong>多线程安全</strong></td>
<td>需要 scheduler-locking</td>
<td>天然安全(per-thread 标志)</td>
</tr>
<tr>
<td><strong>崩溃风险</strong></td>
<td>存在(不安全时机)</td>
<td>极低(安全检查点执行)</td>
</tr>
</tbody>
</table>
</div>
<h3 id="pep-768-实现中的已知问题与-cpython-修复">PEP 768 实现中的已知问题与 CPython 修复</h3>
<p>PEP 768 在 Python 3.14.0a5 合入后(<a href="https://github.com/python/cpython/pull/131937">gh-131937</a>),社区在实际使用中发现了一系列实现缺陷。以下按严重程度梳理关键问题及其修复方案。</p>
<h4 id="命名空间污染gh-132859">命名空间污染(gh-132859)</h4>
<p><strong>问题</strong>:注入脚本在 <span class="codespan">__main__</span> 模块的命名空间中执行,会意外覆盖目标进程的变量:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 目标进程
</span><span class="n">x</span> <span class="o">=</span> <span class="mi">1</span>
<span class="k">while</span> <span class="n">x</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
<span class="k">pass</span>
<span class="c1"># 注入脚本设置了 x = 42 -&gt; 目标进程意外退出
</span></code></pre></div></div>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/132860">PR #132860</a>(3.14.0a5)——脚本现在在隔离的命名空间中执行。如需访问主模块,需显式 <span class="codespan">import __main__</span>。</p>
<h4 id="非-utf-8-路径编码失败gh-133886">非 UTF-8 路径编码失败(gh-133886)</h4>
<p><strong>问题</strong>:<span class="codespan">sys.remote_exec()</span> 硬编码使用 UTF-8 编码脚本路径,导致在非 UTF-8 locale 环境下无法处理非 ASCII 路径,<span class="codespan">os.access()</span> 找不到文件。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/133887">PR #133887</a>(3.14.0b1)——改用文件系统编码(<span class="codespan">sys.getfilesystemencoding()</span>)代替硬编码 UTF-8。</p>
<h4 id="无效参数导致段错误gh-134064">无效参数导致段错误(gh-134064)</h4>
<p><strong>问题</strong>:<span class="codespan">sys.remote_exec(0, None)</span> 在 ASAN/debug 构建中触发段错误——缺少 NULL 检查就直接解引用 <span class="codespan">script_path</span>。由 fusil fuzzer 发现。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/134067">PR #134067</a>(3.14.0b1)——添加参数校验。</p>
<h4 id="elf-搜索中的异常状态污染gh-137293">ELF 搜索中的异常状态污染(gh-137293)</h4>
<p><strong>问题</strong>:在 Linux 上搜索 <span class="codespan">/proc/pid/maps</span> 定位 <span class="codespan">PyRuntime</span> 时,<span class="codespan">search_linux_map_for_section()</span> 对每个无法打开的文件(如已删除的 <span class="codespan">.so</span>)都会设置异常。当最终在其他文件中找到 <span class="codespan">PyRuntime</span> 时,函数返回成功但异常仍被设置:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># sys.remote_exec() 返回成功,但附带一个未清除的异常:
# OSError: Cannot open ELF file '/path/to/lib.so (deleted)'
# -&gt; SystemError: returned a result with an exception set
</span></code></pre></div></div>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/137309">PR #137309</a>——在搜索循环中继续搜索前清除异常。</p>
<h4 id="ctypes-导致的重复-libpython-映射gh-144563">ctypes 导致的重复 libpython 映射(gh-144563)</h4>
<p><strong>问题</strong>:当目标进程导入了 <span class="codespan">_ctypes</span> 或 <span class="codespan">polars</span>(底层使用 ctypes)时,<span class="codespan">/proc/pid/maps</span> 中会出现多个 <span class="codespan">libpython</span> 映射。远程调试代码无法处理重复项,导致 "Can't determine Python version" 或 "Failed to read debug offsets" 错误。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/144595">PR #144595</a>(3.15.0a6)——优雅处理重复映射,使用第一个有效的 PyRuntime。</p>
<h4 id="远程调试偏移表缺乏校验gh-148178">远程调试偏移表缺乏校验(gh-148178)</h4>
<p><strong>问题</strong>:<span class="codespan">_remote_debugging</span> 模块从目标进程内存读取 <span class="codespan">async_debug_offsets</span> 后直接使用,未校验结构有效性。<span class="codespan">asyncio_task_object.size</span> 字段被用作读取长度写入固定 4096 字节的栈缓冲区(<span class="codespan">SIZEOF_TASK_OBJ</span>),<strong>恶意/被攻陷的目标进程可以构造更大的 size 导致栈缓冲区溢出</strong>。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/148187">PR #148187</a>——新增 <span class="codespan">validate_async_debug_offsets()</span> 和 <span class="codespan">validate_debug_offsets()</span> 对所有偏移表做边界校验(+511 行校验基础设施)。已回移至 3.14 分支(<a href="https://github.com/python/cpython/pull/148577">PR #148577</a>)。</p>
<h4 id="远程内存数据损坏导致崩溃gh-140739">远程内存数据损坏导致崩溃(gh-140739)</h4>
<p><strong>问题</strong>:在 free-threading 构建中使用 <span class="codespan">--mode=gil</span> 做性能采样时,远程调试 unwinder 读取到目标进程的损坏内存会导致 SIGSEGV——缺乏对远程读取数据的边界检查。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/143190">PR #143190</a>(3.15.0a4)——对所有远程读取的数据结构添加健壮性校验。</p>
<h4 id="错误处理路径缺失gh-144316-gh-146308">错误处理路径缺失(gh-144316, gh-146308)</h4>
<p><strong>问题</strong>:<span class="codespan">_remote_debugging</span> 模块中多处错误处理缺陷:</p>
<ul>
<li><span class="codespan">RemoteUnwinder.get_stack_trace()</span> 在 <span class="codespan">debug=False</span> 时可能返回 NULL 但不设置异常</li>
<li>varint 解码路径缺少错误检查</li>
<li><span class="codespan">PyMem_RawMalloc()</span> 返回 NULL 时缺少 <span class="codespan">PyErr_NoMemory()</span> 调用</li>
<li>跨平台错误传播不一致</li>
</ul>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/144442">PR #144442</a> 和 <a href="https://github.com/python/cpython/pull/146309">PR #146309</a>——全面审计并修复错误处理路径,<span class="codespan">set_exception_cause</span> 宏改为无条件设置异常。</p>
<h4 id="权限问题sudo-创建的临时文件不可读gh-143511">权限问题:sudo 创建的临时文件不可读(gh-143511)</h4>
<p><strong>问题</strong>:使用 <span class="codespan">sudo</span> 运行注入脚本时,<span class="codespan">NamedTemporaryFile</span> 以 root 用户创建(权限 <span class="codespan">0600</span>),非 root 的目标进程无法读取该脚本文件。这是 PEP 768 "仅接受文件路径"设计的副作用。</p>
<p><strong>处理</strong>:文档化(<a href="https://github.com/python/cpython/pull/143575">PR #143575</a>)——用户需确保脚本文件对目标进程可读,或以相同用户运行。</p>
<h4 id="remote-pdb-无法中断死循环gh-132975">Remote PDB 无法中断死循环(gh-132975)</h4>
<p><strong>问题</strong>:在远程 PDB 提示符下输入 <span class="codespan">while True: pass</span> 后无法中断——能中断脚本本身的执行,但无法中断 PDB 命令求值中的死循环。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/133223">PR #133223</a>(3.14.0b1)——实现基于 socket 的中断机制:Unix 上发送 SIGINT 到远程进程,Windows 上通过 <span class="codespan">socketpair</span> 传递信号。</p>
<h4 id="审计事件缺失gh-135543">审计事件缺失(gh-135543)</h4>
<p><strong>问题</strong>:<span class="codespan">sys.remote_exec()</span> 最初未触发审计事件,安全工具无法监控进程注入行为。</p>
<p><strong>修复</strong>:<a href="https://github.com/python/cpython/pull/135544">PR #135544</a>(3.14.0b1)——添加 <span class="codespan">sys.remote_exec</span> 审计事件,参数为 <span class="codespan">(pid, script)</span>。</p>
<h4 id="外部工具跟进">外部工具跟进</h4>
<ul>
<li><strong>debugpy(VS Code)</strong>:<a href="https://github.com/microsoft/debugpy/commit/c7e86a1954381ceadb2ea398fc60079deef91358">已支持 sys.remote_exec()</a>(2026.1),Python 3.14+ 优先使用原生 API,可通过 <span class="codespan">--disable-sys-remote-exec</span> 回退</li>
<li><strong>helicopter-parent</strong>:<a href="https://github.com/a-reich/helicopter-parent/">社区工具</a>,通过管理子进程绕过 <span class="codespan">ptrace_scope</span> 限制</li>
<li><strong>peeka/memray</strong>:已集成 PEP 768 作为首选注入路径</li>
</ul>
<hr />
<h2 id="5-三代方案对比">5. 三代方案对比</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>方案</th>
<th>代表工具</th>
<th>Python 版本</th>
<th>安全性</th>
<th>依赖</th>
</tr>
</thead>
<tbody>
<tr>
<td>GDB + PyRun_SimpleString</td>
<td>pyrasite</td>
<td>2.4 - 3.13</td>
<td>⚠️ 可能崩溃/死锁</td>
<td>GDB, ptrace</td>
</tr>
<tr>
<td>GDB/LLDB + dlopen + pthread</td>
<td>memray, peeka</td>
<td>3.8 - 3.13</td>
<td>✅ 较安全</td>
<td>GDB/LLDB, ptrace, C 编译器</td>
</tr>
<tr>
<td>sys.remote_exec (PEP 768)</td>
<td>peeka, memray</td>
<td>3.14+</td>
<td>✅ 安全</td>
<td>无外部依赖</td>
</tr>
</tbody>
</table>
</div>
<h3 id="memray-vs-peeka-的容错策略">memray vs peeka 的容错策略</h3>
<p>两个项目虽然共享 dlopen + pthread 的核心方案,但在<strong>失败处理</strong>上有本质区别:</p>
<p><strong>memray:一次选择,不回退。</strong> memray 在启动时通过 <span class="codespan">resolve_debugger()</span> 按优先级(<span class="codespan">sys.remote_exec</span> &gt; <span class="codespan">gdb</span> &gt; <span class="codespan">lldb</span>)选定<strong>一种</strong>注入方法,此后不再切换。GDB 路径硬依赖 C 扩展(<span class="codespan">assert injecter.exists()</span>),dlopen 失败直接报错,没有 legacy 回退。这是刻意的设计选择——memray 作为专业的内存分析器,对运行环境有明确的前置要求。</p>
<p><strong>peeka:逐级降级,尽力而为。</strong> peeka 的决策树包含多层 fallback:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># peeka 的 attach 决策树
</span><span class="k">if</span> <span class="nf">hasattr</span><span class="p">(</span><span class="n">sys</span><span class="p">,</span> <span class="sh">"</span><span class="s">remote_exec</span><span class="sh">"</span><span class="p">):</span> <span class="c1"># Python 3.14+
</span> <span class="o">-&gt;</span> <span class="n">sys</span><span class="p">.</span><span class="nf">remote_exec</span><span class="p">()</span> <span class="c1"># 最优路径
</span><span class="k">elif</span> <span class="nf">_has_injector</span><span class="p">():</span> <span class="c1"># C 扩展可用
</span> <span class="k">if</span> <span class="n">Linux</span><span class="p">:</span>
<span class="k">try</span><span class="p">:</span>
<span class="o">-&gt;</span> <span class="n">GDB</span> <span class="o">+</span> <span class="n">dlopen</span> <span class="o">+</span> <span class="n">pthread</span> <span class="c1"># 安全回退
</span> <span class="nf">except </span><span class="p">(</span><span class="nb">TimeoutError</span><span class="p">,</span> <span class="p">...):</span>
<span class="o">-&gt;</span> <span class="n">GDB</span> <span class="o">+</span> <span class="n">PyRun_SimpleString</span> <span class="c1"># 降级到 legacy
</span> <span class="k">elif</span> <span class="n">macOS</span><span class="p">:</span>
<span class="o">-&gt;</span> <span class="n">LLDB</span> <span class="o">+</span> <span class="n">dlopen</span> <span class="o">+</span> <span class="n">pthread</span>
<span class="k">elif</span> <span class="n">Linux</span><span class="p">:</span>
<span class="o">-&gt;</span> <span class="n">GDB</span> <span class="o">+</span> <span class="n">PyRun_SimpleString</span> <span class="c1"># 最后手段
</span><span class="k">else</span><span class="p">:</span>
<span class="o">-&gt;</span> <span class="n">报错</span>
</code></pre></div></div>
<p>这种设计源于 peeka 的定位——作为通用诊断工具,它需要在各种环境下都能工作,包括 GLIBC 版本不匹配(C 扩展无法加载)或 GIL 死锁(dlopen 路径超时)等边缘情况。代价是多了一层复杂度和潜在的时机安全风险(legacy 路径),但换来了更广的兼容性。</p>
<div class="table-container">
<table>
<thead>
<tr>
<th> </th>
<th>memray</th>
<th>peeka</th>
</tr>
</thead>
<tbody>
<tr>
<td>C 扩展缺失</td>
<td>硬报错 (<span class="codespan">assert</span>)</td>
<td>回退到 legacy GDB</td>
</tr>
<tr>
<td>dlopen 运行时失败</td>
<td>报错退出</td>
<td>回退到 legacy GDB</td>
</tr>
<tr>
<td>dlopen 后 GIL 死锁</td>
<td>超时报错</td>
<td>超时后回退到 legacy GDB</td>
</tr>
<tr>
<td>设计哲学</td>
<td>明确前置要求,快速失败</td>
<td>尽力而为,逐级降级</td>
</tr>
</tbody>
</table>
</div>
<hr />
<h2 id="6-实践中的坑">6. 实践中的坑</h2>
<p>在实际开发 peeka 的过程中,我们踩过一些有趣的坑:</p>
<h3 id="61-glibc-版本不匹配">6.1 GLIBC 版本不匹配</h3>
<p>C 扩展 <span class="codespan">_inject.abi3.so</span> 在高版本 Linux 上编译后,放到低版本容器(如 Python 3.8 的 Debian 旧镜像)中会因为 <span class="codespan">GLIBC_2.34 not found</span> 而 dlopen 失败。</p>
<p>但 <span class="codespan">importlib.util.find_spec()</span> 只检查文件是否存在,不检查能否加载:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ❌ 错误:文件存在但无法加载
</span><span class="k">def</span> <span class="nf">_has_injector</span><span class="p">():</span>
<span class="k">return</span> <span class="n">importlib</span><span class="p">.</span><span class="n">util</span><span class="p">.</span><span class="nf">find_spec</span><span class="p">(</span><span class="sh">"</span><span class="s">peeka.core._inject</span><span class="sh">"</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span>
<span class="c1"># ✅ 正确:实际尝试 import
</span><span class="k">def</span> <span class="nf">_has_injector</span><span class="p">():</span>
<span class="k">try</span><span class="p">:</span>
<span class="kn">import</span> <span class="n">peeka.core._inject</span>
<span class="k">return</span> <span class="bp">True</span>
<span class="nf">except </span><span class="p">(</span><span class="nb">ImportError</span><span class="p">,</span> <span class="nb">OSError</span><span class="p">):</span> <span class="c1"># OSError 捕获 dlopen 失败
</span> <span class="k">return</span> <span class="bp">False</span>
</code></pre></div></div>
<h3 id="62-gdb-dlopen-后的-gil-死锁">6.2 GDB dlopen 后的 GIL 死锁</h3>
<p>在 Python 3.12 上,GDB dlopen 注入的 C 扩展创建的 pthread 调用 <span class="codespan">PyGILState_Ensure()</span> 时,可能因为 GDB 操作后 GIL 状态不一致而永远无法获取 GIL。</p>
<p>解决方案是将 dlopen 路径视为<strong>乐观尝试</strong>,失败后回退到 legacy GDB:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="nf">_has_injector</span><span class="p">():</span>
<span class="k">try</span><span class="p">:</span>
<span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="nf">_inject_via_gdb_dlopen</span><span class="p">()</span>
<span class="nf">except </span><span class="p">(</span><span class="nb">TimeoutError</span><span class="p">,</span> <span class="nb">RuntimeError</span><span class="p">,</span> <span class="nb">OSError</span><span class="p">):</span>
<span class="n">logger</span><span class="p">.</span><span class="nf">warning</span><span class="p">(</span><span class="sh">"</span><span class="s">GDB dlopen failed, falling back to legacy GDB</span><span class="sh">"</span><span class="p">)</span>
<span class="c1"># 控制流落入 legacy 路径
</span></code></pre></div></div>
<h3 id="63-ptrace-权限">6.3 ptrace 权限</h3>
<p>不同 Linux 发行版的默认 <span class="codespan">ptrace_scope</span> 不同:</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>ptrace_scope</th>
<th>含义</th>
<th>默认发行版</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>任意同 UID 进程可 attach</td>
<td>Arch, RHEL</td>
</tr>
<tr>
<td>1</td>
<td>仅父进程可 attach</td>
<td>Ubuntu, Debian</td>
</tr>
<tr>
<td>2</td>
<td>仅 <span class="codespan">CAP_SYS_PTRACE</span></td>
<td>—</td>
</tr>
<tr>
<td>3</td>
<td>完全禁止</td>
<td>—</td>
</tr>
</tbody>
</table>
</div>
<p>Docker 容器默认<strong>不授予</strong> <span class="codespan">CAP_SYS_PTRACE</span>,且 seccomp 配置也会阻止 ptrace 系统调用。需要:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">--cap-add</span><span class="o">=</span>SYS_PTRACE <span class="nt">--security-opt</span> <span class="nv">seccomp</span><span class="o">=</span>unconfined your-image
</code></pre></div></div>
<h3 id="64-sysmonitoring-tool_id-冲突">6.4 sys.monitoring tool_id 冲突</h3>
<p>Python 3.12+ 的 <span class="codespan">sys.monitoring</span> 只有 6 个 tool slot(0-5)。如果 coverage 工具或 debugger 已经占用了 slot 0,硬编码 <span class="codespan">use_tool_id(0, ...)</span> 会抛 <span class="codespan">ValueError</span>。应该遍历可用 slot:</p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">candidate_id</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">):</span>
<span class="k">try</span><span class="p">:</span>
<span class="n">sys</span><span class="p">.</span><span class="n">monitoring</span><span class="p">.</span><span class="nf">use_tool_id</span><span class="p">(</span><span class="n">candidate_id</span><span class="p">,</span> <span class="sh">"</span><span class="s">peeka-trace</span><span class="sh">"</span><span class="p">)</span>
<span class="k">break</span>
<span class="k">except</span> <span class="nb">ValueError</span><span class="p">:</span>
<span class="k">continue</span>
</code></pre></div></div>
<hr />
<h2 id="7-总结">7. 总结</h2>
<p>Python 进程注入经历了三代演进:</p>
<ol>
<li><strong>直接调用 C API</strong>(pyrasite):简单粗暴,但有崩溃和死锁风险</li>
<li><strong>dlopen + pthread</strong>(memray/peeka):通过安全断点和独立线程显著降低风险</li>
<li><strong>解释器原生支持</strong>(PEP 768):从根本上解决问题,让解释器自己在安全时机执行代码</li>
</ol>
<p>如果你的目标环境是 Python 3.14+,<span class="codespan">sys.remote_exec</span> 是毫无争议的最佳选择。如果需要支持更老的版本,dlopen + pthread 方案提供了合理的安全性和兼容性平衡。</p>
<p>进程注入不是魔法,它是系统编程、编译器原理和 CPython 内部机制的交汇点。理解这些底层原理,能帮助你在遇到"进程卡死"或"注入失败"时,快速定位问题所在。</p>
<hr />
<p><em>本文基于 <a href="https://github.com/wwulfric/peeka">peeka</a> 的实际开发经验撰写。peeka 是一个受 <a href="https://github.com/alibaba/arthas">Alibaba Arthas</a> 启发的 Python 运行时诊断工具,支持 Python 3.8-3.14+。</em></p>
</description>
<pubDate>Sun, 19 Apr 2026 00:00:00 +0000</pubDate>
<link>https://wulfric.me/2026/04/python-process-injection/</link>
<guid isPermaLink="true">https://wulfric.me/2026/04/python-process-injection/</guid>
<category>python</category>
<category>安全</category>
<category>cpython</category>
<category>技术</category>
</item>
<item>
<title>翻译:AGENTS.md 在我们的智能体评估中优于 Skills</title>
<description><p>翻译自 Vercel 博客文章:<a href="https://vercel.com/blog/agents-md-outperforms-skills-in-our-agent-evals">AGENTS.md outperforms skills in our agent evals</a></p>
<p>我们原本期望技能是教授编码智能体框架特定知识的解决方案。在构建专注于 Next.js 16 API 的评估后,我们发现了意想不到的结果。</p>
<p>直接嵌入在 <span class="codespan">AGENTS.md</span> 中的压缩版 8KB 文档索引实现了 100% 的通过率,而技能即使在明确指示智能体使用它们的情况下,最高也只能达到 79%。如果没有这些指示,技能的表现与完全没有文档时没有区别。</p>
<p>以下是我们尝试的方法、我们学到的经验,以及如何在自己的 Next.js 项目中设置这一功能。</p>
<h2 id="我们试图解决的问题">我们试图解决的问题</h2>
<p>AI 编码智能体依赖于会过时的训练数据。Next.js 16 引入了 <span class="codespan">'use cache'</span>、<span class="codespan">connection()</span> 和 <span class="codespan">forbidden()</span> 等 API,这些 API 并不在当前模型的训练数据中。当智能体不了解这些 API 时,它们会生成不正确的代码或回退到旧的模式。</p>
<p>反之也可能发生——你运行的是较旧版本的 Next.js,而模型建议了你的项目中尚不存在的新 API。我们希望通过向智能体提供版本匹配的文档来解决这个问题。</p>
<h2 id="教授智能体框架知识的两种方法">教授智能体框架知识的两种方法</h2>
<p>在深入探讨结果之前,先简要介绍一下我们测试的两种方法:</p>
<ul>
<li><strong>技能</strong>是一种开放标准,用于封装编码智能体可以使用的领域知识。一个技能将智能体可以按需调用的提示、工具和文档捆绑在一起。其理念是智能体在意识到需要特定框架的帮助时调用该技能,并获取相关文档。</li>
<li><span class="codespan">AGENTS.md</span> 是项目根目录中的一个 Markdown 文件,为编码智能体提供持久上下文。无论你在 <span class="codespan">AGENTS.md</span> 中放入什么内容,智能体在每个回合都可以使用,而无需智能体决定加载它。Claude Code 使用 <span class="codespan">CLAUDE.md</span> 来达到相同的目的。</li>
</ul>
<p>我们构建了一个 Next.js 文档技能和一个 <span class="codespan">AGENTS.md</span> 文档索引,然后将它们通过我们的评估套件,看看哪种表现更好。</p>
<h2 id="我们最初押注于技能">我们最初押注于技能</h2>
<p>技能似乎是正确的抽象。你将框架文档打包成一个技能,智能体在处理 Next.js 任务时调用它,然后你就能得到正确的代码。职责分离清晰,上下文开销最小,智能体只加载它需要的内容。甚至在 skills.sh 上还有一个不断增长的现成技能目录。</p>
<p>我们期望智能体遇到 Next.js 任务,调用技能,阅读版本匹配的文档,然后生成正确的代码。</p>
<p>然后我们运行了评估。</p>
<h2 id="技能没有被可靠地触发">技能没有被可靠地触发</h2>
<p>在 56% 的评估案例中,技能从未被调用。智能体可以访问文档但没有使用它。添加技能相比基线没有产生任何改进:</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>配置</th>
<th>通过率</th>
<th>相比基线</th>
</tr>
</thead>
<tbody>
<tr>
<td>基线(无文档)</td>
<td>53%</td>
<td>—</td>
</tr>
<tr>
<td>技能(默认行为)</td>
<td>53%</td>
<td>+0pp</td>
</tr>
</tbody>
</table>
</div>
<p>零改进。技能存在,智能体可以使用它,但智能体选择不使用。在详细的构建/Lint/测试细分中,技能在某些指标上实际上表现比基线更差(测试方面为 58% vs 63%),这表明环境中未使用的技能可能会引入噪声或干扰。</p>
<p>这并非我们设置所独有。智能体不能可靠地使用可用工具是当前模型的一个已知局限性。</p>
<h2 id="明确指令有帮助但措辞很脆弱">明确指令有帮助,但措辞很脆弱</h2>
<p>我们尝试在 <span class="codespan">AGENTS.md</span> 中添加明确指示,告诉智能体使用该技能。</p>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Before writing code, first explore the project structure,
then invoke the nextjs-doc skill for documentation.
</code></pre></div></div>
<p>这提高了触发率到 95% 以上,并将通过率提升到 79%。</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>配置</th>
<th>通过率</th>
<th>相比基线</th>
</tr>
</thead>
<tbody>
<tr>
<td>基线(无文档)</td>
<td>53%</td>
<td>—</td>
</tr>
<tr>
<td>技能(默认行为)</td>
<td>53%</td>
<td>+0pp</td>
</tr>
<tr>
<td>带明确指令的技能</td>
<td>79%</td>
<td>+26pp</td>
</tr>
</tbody>
</table>
</div>
<p>一个实质性的改进。但我们发现指令措辞影响智能体行为的方式有些出乎意料。</p>
<p>不同的措辞产生了截然不同的结果:</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>指令</th>
<th>行为</th>
<th>结果</th>
</tr>
</thead>
<tbody>
<tr>
<td>"你必须调用技能"</td>
<td>先阅读文档,以文档模式为锚点</td>
<td>错过项目上下文</td>
</tr>
<tr>
<td>"先探索项目,再调用技能"</td>
<td>先构建心理模型,使用文档作为参考</td>
<td>更好的结果</td>
</tr>
</tbody>
</table>
</div>
<p>相同的技能。相同的文档。基于微小的措辞变化产生不同的结果。</p>
<p>在一个评估(<span class="codespan">'use cache'</span> 指令测试)中,"先调用"的方法编写了正确的 <span class="codespan">page.tsx</span>,但完全错过了所需的 <span class="codespan">next.config.ts</span> 更改。"先探索"的方法两者都得到了。</p>
<p>这种脆弱性让我们担心。如果微小的措辞调整会导致巨大的行为变化,这种方法在生产环境中感觉会很脆弱。</p>
<h2 id="构建我们可以信任的评估">构建我们可以信任的评估</h2>
<p>在得出结论之前,我们需要能够信任的评估。我们的初始测试套件存在模棱两可的提示、验证实现细节而非可观察行为的测试,以及关注模型训练数据中已有的 API。我们没有测量我们真正关心的东西。</p>
<p>我们通过移除测试泄露、解决矛盾以及转向基于行为的断言来强化评估套件。最重要的是,我们添加了针对不在模型训练数据中的 Next.js 16 API 的测试。</p>
<p>我们重点评估套件中的 API:</p>
<ul>
<li><span class="codespan">connection()</span> 用于动态渲染</li>
<li><span class="codespan">'use cache'</span> 指令</li>
<li><span class="codespan">cacheLife()</span> 和 <span class="codespan">cacheTag()</span></li>
<li><span class="codespan">forbidden()</span> 和 <span class="codespan">unauthorized()</span></li>
<li><span class="codespan">proxy.ts</span> 用于 API 代理</li>
<li>异步 <span class="codespan">cookies()</span> 和 <span class="codespan">headers()</span></li>
<li><span class="codespan">after()</span>、<span class="codespan">updateTag()</span>、<span class="codespan">refresh()</span></li>
</ul>
<p>以下所有结果都来自这个强化的评估套件。每种配置都根据相同的测试进行判断,并通过重试排除模型差异。</p>