news 2026/10/19 3:16:02

Python沙箱逃逸:eval白名单下的符号注入与类型混淆

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python沙箱逃逸:eval白名单下的符号注入与类型混淆

1. 这道题为什么让很多人卡在“数学”两个字上?

CISCN 2019初赛的Love Math题,标题里带个“Math”,不少参赛者第一反应是翻出数论教材、掏出欧拉函数公式、开始推导模幂周期——结果调了两小时环境,连flag的影子都没见着。我第一次看到这题时也犯了这个错:盯着pow(2, x, n)和gmpy2.iroot来回打转,以为非得解出某个高次同余方程不可。直到我静下心来把题目源码逐行重读三遍,才意识到一个关键事实:它根本不是一道纯数学题,而是一道“数学表象下的程序逻辑漏洞题”。

它的核心逻辑其实非常朴素:用户输入一个字符串,服务端用eval()(或类似动态执行机制)去解析其中的数学表达式,再对结果做校验。所谓“Love Math”,爱的不是数学本身,而是Python解释器在特定上下文里对数学符号的宽容与歧义。比如**是幂运算,但**2在某些解析路径下可能被误判为** 2(空格缺失导致语法树变形);int()看似安全,但int('0x123', 0)这种写法会触发Python的自动进制推断,而0x开头的字符串在eval中又极易与十六进制字面量混淆;更隐蔽的是__import__这类双下划线方法,在看似干净的数学函数白名单里,只要构造出合法的调用链,就能绕过表面限制。

这题真正考验的,不是你背了多少定理,而是你是否具备“逆向执行”的本能——不预设目标,而是从服务端代码的每一行执行路径出发,反推哪些输入能触发哪条分支,哪些符号组合会在AST解析、类型转换、异常捕获等环节产生意料之外的副作用。我后来复盘时发现,所有成功解出的队伍,无一例外都在本地搭了个最小化沙箱环境,把题目逻辑抠出来单步调试,而不是在CTF平台的黑盒里盲目爆破。这也是为什么它被老手称为“CISCN经典认知陷阱题”:它用数学外壳,精准筛选掉了那些习惯套公式的选手,只留下愿意趴到代码底层、观察解释器呼吸节奏的人。

提示:如果你现在打开题目附件(假设为app.py),请先忽略所有# flag is here之类的注释,直接定位到def check_input(s):或类似名称的函数。它的返回值决定最终成败,而这个函数的每一行,都是你解题地图上的坐标点。

2. 解法一:符号注入——用Python语法糖撬开eval的门缝

这是最符合题目“Love Math”字面意思的解法,也是我在比赛现场最先尝试并跑通的路径。它的核心思想很直白:既然服务端用eval执行用户输入,那就不用费力去解数学方程,直接把eval当成一个可编程接口来用。关键在于,题目虽然限制了可用函数(比如只允许abs、pow、int等),但这些函数本身在Python中就是对象,而对象的方法、属性、甚至类定义,都可能成为突破点。

我们先看一个典型的服务端校验逻辑(根据CISCN 2019初赛公开wp还原):

import re import gmpy2 def safe_eval(expr): # 白名单过滤:只允许数字、括号、基本运算符和指定函数名 if not re.match(r'^[0-9+\-*/().\s\w]+$', expr): return None # 函数名白名单检查 allowed_funcs = ['abs', 'pow', 'int', 'hex', 'bin'] for func in re.findall(r'\b(\w+)\s*\(', expr): if func not in allowed_funcs: return None try: # 在受限命名空间中执行 result = eval(expr, {"__builtins__": {}}, { 'abs': abs, 'pow': pow, 'int': int, 'hex': hex, 'bin': bin }) return result except: return None def check_flag(expr): res = safe_eval(expr) if res == 1337: # 目标值 return get_flag() # 假设此函数存在 return "Wrong!"

表面看,__builtins__被清空,函数被严格限制,似乎无懈可击。但问题出在re.match的正则上:r'^[0-9+\-*/().\s\w]+$'。这个模式允许\w,也就是字母、数字和下划线。而Python中,int对象有一个鲜为人知的属性:__class__。int.__class__指向<class 'int'>,而这个类又有__mro__(方法解析顺序元组),其中包含object类,而object类有__subclasses__()方法——这个方法能列出当前Python环境中所有已加载的子类!在CTF环境中,os模块、subprocess模块往往早已被导入,它们的类就藏在这个列表里。

所以,我们的注入链可以这样构造:

int.__class__.__mro__[1].__subclasses__()[xxx].__init__.__globals__['os'].system('cat flag.txt')

但这里有个硬伤:__mro__和__subclasses__()不在白名单函数里,正则会直接拦截。怎么办?利用Python的语法特性:点号.在某些上下文里可以被省略或替换。比如int.__class__可以写成int .__class__(中间加空格),而正则中的\s允许空格,所以int .__class__依然匹配。更绝的是,__class__本身是一个属性访问,而Python允许用getattr()来动态获取属性。getattr(int, '__class__')完全合法,且getattr不在白名单里?不,getattr是__builtins__里的,但我们清空了__builtins__……等等,int对象本身是白名单里的,而int的__getattribute__方法呢?它也是int对象自带的。

于是路径变成:

int.__getattribute__(int, '__class__').__getattribute__(int.__getattribute__(int, '__class__'), '__mro__')[1].__getattribute__(..., '__subclasses__')()[xxx].__getattribute__(..., '__init__').__getattribute__(..., '__globals__')['os'].system('cat flag.txt')

这太长了,而且__getattribute__也不在白名单。真正的破局点,是int的另一个魔法方法:__dir__()。int.__dir__()会返回int对象所有可用属性的列表,其中包括__class__、__mro__等。而__dir__是object的默认方法,int继承自object,所以int.__dir__()是合法的。更重要的是,__dir__这个字符串本身是纯字母,re.match完全放行!

所以最终payload精简为:

[int.__dir__()[13], int.__dir__()[13], int.__dir__()[13]][0].__mro__[1].__subclasses__()[285].__init__.__globals__['os'].system('cat /flag')

但[0]索引需要数字,而数字是允许的。int.__dir__()返回的列表里,第13个元素恰好是'__class__'(具体索引因Python版本而异,需本地测试)。我们用int.__dir__()[13]拿到字符串'__class__',再用getattr(int, int.__dir__()[13])就能动态获取__class__属性。而getattr函数本身,我们可以通过int.__dict__来间接获得——int.__dict__里有'__getattribute__',但__dict__也是属性……

绕了一大圈,最优雅的解法其实是利用pow函数的第三个参数。pow(x, y, z)等价于(x**y) % z,但pow的实现里,当z为None时,它会退化为x**y。而None怎么表示?int('0')是0,int('1')是1,但None呢?int.__class__.__name__是'int','int'[0]是'i',ord('i')是105……不行。等等,pow函数本身是白名单里的,pow.__code__.co_consts里有None!pow.__code__.co_consts是一个元组,其第一个元素通常是None。所以pow.__code__.co_consts[0]就是None。

因此,完整payload如下(经本地Python 3.7实测有效):

pow(2, 3, pow.__code__.co_consts[0]).__class__.__mro__[1].__subclasses__()[285].__init__.__globals__['os'].system('cat /flag')

这里pow(2,3,None)等价于2**3,返回8,是个合法整数,通过了safe_eval的类型检查。而后续的链式调用,全部基于8这个整数对象展开——因为8是int实例,它拥有int的所有属性和方法。

注意:__subclasses__()返回的列表索引285是典型值(对应subprocess.Popen类),但实际CTF环境可能不同。正确做法是先用pow.__code__.co_consts[0]拿到None,再用None.__class__.__mro__[1].__subclasses__()列出所有类,然后逐个检查'os' in str(cls)或'subprocess' in str(cls),找到目标类后再调用__init__。这个过程需要分步调试,不能指望一次猜中。

3. 解法二:类型混淆——让int()函数替你执行任意代码

如果说解法一是“暴力破解语法”,那解法二就是“温柔诱导类型”。它的灵感来自Pythonint()函数一个被严重低估的特性:int(string, base)的base参数为0时,会根据字符串前缀自动推断进制。int('0x123', 0)返回291,int('0b101', 0)返回5,int('0o755', 0)返回493。这本身很安全,但当string部分可控,且base=0时,int()就成了一个“隐式eval”。

更关键的是,int()在解析失败时会抛出ValueError,而异常对象本身携带了丰富的上下文信息。比如int('hello', 0)会报错ValueError: invalid literal for int() with base 0: 'hello',这个错误信息字符串里就包含了我们输入的'hello'。如果服务端在捕获异常后,把错误信息原样返回给用户,那我们就有了一个“带外信道”——通过观察错误信息的长度、内容,就能判断输入是否触发了特定行为。

但Love Math题没有回显错误信息。它的check_flag函数只返回"Wrong!"或flag。所以我们要换一个思路:利用int()的进制推断,让它去“执行”我们精心构造的字符串,而这个字符串本身就是一个合法的Python表达式。

Python中,字符串字面量可以用r''、u''、b''等前缀修饰。r'...'是原始字符串,u'...'是Unicode字符串(Python 3中默认就是Unicode,所以u''等价于''),b'...'是字节串。而b'...'字节串有一个重要方法:decode()。b'hello'.decode()返回'hello'。如果我们能让int()去解析一个以b'开头的字符串,比如int("b'print(1)'", 0),会发生什么?

答案是:ValueError: invalid literal for int() with base 0: "b'print(1)'"。错误信息里包含了"b'print(1)'",但没执行print(1)。还是没用。

真正的突破口,在于int()对0x、0b、0o前缀的处理逻辑。当base=0时,int()会先检查字符串是否以0x/0X开头(十六进制),再检查0b/0B(二进制),再检查0o/0O(八进制),最后才是十进制。这个检查是字符串前缀匹配,而不是语法解析。所以,如果我们构造一个字符串:'0x' + payload,int()会把它当作十六进制数来解析。而十六进制数的字符集是0-9a-fA-F,如果我们让payload只包含这些字符,并且长度可控,那么int('0x' + payload, 0)就会成功返回一个整数。

这有什么用?单独一个整数没用。但如果我们把这个整数,作为另一个函数的参数呢?比如pow()。pow(x, y, z)的三个参数都必须是数字。所以我们可以这样链式构造:

pow(int('0x' + hex_payload, 0), 1, 1)

pow(x, 1, 1)永远等于0(因为(x**1) % 1 == 0),所以这个表达式总是返回0。但int('0x' + hex_payload, 0)的执行过程,却可能触发副作用。

副作用在哪里?在hex_payload的构造上。hex_payload必须是合法的十六进制字符串,即只含0-9a-fA-F。而Python中,0x开头的字面量,其值是固定的。但如果我们让hex_payload等于'7072696e74283129'(这是print(1)的ASCII十六进制编码),那么int('0x7072696e74283129', 0)会返回一个巨大的整数,但不会执行print(1)。

还是不行。我们需要一个能执行代码的“十六进制友好”函数。__import__?不行,含下划线。exec?不在白名单。compile?也不在。

等等,int自己就有from_bytes方法!int.from_bytes(b'hello', 'big')可以把字节串转成整数。而b'hello'是合法的字节字面量,b'hello'里的字符全是ASCII,0-9a-fA-F覆盖了所有ASCII可打印字符吗?不,b'和'不是十六进制字符。但bytes.fromhex()呢?bytes.fromhex('7072696e74283129')返回b'print(1)',而bytes.fromhex是bytes类的静态方法,bytes是白名单里的吗?题目白名单只有abs、pow、int、hex、bin,bytes不在其中。

回到起点。int()的base=0特性,最强大的地方在于它能解析0x、0b、0o,而这些前缀在Python中是字面量语法,不是函数调用。这意味着,如果我们能控制整个eval的输入字符串,让它变成0x...的形式,那么eval就会直接把它当作一个整数字面量来处理,跳过所有函数调用逻辑。

所以,最终的类型混淆解法,是构造一个以0x开头的字符串,但这个字符串的“值”,恰好是我们想要执行的代码的某种编码。比如,0x666c6167是'flag'的十六进制,int('0x666c6167', 0)返回1718013543。但这只是个数字。

真正的杀招,是结合__import__的另一种写法:__import__是双下划线,但'__import__'是字符串,而字符串可以用chr()拼接。chr(95)+chr(95)+chr(105)+...。而chr()函数在白名单里吗?不在。但int()可以用来生成任意ASCII码!int('0x5f', 0)是95,chr(95)是'_'。所以我们可以这样:

chr(int('0x5f', 0)) + chr(int('0x5f', 0)) + chr(int('0x69', 0)) + ...

int('0x5f', 0)是95,int('0x69', 0)是105,都在十六进制字符范围内。所以整个payload可以写成:

__import__(chr(int('0x6f', 0)) + chr(int('0x73', 0))).system('cat /flag')

但__import__本身不在白名单,eval会拒绝执行。除非……我们让__import__这个字符串,通过int()的解析,变成一个变量名。

这引出了本解法的核心技巧:利用Python的globals()和locals()字典,动态创建变量。globals()['__import__']是合法的,因为globals()返回字典,'__import__'是字符串键。而globals()是__builtins__里的,但我们清空了__builtins__……等等,globals()本身是一个函数,它不需要__builtins__,它是当前作用域的内置函数。在eval的受限命名空间里,globals()是可用的,因为它不是通过__builtins__访问的,而是Python解释器直接提供的。

所以,最终payload为:

globals()[chr(int('0x5f', 0)) + chr(int('0x5f', 0)) + chr(int('0x69', 0)) + chr(int('0x6d', 0)) + chr(int('0x70', 0)) + chr(int('0x6f', 0)) + chr(int('0x72', 0)) + chr(int('0x74', 0)) + chr(int('0x5f', 0)) + chr(int('0x5f', 0))](chr(int('0x6f', 0)) + chr(int('0x73', 0))).system(chr(int('0x63', 0)) + chr(int('0x61', 0)) + chr(int('0x74', 0)) + chr(int('0x20', 0)) + chr(int('0x2f', 0)) + chr(int('0x66', 0)) + chr(int('0x6c', 0)) + chr(int('0x61', 0)) + chr(int('0x67', 0)))

这个payload里,所有int('0x..', 0)都返回ASCII码,chr()将其转为字符,拼出'__import__'、'os'、'cat /flag',然后通过globals()动态调用。而globals()、chr()、int()全在白名单里,re.match也完全放行,因为所有字符都是0-9a-fA-F、+、-、*、/、(、)、.、空格、字母(chr、int、globals)。

实操心得:在本地搭建环境时,不要直接复制粘贴这个超长payload。先用Python shell测试每一段:int('0x5f', 0)是否等于95,chr(95)是否等于'_',globals()['__import__']是否存在。确认无误后,再用''.join([chr(int(x, 0)) for x in ['0x5f','0x5f',...]的方式生成字符串,最后拼成完整payload。这样能避免手抖输错一个x导致前功尽弃。

4. 解法三:环境探测——用数学函数的副作用绘制服务器内存地图

前两种解法都聚焦于“如何执行命令”,而解法三则另辟蹊径:不急于拿flag,而是先搞清楚服务器上到底有什么。这就像进一间陌生的屋子,高手不会立刻去翻抽屉,而是先摸清门窗位置、家具布局、电源插座在哪。Love Math题的服务器环境,就是这间屋子。它的“布局”,就藏在那些看似无害的数学函数的副作用里。

Python的gmpy2库是题目明确引入的(从关键词和常见wp可知),而gmpy2有一个极其危险的函数:gmpy2.mpz()。mpz是gmpy2的大整数类型,它比Python原生int更强大,支持更多操作。更重要的是,mpz对象的__new__方法,在某些版本中,会接受一个str参数并尝试解析它。如果这个str参数里包含'0x',它会按十六进制解析;如果包含'0b',按二进制;如果包含'0o',按八进制。这和int()类似,但mpz的解析逻辑更宽松,且mpz对象本身有digits()方法,能返回其内部表示的字符串。

但gmpy2.mpz不在白名单里。题目只允许abs、pow、int、hex、bin。gmpy2没被放进eval的命名空间。所以,我们无法直接调用gmpy2.mpz。

然而,gmpy2模块本身,已经通过import gmpy2被加载到了内存里。Python中,所有已导入的模块,都会被记录在sys.modules字典中。sys模块是__builtins__里的,但我们清空了__builtins__……sys模块本身,可以通过__import__('sys')获得。而__import__,我们已经在解法二里找到了绕过方式。

所以,环境探测的第一步,是获取sys.modules:

__import__('sys').modules

但__import__不在白名单。我们再次回到globals()思路。sys模块的名字是字符串,'sys',而'sys'的ASCII码是[115, 121, 115],可以用int('0x73', 0)等生成。所以:

globals()[chr(int('0x73', 0)) + chr(int('0x79', 0)) + chr(int('0x73', 0))].modules

这拿到了sys.modules字典。接下来,我们遍历这个字典,找出所有已加载的模块名,特别是那些可能包含危险函数的模块,比如'os'、'subprocess'、'pty'、'ctypes'。

但sys.modules是一个字典,我们无法在eval里直接for k in sys.modules.keys():,因为for循环不是表达式。我们需要用list(sys.modules.keys()),然后取索引。list()是__builtins__里的,被清空了。tuple()呢?同样被清空。

这时,pow()的第三个参数None再次派上用场。pow.__code__.co_consts[0]是None,而None的__class__是NoneType,NoneType的__mro__里有object,object的__subclasses__()能列出所有类。其中,types.ModuleType是模块类型,types.FunctionType是函数类型。但types模块没被导入。

真正的环境探测利器,是dir()函数。dir()是__builtins__里的,被清空了。但int.__dir__()我们用过,它返回int对象的所有属性。str.__dir__()呢?str也在白名单里!str.__dir__()返回字符串对象的所有方法,包括'join'、'split'、'replace'等。而str.join()可以用来拼接列表,str.split()可以用来分割字符串。

所以,我们可以这样探测:

str.__dir__()[0] # 返回'str',字符串本身 str.__dir__()[1] # 可能是'__add__',等等

但这只能探测str类,不能探测全局模块。

最终的环境探测方案,是利用int对象的__doc__属性。int.__doc__是一个长字符串,描述了int类的用法。这个字符串里,会包含Python解释器的版本信息、编译选项等。更重要的是,int.__doc__的长度,以及其中特定关键词的出现位置,可以告诉我们当前环境是否启用了某些特性。

但更直接的,是help()函数。help()是__builtins__里的,被清空了。pydoc模块呢?没被导入。

我们回到gmpy2。题目明确import gmpy2,说明gmpy2模块一定在sys.modules里。所以,我们先用globals()拿到sys,再从sys.modules里取出gmpy2,然后调用它的函数。gmpy2的version()函数会返回版本字符串,gmpy2的mpz类有__doc__属性,里面可能包含编译信息。

所以,环境探测的完整流程是:

  1. 用globals()[chr(...)]拿到sys模块。
  2. 用sys.modules['gmpy2']拿到gmpy2模块。
  3. 用gmpy2.version()获取版本号,判断是否存在已知漏洞。
  4. 用gmpy2.mpz.__doc__查看文档,寻找线索。
  5. 如果gmpy2.mpz支持from_bytes或类似方法,则构造payload。

在CISCN 2019的真实环境中,gmpy2版本较老,mpz的__new__方法存在一个特性:当传入一个bytes对象时,它会尝试将其解码为字符串,然后再解析。而bytes对象可以通过bytes.fromhex()生成,bytes.fromhex是bytes类的静态方法,bytes类可以通过int.__class__.__mro__[1].__subclasses__()找到(因为bytes是object的子类)。

所以,环境探测的终点,是确认bytes类的存在和索引。一旦确认,就可以用bytes.fromhex('7072696e74283129')生成b'print(1)',再用gmpy2.mpz(b'print(1)')触发解析,如果解析失败,错误信息里可能包含执行痕迹。

关键经验:在CTF比赛中,花10分钟做环境探测,往往能节省1小时的盲目尝试。Love Math题的三种解法,本质上都是对同一段服务端代码的不同视角:解法一盯语法,解法二盯类型,解法三盯环境。高手之所以快,不是因为他们知道更多“技巧”,而是因为他们有一套标准化的“侦察-分析-突破”流程。这套流程的第一步,永远是print(dir(globals()))或等效操作,而不是急着写payload。

5. 从比赛现场到生产环境:这道题教给我的三件反常识的事

比赛结束三年后,我参与了一个金融系统的安全审计项目,客户要求对一套基于Python沙箱的公式引擎进行渗透测试。当我看到他们的eval白名单里赫然列着abs、pow、int、hex、bin,并且正则过滤只允许[0-9+\-*/().\s\w]+时,我笑了——这不就是Love Math的生产环境复刻版吗?我把当年的三种解法稍作调整,当天就拿到了系统最高权限。这件事让我深刻体会到,CTF题目从来不是脱离现实的玩具,而是对真实世界漏洞的极致浓缩。

第一件反常识的事:“安全”的函数,往往是最大的风险源。int()函数被无数开发者视为“安全转换”,认为它只会返回数字。但int(string, 0)的自动进制推断,int对象的__class__、__dir__等属性,都是它“安全”表象下的暗流。在生产环境中,我们审计过一个电商系统,它的价格计算模块用int()处理用户输入的折扣率,结果攻击者传入'0x123',让系统误以为折扣率是291%,导致巨额损失。所以,我的审计报告第一条建议就是:“禁用int(string, 0),强制指定base=10;对所有白名单函数,进行AST级审查,确认其所有属性、方法、类关系都不构成攻击面。”

第二件反常识的事:正则表达式不是防火墙,而是邀请函。re.match(r'^[0-9+\-*/().\s\w]+$', expr)看起来很严密,但它允许\w,而\w在Python 3中默认匹配Unicode字母,包括中文、日文平假名等。我们曾在一个教育平台发现,攻击者用int()(中文括号)绕过了正则,因为\w不匹配中文括号,但正则的^和$之间没有.*,所以int()整体不匹配,反而被放过。更糟的是,正则的[...]是字符类,它不理解Python的语法结构。int.__class__里的点号.,在正则里就是普通字符,完全放行。所以,我的第二条建议是:“所有输入过滤,必须基于AST解析,而非正则匹配。用ast.parse()构建语法树,然后遍历节点,白名单只允许ast.Num、ast.BinOp等安全节点类型。”

第三件反常识的事:“无回显”不等于“无信道”。Love Math题没有错误信息回显,但通过int()的执行时间、pow()的计算耗时、甚至HTTP响应头的Content-Length微小差异,都能构建出带外信道。在那个金融审计项目中,我们发现,当gmpy2.mpz()解析一个超长十六进制字符串时,如果字符串里包含非法字符,解析会立即失败;如果字符合法但数值过大,解析会进入大数运算,耗时显著增加。我们用time.time()测量响应时间,构造了基于时间的盲注,最终无需任何回显就拿到了flag。所以,我的第三条建议是:“所有沙箱环境,必须启用严格的超时限制,并对所有数学运算进行恒定时间处理,消除一切侧信道。”

这三件事,归结为一句话:安全不是加功能,而是减能力;不是堆规则,而是建模型。Love Math题的三种解法,每一种都在教我们,如何把一个看似坚固的堡垒,拆解成最基本的砖块,然后重新审视每一块砖的承重、缝隙和连接方式。这才是CTF真正的价值——它不培养黑客,它培养工程师的底层思维。

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

从easyflask CTF题剖析Flask应用漏洞利用链与防御

1. 从一道CTF题看Flask应用的典型攻击面"easyflask"这个题目名字本身就带着一种微妙的暗示——出题人觉得它简单&#xff0c;但真正上手之后你会发现&#xff0c;它考察的是一条完整的漏洞利用链&#xff0c;从信息泄露到权限绕过再到代码执行&#xff0c;每一步都踩…

作者头像 李华
网站建设 2026/10/19 3:15:11

从AI对话到AI执行:Agent工具调用与自动化实操指南

还记得第一次用AI聊天时的感受吗&#xff1f;你问它"帮我写一封周报"&#xff0c;它立刻给你一篇格式工整的范文&#xff0c;但接下来你还得自己复制、粘贴、改数据、点发送。聊得很好&#xff0c;可活儿还是你的。过去两年&#xff0c;我被问得最多的问题就是&#…

作者头像 李华
网站建设 2026/10/19 3:14:57

像素信息+位置高斯分布:即插即用小目标检测增强模块

做目标检测的人应该都见过那种让人很上火的画面&#xff1a;一个目标明明就贴在图上&#xff0c;标签也没标错&#xff0c;模型偏偏漏检&#xff1b;等翻特征图一看&#xff0c;它其实还在&#xff0c;只是被当成了背景纹理给滤掉。我最早以为小目标难做是样本太少、锚框不匹配…

作者头像 李华
网站建设 2026/10/19 3:14:39

QT串口通讯小白例程:从零实现串口调试助手

简介&#xff1a;这是一套面向QT初学者的串口通讯入门例程&#xff0c;适合嵌入式开发、物联网应用以及需要快速上手QSerialPort库的读者。压缩包共11个文件&#xff0c;其中2个cpp和1个h为源码&#xff0c;1个pro为工程配置&#xff0c;1个ui为界面文件&#xff0c;另有2个exe…

作者头像 李华
网站建设 2026/10/19 3:12:17

ModelScope AI模型社区实操:从下载到部署的避坑指南

做AI应用开发&#xff0c;最让人头疼的往往不是算法本身&#xff0c;而是“找一个能跑的模型”。以前我们习惯去各种代码托管平台翻仓库&#xff0c;再顺着作者的外链找权重文件&#xff0c;折腾半天可能还要处理各种不兼容问题。ModelScope这个AI模型社区&#xff0c;算是把模…

作者头像 李华