# Ikonboard 2.1.9 Beta -- complete teardown A Perl CGI web forum, released 7 June 2001 by Ikonboard.com of Gosport, Hampshire. Free software, no database, plaintext passwords, 15,526 lines of Perl with HTML in string literals. It ran tens of thousands of communities and then the whole category disappeared. This is a full teardown of the distribution: architecture, every script, every on-disk record format, the security model measured against modern standards, who wrote it and what became of them, and what this particular surviving copy reveals about the night somebody patched it. The distribution is preserved at . Written August 2026. Sources, uncertainties and acknowledgements are in section 12. --- ## Revision history | Version | Date | Change | |---|---|---| | 1.0 | 08/15/2026 | Initial release. Teardown of the distribution: sections 1-8, plus method, toolchain and acknowledgements. | | 1.1 | 08/16/2026 | Added **section 9, Field evidence** -- the records of a board that ran 2.1.9 in production and the June 2002 failure that ended it. Method, toolchain and acknowledgements renumbered 9-11 to 10-12. Section 9 carries its own revision block; its open item on Wayback Machine corroboration is unresolved. | --- ## Contents | | | |---|---| | 1 | [What it is](#1-what-it-is) -- the shape of the thing, and why this copy matters | | 2 | [History and authorship](#2-history-and-authorship) -- Matt Mecham, the dev team, the Invision lineage, the CVEs | | 3 | [Architecture](#3-architecture) -- CGI, flat files, config as executable code, the broken locking | | 4 | [Script reference](#4-script-reference) -- all 32 scripts, every action endpoint | | 5 | [Data formats](#5-on-disk-data-format-specification) -- every on-disk record, field by field | | 6 | [Security](#6-security-teardown) -- 23 findings, 3 critical, by modern standards | | 7 | [Installation and operation](#7-installation-hosting-and-day-to-day-operation) -- how you ran one of these in 2001 | | 8 | [Archaeology](#8-archaeology) -- the .bak diffs and the billion-second patch | | 9 | [Field evidence](#9-field-evidence) -- one board that ran it, and how it died | | 10 | [How to reproduce this](#10-how-to-reproduce-this) -- the method | | 11 | [The toolchain](#11-the-toolchain) -- all seven analysis scripts in full | | 12 | [About this teardown](#12-about-this-teardown) -- uncertainties, acknowledgements, corrections | Sections 4, 5 and 6 are reference material and long. Sections 1, 3 and 8 are the ones to read if you only read three. --- ## 1. What it is **Ikonboard 2.1.9 Beta** is a web forum written in Perl, released on or about **7 June 2001** by Ikonboard.com of Gosport, Hampshire, England. It was free for personal and commercial use; GBP 200 bought you the right to remove the copyright link from the footer. It was written by **Matthew Mecham**, whose name appears in the copyright banner of all 34 Perl files -- `# All files written by Matthew Mecham`. Eight months after this release he co-founded Invision Power Services and built **Invision Power Board**, the commercial forum package that became Ikonboard's successor and outlived it by twenty years. Ikonboard 2.1.x is the code he wrote immediately before that, which makes this distribution a useful thing to have preserved: it is the last complete Perl-era snapshot of a lineage that continues, in PHP, to this day. By 2.1.9 he was not working alone. The admin center credits a ten-person iB 2 development team by handle, and the changelog credits three of them for specific fixes in this release -- none of which the file headers ever acknowledged. Section 2 has the verbatim credits. It ran on shared hosting that offered nothing but a `cgi-bin` and a Perl interpreter. There is no database, no daemon, no framework, and no dependency outside core Perl. Every page view forks a fresh process, reads flat text files off disk, prints HTML to stdout, and exits. Configuration is stored as Perl source code that the application generates and then executes. Passwords are stored in plaintext and carried in a cookie. Between roughly 1999 and 2003 this architecture -- or something very close to it -- was what a forum *was*. Ikonboard, UBB, YaBB and their kin ran tens of thousands of communities, and Ikonboard's own support board had grown past 5,000 members by May 2001. Then PHP and MySQL became universally available on cheap hosting, phpBB and vBulletin arrived, and the entire category was gone within about four years. This document is a complete teardown of the distribution: what every script does, how the data is laid out on disk, how the whole thing was built and operated, who wrote it, what happened to them, and -- at length -- every way it would fall over if you exposed it to the modern internet. #### The distribution 145 files, 839,181 bytes unpacked. 34 Perl programs and libraries totaling **15,526 lines**. 78 GIFs. Six shipped data files, fifteen help texts, five documents. ``` ib219/ +-- cgi-bin/ | +-- ikon.lib the core library -- 1,127 lines, 35 subs | +-- ikonadmin.lib admin authentication | +-- ikonmail.lib mail, adapted from Milivoj Ivkovic's sendmail.pm | +-- install.cgi a four-step installer with real diagnostics | +-- ikonboard.cgi board index -+ | +-- forums.cgi forum view | | +-- topic.cgi topic list +- the public board | +-- postings.cgi thread display | | +-- post.cgi composer -+ | +-- register.cgi profile.cgi loginout.cgi misc.cgi whosonline.cgi | +-- messenger.cgi private messages | +-- search.cgi newposts.cgi printpage.cgi ikonfriend.cgi privacy.cgi | +-- admincenter.cgi -+ | +-- set{forums,members,styles,variables,badwords,membertitles}.cgi | +-- settemplate.cgi announcements.cgi forumoptions.cgi | +-- viewip.cgi checkboard.cgi checklog.cgi help.cgi | +-- data/ config as executable Perl + shipped text | +-- help/ 15 help files, two naming conventions | +-- members/ (empty -- one file per member at runtime) | +-- messages/ (empty -- private message store) +-- non-cgi/ | +-- images/ 51 interface GIFs | +-- emoticons/ 9 emoticons | +-- avatars/ 18 avatars, alphabetical, stopping at F +-- upgrading/ the 2.1.5 -> 2.1.9 format converter +-- README.txt Changes.txt license.html +-- first_install.html help.html ``` #### What makes this copy worth documenting This copy was never installed -- no board ever ran from it, which section 8 establishes from the files that are missing. But it is not the stock 7 June release either. Four files postdate it, and the newest of them tells a story the rest of the archive cannot. `ikon.lib` carries a modification time of **epoch 1000009398** -- two hours, thirty-six minutes and thirty-eight seconds after Unix time first reached ten digits on 9 September 2001. The three-line diff against its own backup is a fix for exactly that event: Ikonboard sorted forum topics by comparing timestamps as **strings**, so the moment `999999999` became `1000000000`, every newly-posted thread sorted below every old one and active discussions vanished from the top of every forum on every board running this software. The file that fixes the ten-digit timestamp bug is itself stamped with the first ten-digit timestamp. That is the find, and section 8 documents it in full -- including why the patch has to have come from Ikonboard.com rather than from any administrator running the software. #### The one-paragraph security summary Passwords are stored as plaintext in field 2 of a pipe-delimited member file, compared with `eq`, and written to a 30-day cookie that is re-sent on every request. There are no session identifiers of any kind -- identity *is* the username and password, held by the browser. CSRF protection consists of checking the `Referer` header and then, if the check fails, **writing a log entry and processing the request anyway**. The XSS filter neutralizes the literal string ` $ikonboard_main ``` `sub output` in `ikon.lib:834-889` performs the merge: ```perl foreach my $line (@templatedata) { $line =~ s/\$page_title/$title/sg; if ($maintenancemode eq "y") { $line =~ s/\$ikonboard_main/\n$maintmodetext\n\n\n/sg; } else { $line =~ s/\$ikonboard_main/$output\n\n$copyright\n/sg; } print $line; } exit; ``` The entire page body has been accumulated as a single giant string in `$output` by the calling script; `output()` drops it into the placeholder and prints. Note the unconditional `exit` -- `output()` never returns. It is the terminal operation of every request. Two details of interest: - **Maintenance mode** (`$maintenancemode eq "y"`) is handled right here, swapping the body for a "down for maintenance" notice. Per `Changes.txt`, this was new in 2.1.9 and credited to LarryC. - **The copyright block** at lines 855-866 is wrapped in comments reading `### Removing this invalidates your license. Please keep intact.` and `### Keep this program free, and leave that code intact.` -- the license enforcement mechanism is a politely-worded code comment. (See the History chapter: GBP 200 bought you the right to delete these six lines.) The `clckcntr()` JavaScript is a double-submit guard, attached to post forms -- a pragmatic 2001 answer to users double-clicking Submit and creating duplicate posts. There is no server-side idempotency token; this is the whole defense. **Everything else is hardcoded HTML inside Perl string literals.** Ikonboard uses `qq~...~` quoting (tilde as delimiter, chosen because tildes rarely appear in HTML) to embed large HTML blocks with variable interpolation: ```perl $output .= qq~
$boardname welcomes its newest member $cleanlastregistered
... ~; ``` This is why the scripts are so large -- `postings.cgi` is 57 KB and `setforums.cgi` is 53 KB, and the majority of both is table markup. There is no separation of logic and presentation anywhere except the outermost page shell. --- ### 3.6 The global variable soup **No script in the distribution uses `use strict`.** Every variable is a package global in `main::`, shared freely between the calling script and all three libraries. The consequences are pervasive: - `sub title` (`ikon.lib:164`) does not return anything. It *assigns* to the global `$output`, and callers rely on that side effect. `sub mischeader` similarly does `$output .= ...` after itself calling `&title`. - `sub getmember` (`ikon.lib:373`) returns nothing useful; it populates 22 globals (`$membername`, `$password`, `$membertitle`, ...) plus the `%allowedentry` hash. Calling it twice for two different members silently clobbers the first. - `sub moderator` (`ikon.lib:270`) sets the global `$inmembmod` to `"yes"`/`"no"` and builds `$modoutput`. - `$trash` is used as a universal discard variable in `split` assignments -- and because it's a global assigned repeatedly in one statement, e.g. `($trash, $topictitle, $topicdescription, $threadstate, $trash) = split(...)`, its final value is whatever landed last. There is a mix of `my`, `local`, and bare globals, sometimes within the same sub -- `sub getmember` uses `my $nametocheck` but assigns to global `$filetoopen`; `sub mischeader` uses `local($misctype)`; `sub whosonline` uses `local($instruct)`. This inconsistency is one of the clearest fingerprints of multiple hands and multiple eras in the codebase. `sub set_up_guest` (`ikon.lib:1100-1124`) exists purely to clean up after this design: when a post references a member whose file no longer exists, `getmember` leaves the previous member's globals in place, so `set_up_guest` manually blanks all 22 of them and substitutes "Deleted Member". The comment above it is honest -- `# Used to fix deleted members bug`. --- ### 3.7 Data storage: one file per entity ``` cgi-bin/ikonboard/ +-- data/ | +-- boardinfo.cgi <- config (Perl) | +-- styles.cgi <- colors (Perl) | +-- membertitles.cgi <- ranks (Perl) | +-- progs.cgi <- script map (Perl) | +-- allforums.cgi <- ALL forums, one pipe-delimited line each | +-- boardstats.cgi <- totals (Perl) | +-- onlinedata.dat <- who's online, rewritten every pageview | +-- hacklog.cgi <- security log (HTML fragments) | +-- template.dat <- page shell | +-- privacy.dat, register.dat <- static text shown to users | +-- ... +-- members/ | +-- FieRcE_YeD.cgi <- ONE FILE PER MEMBER, single pipe-delimited line | +-- Highlander.cgi | +-- ... +-- messages/ | +-- Bob_msg.cgi <- private message inbox per member +-- forum1/ | +-- list.cgi <- denormalized topic index (sort cache) | +-- 1.pl <- topic HEADER, one line, 10 fields | +-- 1.thd <- the POSTS, one line each, 7 fields | +-- 1.mal <- email-notify subscribers (if any) | +-- 2.pl 2.thd | +-- ... +-- forum2/ +-- help/ ``` The organizing principle is **one file per entity, one line per record, pipe-delimited fields**. Member names become filenames with spaces converted to underscores (`ikon.lib:375`, `$nametocheck =~ s/ /\_/g`). A topic is split across two files sharing a numeric stem. `.pl` holds only the topic **header** -- a single line of 10 fields (title, state, counts, first/last poster and times). `.thd` holds the **posts**, one 7-field line each. This is why `rebuildLIST` can index an entire forum by opening each `.pl` and reading one line: the message bodies, which are the bulk of the data, live in a separate file it never touches. Neither extension is honest -- `.pl` files contain no Perl, and `.thd` is not a recognized type at all. The member record, from `ikon.lib:383`, is 22 pipe-separated fields: ```perl ($membername, $password, $membertitle, $membercode, $numberofposts, $emailaddress, $showemail, $ipaddress, $homepage, $aolname, $icqnumber, $location, $interests, $joineddate, $lastpostdate, $signature, $timedifference, $privateforums, $useravatar, $misc1, $misc2, $misc3) = split(/\|/,$filedata); ``` Field 2 is the password, in plaintext. `$privateforums` (field 18) is a nested structure: `&`-separated `key=value` pairs, unpacked at `ikon.lib:385-392` into `%allowedentry` to control private forum access. Because `|` is the record separator, it must never appear in user content -- `sub cleaninput` and friends convert it to the HTML entity `|` before storage. That single substitution is the entire integrity guarantee for the storage layer. *(Full field-by-field specifications for every file are in the Data Formats chapter.)* --- ### 3.8 The `list.cgi` sort cache -- and the bug that ate the board Reading every topic file in a forum just to display the topic list would be prohibitively slow, so each forum directory keeps a denormalized index at `forum/list.cgi`: one line per topic, carrying the fields needed to render the listing, pre-sorted by last-post time descending. `sub rebuildLIST` (`ikon.lib:1025-1069`) regenerates it by scanning the directory, opening every `*.pl` topic file, reading only its first (header) line, and re-sorting: ```perl @entry = grep(/pl/,@dirdata); foreach (@entry) { ($id, $tr) = split(/\./,$_); my $file = "$ikondir" . "forum$IN{-Forum}/$id.pl"; open (TMP, "$file"); flock (TMP, 1); ($topicid, $topictitle, $topicdescription, $threadstate, $threadposts, $threadviews, $startedby, $startedpostdate, $lastposter, $lastpostdate) = split (/\|/,); close (TMP); if ($lastpostdate < 1000000000) { $lastpostdate = "0" . $lastpostdate; } $rr = "$lastpostdate|$topicid|$topictitle|..."; push (@dat, $rr); } @sortdat = sort(@dat); undef @dat; @sortdat = reverse(@sortdat); ``` Note what `sort(@dat)` is doing: **the default Perl `sort` is a string comparison.** The timestamp was deliberately placed in the first field so that lexical sorting would order topics by recency -- which works perfectly, right up until the moment Unix timestamps gain a digit. The line `if ($lastpostdate < 1000000000) { $lastpostdate = "0" . $lastpostdate; }` is the fix: zero-pad 9-digit timestamps to 10 characters so string comparison keeps matching numeric order. Without it, `"1000000000" lt "999999999"` is true and every topic posted after the rollover sorts to the *bottom* of the forum. Unix time hit 1,000,000,000 at **01:46:40 UTC on 9 September 2001**. The `ikon.lib` in this archive is dated **8 September 2001** -- patched roughly a day ahead of the event. See the Archaeology chapter; this file is not the stock 2.1.9 release. `rebuildLIST` is an O(topics) full-directory scan with a file open per topic, triggered on post/delete/move operations. On a large forum this was the board's dominant performance cost and a notorious source of the "disappearing threads" bug that `README.txt` and `Changes.txt` both lead with. --- ### 3.9 Concurrency: `flock` as ritual Ikonboard calls `flock` extensively. It does not use it correctly. `sub openFILE` (`ikon.lib:1071-1097`) is the intended abstraction: ```perl if ($method eq 'r') { open ($FILEHANDLE, $filename); flock $FILEHANDLE, 1; # LOCK_SH, AFTER open } if ($method eq 'wr') { open ($FILEHANDLE, ">$filename"); # <- file is TRUNCATED here flock ($FILEHANDLE, 2); # <- lock acquired after truncation } ``` The `>` in `open` truncates the file **immediately**, before `flock` is ever called. Two concurrent writers both truncate, then queue politely for a lock on a file they have already destroyed. The lock provides no protection for the operation it is meant to protect. The pattern is worse in the read-modify-write cycles that make up most of the board's state changes. `sub whosonline` (`ikon.lib:475-497`) is the clearest example: ```perl open(FILE,"$filetoopen"); # open for read -- no lock at all @onlinedata = ; close(FILE); open(FILE,">$filetoopen"); # reopen for write, truncating flock(FILE, 2); # lock the now-empty file ... rewrite all lines ... close(FILE); ``` The file is closed and reopened between the read and the write. Any lock held during the read is released at `close`. Two simultaneous page views race, and the loser's update is lost -- or, if the timing is unlucky, `onlinedata.dat` is left empty or half-written. This routine runs on **every page view of the entire board**, making it the highest-contention file in the system. Elsewhere `flock(FILE, 2)` -- an *exclusive* lock -- is used on files being opened read-only, e.g. `sub mischeader` at `ikon.lib:95-98` and `sub moderator` at `ikon.lib:274-277`. This is not harmful so much as it is cargo-cult: it serializes readers unnecessarily while protecting nothing. Also note: `flock` is unavailable or a silent no-op on several platforms of the era (notably Windows Perl builds and NFS-mounted filesystems), and none of these calls check the return value. Many boards ran with no locking at all and no way to know. This is the mechanical root of "disappearing threads." `README.txt` opens by acknowledging it: > This Version of IkonBoard attempts to combat the problem of disappearing threads by rewriting some of the process routines. and `Changes.txt` credits SmileyMan with "a huge rewrite of processing routines to try and stop disappearing threads." The word *try* is load-bearing. The rewrite reduced the window; it could not close it, because closing it requires either locking that spans the whole read-modify-write cycle or a database with transactions -- and the architecture had neither. --- ### 3.10 Authentication and session model There are no sessions. There is no session store, no session ID, no server-side login state of any kind. The complete authentication mechanism: 1. `loginout.cgi` receives a username and password by POST. 2. It calls `&getmember($inmembername)`, which reads `members/.cgi` and unpacks the plaintext password into `$password`. 3. It compares (`loginout.cgi:95`): ```perl if (($userregistered ne "no") && ($inpassword eq $password)) { ``` 4. On success it sets two cookies (`loginout.cgi:113-114`): ```perl $passcookie = cookie(-name => "apasswordcookie", -value => "$inpassword", ``` 5. Every subsequent request reads those cookies back (`ikonboard.cgi:45-46`) and treats the presence of a member-name cookie as proof of identity. So: **the user's plaintext password is stored on disk, transmitted in a cookie on every request, and stored in the browser's cookie jar.** In 2001 that traffic was almost universally unencrypted HTTP. There is no `HttpOnly` flag (it did not exist yet -- IE6 SP1 introduced it in 2002), no `Secure` flag, and no expiry on the password cookie in the session case. Authorization is carried by `$membercode` (field 4 of the member record) -- a role marker checked ad hoc in each script -- plus `$privateforums`/`%allowedentry` for per-forum access, and `sub moderator`, which determines moderator status by **string-matching the member's name against a comma-separated list of names stored on the forum record** (`ikon.lib:286-306`). Moderator identity is a name, not an ID; renaming a member silently revokes or grants moderation. The admin center has a second, separate login in `ikonadmin.lib` (`sub adminlogin`), which is the only real privilege boundary in the system. CSRF protection is `sub checkVALIDITY` (`ikon.lib:16-36`): ```perl $rm = $query->request_method(); $rr = $query->referer(); if (($rm eq 'POST') and ($rr !~ /$boardurl/i)) { ... write an entry to data/hacklog.cgi ... } ``` It checks that POST requests carry a `Referer` containing the board URL -- and if they don't, it **writes a log entry and allows the request to proceed anyway.** There is no `exit`, no `&error`, no branch that stops execution. It is a burglar alarm with no lock on the door. *(All of this is analyzed properly in the Security chapter. It is described here because the session model is an architectural fact, not merely a vulnerability.)* --- ### 3.11 Presentation helpers A cluster of subs in `ikon.lib` handle the transformation from stored text to displayed HTML: - **`sub cleaninput` / `unHTML` / `cleanarea`** (`ikon.lib:602-649`) -- three near-identical escapers applied on *input*, differing only in whitespace/newline handling. They escape `&`, `"`, `<`, `>`, and `|`, strip HTML comments, and specially neutralize the literal string ` $ikonboard_main ``` #### 9.7 `data/privacy.dat` and `data/register.dat` -- raw HTML fragments (SHIPPED) Neither is Perl and neither is ever written by a script -- edit by FTP. * `privacy.dat` -- slurped and emitted by `privacy.cgi:71-89`. One post-processing rule: `privacy.cgi:91` does `$output =~ s/\$adminemail_in/$adminemail_in/sg`, so the literal text `$adminemail_in` inside the file is a **substitution token**. The shipped file uses it twice. Excerpt (documents the on-disk model from the user's side -- note it names the exact cookies): > Membership: This is so the board can 'remember' your username when using the bulletin board. > This cookie is called 'amembernamecookie'. ... > Last Visit Data: So that you can be made aware of any new posts since your last visit, two > cookies are stored. One called 'templastvisit', the other 'lastvisit'. * `register.dat` -- the registration terms, emitted by `register.cgi:494-497`. No substitution. Excerpt: > Considering the real-time nature of this bulletin board, it is impossible for us to review > messages or confirm the validity of information posted. ... --- ### 5.10 `messages/` -- the private message store There is no central PM table. Each member owns **two append-at-top mailbox files**: | Path | Role | |---|---| | `messages/_msg.cgi` | **Inbox** | | `messages/_out.cgi` | **Outbox** (sender's own copy) | A single send therefore writes **two independent copies** of the message body -- one to the recipient's inbox, one to the sender's outbox (`messenger.cgi:411-447`). Deleting one does not affect the other. For recovery this is a gift: a PM survives if *either* party's file survives. **Structure:** line-oriented, 5 pipe-separated fields, `\n`-terminated, **newest first** (new messages are prepended, `messenger.cgi:421` / `:441`). | # | Field | Type | Meaning (inbox) | Meaning (outbox) | |---|---|---|---|---| | 0 | `from` / `to` | string | Sender's member name | **Recipient's** member name | | 1 | `readstate` | enum | `no` (unread) \| `yes` (read) | always `yes` | | 2 | `date` | epoch | Send time | Send time | | 3 | `messagetitle` | string | Subject, `maxlength=80` on the form | same | | 4 | `message` | string | Body, `cleaninput`-escaped. **Last field.** | same | **Read** -- `messenger.cgi:209` (list), `:319` (outbox read), `:645` (inbox read). **Write** -- `:421` (deliver to inbox), `:441` (copy to outbox), `:655` (mark read), `:758` (delete one), `:256-262` (`unlink` the whole mailbox for "delete all"). **Message identity is positional.** The URL is `messenger.cgi?action=read&msg=` where `N` is the **0-based line index** into the mailbox file (`messenger.cgi:642-645`, `$msgtograb = @inboxmessages[$inmsg]`). There is no message id. Delete any message and every later message's "id" shifts. This is why the format has no dangling references -- nothing references a PM. **Unread badge.** `ikon.lib sub title` (`ikon.lib:179-199`) opens the inbox on **every page load** and counts lines whose field 1 is `no`. **Mark-as-read quirk.** `messenger.cgi:655` writes the modified line as `print FILE "$from|yes|$date|$messagetitle|$post";` with no `"\n"`. This is correct only because `$post` was split from the *unchomped* array element and still carries its trailing newline. If the source line was the file's last and lacked a newline, the following lines would run together. In practice all mailbox lines are `\n`-terminated (`:424`, `:444`, `:658`), so it holds. **Example inbox file:** ``` Bob|no|991240500|Re: your avatar|Where'd you get it?
Looks great. Jane Doe|yes|991239000|Welcome|Hi and welcome to the board! ``` --- ### 5.11 Remaining `data/` files #### 11.1 `data/news.cgi` -- announcements **Created/written by:** admin only, via `announcements.cgi` -- add at `:229-238`, delete at `:117-138`, edit rewrites the same way. **Read by:** `announcements.cgi:258-270`, and `ikonboard.cgi:146-153` which shows only line 0. Line-oriented, **3** pipe fields, newest first, `\n`-terminated: | # | Field | Type | Meaning | |---|---|---|---| | 0 | `announcementtitle` | string | Headline, `maxlength=100` | | 1 | `dateposted` | epoch | Post time | | 2 | `announcementpost` | string | Body, `cleaninput`-escaped, IkonCode + emoticons rendered at display | Identity is again **positional** -- `announcements.cgi?action=delete&number=` is a 0-based line index (`announcements.cgi:130-134`). ``` Server maintenance Saturday|991300000|We'll be down 0200-0400 UTC.

Sorry! Welcome to the board|991000000|Read the rules before posting. ``` #### 11.2 `data/banlist.cgi` -- the ban filter **Written by:** `setmembers.cgi:391-395` (append on ban), `setmembers.cgi:625-641` (rewrite on unban). **Read by:** `register.cgi:104-120` -- checked against every registration attempt. 3 pipe fields: | # | Field | Meaning | |---|---|---| | 0 | `bannedname` | Member name (may be underscored; unban compares after `s/_/ /g`) | | 1 | `bannedemail` | Email -- matched as a **prefix regex**: `if ($emailaddress =~ /^$bannedemail/)` | | 2 | `bannedip` | IP -- also a prefix regex: `if ($ipaddress =~ /^$bannedip/)` -- so `203.0.113.` bans a /24 | *** Corruption bug.** The append path (`setmembers.cgi:394`) is `print FILE "$inmember|$inemailaddress|$ipaddress";` -- **no trailing `\n`**. Banning two members in sequence concatenates them onto one physical line: ``` Spammer|spam@bad.example|203.0.113.5Troll|troll@bad.example|198.51.100.9 ``` Field 2 of the first record then contains `203.0.113.5Troll`. The unban path (`setmembers.cgi:634-639`) *does* write `"$_\n"`, so a single unban silently re-lineates whatever was already glued together -- but only into the same bad split. Expect malformed `banlist.cgi` on any board that banned more than one person. A recovery parser should split on `\n` and then heuristically re-split runs like `|`. Also note: `$ipaddress` at `setmembers.cgi:394` is whatever `getmember` last loaded -- i.e. the banned member's **registration** IP (field 7), not a current one. #### 11.3 `data/badwords.cgi` -- word substitution map **Written by:** `setbadwords.cgi:74-77`. **Read by:** `post.cgi:323-336`, `post.cgi:763-776`, `register.cgi:161-181`, `profile.cgi:441-457`, `postings.cgi:1259-1270`. **Single line, no newlines.** Format: `&`-separated pairs, each pair `bad=good`: ``` damn=d*amn&hell=h*ll&arse=a*se ``` The editor's textarea is converted with `s/\n/\&/g` before saving (`setbadwords.cgi:70-71`) and back with `s/\&/\n/g` for display (`:124`). Substitution is applied case-insensitively (`s///ig`) to post bodies, topic titles, topic descriptions, and -- when `$membernamefilter eq "yes"` -- to member names and signatures at registration/profile-save time. **Warning for a parser:** the `bad` side is interpolated straight into a regex, so a badword containing regex metacharacters is a live pattern, not a literal. Default fallback when the file is missing (`setbadwords.cgi:118`): `damn=d*amn\nhell=h*ll`. #### 11.4 `data/onlinedata.dat` -- who's online **Written by:** `ikon.lib sub whosonline` (`ikon.lib:462-520`), called from nearly every page. **Read by:** `whosonline.cgi:81-84`. 3 pipe fields, `\n`-terminated, arbitrary order: | # | Field | Meaning | |---|---|---| | 0 | `savedusername` | Member name, or a string beginning `Guest` | | 1 | `savedtime` | Epoch of last activity | | 2 | `savedwhere` | **A raw HTML fragment** describing the activity, e.g. `Viewing topic Best FF game ever? in Off Topic` | Rows are expired on write: any row older than `time - 15*60` is dropped (`ikon.lib:469-470`, `:491`). This file is **pure ephemeral state** and carries no historical value -- but it is a useful forensic snapshot of who was on the board at the moment the archive was taken. Note field 2 embeds pipe-free HTML because the caller composes it from already-escaped values; guest rows carry the literal name `Guest` (`ikon.lib:474`, `whosonline.cgi:101`). #### 11.5 `data/hacklog.cgi` -- the security log **Appended by:** `ikon.lib sub checkVALIDITY` (`ikon.lib:16-36`) on any POST whose `Referer` doesn't contain `$boardurl`, and by `ikon.lib sub systemerror` (`ikon.lib:41-58`). **Read by:** `checklog.cgi:117+`. **Deleted by:** `checklog.cgi:66-68`. **Not delimited.** Free-form HTML, appended in blocks: ``` ------Log Entry------


Reason for log entry -

Time of log entry - June 5, 2001, 3:14pm

Details Logged
HTTP_HOST = example.com
HTTP_REFERER = ...
...
``` It dumps the full CGI environment sorted by key, **excluding any variable whose name matches `/password/i`** (`ikon.lib:31`). Records are delimited by the literal line `------Log Entry------`. Contains IP addresses, referers, and user agents -- treat as sensitive. --- ### 5.12 `help/` files Raw HTML fragments, slurped and emitted verbatim. The **file extension is the access control**: | Pattern | Audience | Read at | |---|---|---| | `help/.dat` | public | `help.cgi:97` | | `help/.cgi` | moderators/admins only (`membercode` must be `ad` or `mo`) | `help.cgi:202`, gated at `:178` | The topic name is taken straight from the query string (`?helpon=Post_Markers`) and run through `stripMETA`; underscores in the filename are rendered as spaces in the heading. Shipped set: * Public (`.dat`): `Active_Users`, `Announcements`, `Lost_Password`, `Making_a_post`, `Messenger`, `Post_Markers`, `Profile`, `Registering` * Mod-only (`.cgi`): `Deleting_Threads`, `Locking_threads`, `Moderation_mode`, `Moving_Topics`, `Postings`, `Unlocking_threads` * `help/admin/fontsizing.html` -- admin-center help, new in 2.1.9 * `help/index.html` -- a **fake Apache "401 Authorization Required" page**, byte-identical to the one `register.cgi:289-305` writes into `members/`. It is a decoy, not a real access control. Excerpt, `help/Post_Markers.dat` -- documents the cookie-based read-marker model described in section 5.14: > As a registered member, all new posts made while you're away from the board will be marked on > your return. You'll have to log in to view them, and they'll remain 'marked' until you finish > your session on the bulletin board. When you leave the board, all post markers are updated > ready for your return. --- ### 5.13 `search/` -- transient search result cache **Directory auto-created 0777** by `search.cgi:106-109` if absent. **Path:** `search/_sch.txt` where `` is the mangled member name, or for guests `Guest` (`search.cgi:57-65`): ```perl $filename = $inmembername; if (!$filename) { $filename = "Guest$ipaddress"; $filename =~ s/\.//g; } $filename =~ y/ /_/; $filename = "$filename" . "_sch.txt"; ``` **Lifetime: 30 minutes.** Every invocation of `search.cgi` sweeps the directory and unlinks any file whose mtime is older than `time - 30*60` (`search.cgi:68-75`). Expired results produce *"Sorry, the search results are only active for thirty minutes"* (`search.cgi:454`). **Structure -- a 5-line header followed by N result rows.** Header (`search.cgi:124-129`), one value per line: | Line | Content | Domain | |---|---|---| | 0 | `CUR_TIME` | epoch of search start | | 1 | `SEARCH_STRING` | raw query | | 2 | `TYPE_OF_SEARCH` | `keyword_search` \| `username_search` | | 3 | `REFINE_SEARCH` | `both_search` \| `topictitle_search` \| `post_search` | | 4 | `FORUMS_TO_SEARCH` | `all` or a forum id | Result rows, appended in batches as each forum is scanned (`search.cgi:291-295`), 7 pipe fields: `forumid|topicid|topictitle|topicdescription|forumname|postdate|` where match reason is `Found Word ` or `Started by `. This directory is a **cache with no archival value** -- but its presence in an archive can reveal what people were searching for, and guest filenames leak IP addresses. --- ### 5.14 Cookies (not on-disk, but part of the model) Two of these encode state you will see referenced from the data files, so they are documented here for completeness. All are set with `-path => $cookiepath`. | Cookie | Value | Expiry | Set at | |---|---|---|---| | `amembernamecookie` | member name, **spaces not underscores** | +30d | `post.cgi:85-88`, `register.cgi:257`, `loginout.cgi:109` | | `apasswordcookie` | **the plaintext password** | +30d | `post.cgi:89-92`, `loginout.cgi:113` | | `adminname` / `adminpass` | admin credentials, plaintext | session / +1d | `admincenter.cgi:69-72`, `install.cgi:309-315` | | `lastvisit` | per-forum read markers | +30d | `ikon.lib:441-444` | | `templastvisit` | session copy of the above | session | `ikon.lib:414-416`, `:446-448` | **`lastvisit` encoding** (`ikon.lib:409-450`): pairs `-` joined by `--`, with a trailing `--`: ``` 1-991230000--4-991239000--9-991100000-- ``` Parsed by `split(/\--/)` then `split(/\-/)` into `%lastvisitinfo`. **The read-state of the entire board lives only in the user's browser** -- there is no server-side read tracking. Consequently a recovered board has no per-user read history at all. Authentication is: send name + plaintext password in cookies, compare with `eq` against member field 1 on every request. There is no session table, no token, no hash. --- ### 5.15 Entity relationship overview ``` data/boardinfo.cgi (Perl fragment: global config) data/boardstats.cgi (Perl fragment: 4 counters, drift-prone) data/styles.cgi (Perl fragment: presentation + $maxthreads) data/progs.cgi (Perl fragment: script name map) data/membertitles.cgi(Perl fragment: rank ladder by post count) | v +---------------------- data/allforums.cgi ----------------------+ | one line per FORUM, 15 fields | | PK : field 0 forumid -> directory forum/ | | cat : fields 1,2 (name, place) -> categories are DENORMALIZED, | | there is no category file | | FK-ish: field 5 forummoderator -> members/.cgi (CSV names)| | FK-ish: field 10 lastposter -> members/.cgi | +-------------------------------------------------------------------+ | 1 : N v forum/.pl forum/list.cgi +---------------------------+ +------------------------------+ | ONE line, 10 fields | -- cached --> | every .pl line, same 10 fields| | PK : field 0 topicid | rebuildLIST | sorted by field 9 DESC | | FK : field 6 startedby -->| (STRING sort!) | *** DERIVED -- no unique data *** | FK : field 8 lastposter ->| +------------------------------+ +-----------+---------------+ | 1 : 1 forum/.mal v +--------------------------+ forum/.thd | name|email, 1 line/subscr | +------------------------------------+ | FK: field 0 -> member | | one line per POST, 7 fields | +--------------------------+ | ordinal position = post number | | FK : field 0 membername ----------+--> members/.cgi | DUP : field 1 topictitle (copy) | +------------------------------+ | field 6 = body (escaped HTML + | | ONE line, 22 fields | | IkonCode) | | PK : filename stem (mangled) | +------------------------------------+ | field 3 membercode: | | ad | mo | me | banned | | field 14 lastpostdate: | | epoch %%% url %%% title ---+-> topic | field 17 privateforums: | | "id=yes&id=yes&" ----------+-> forums +-----------+------------------+ | 1 : 2 v messages/_msg.cgi (inbox) messages/_out.cgi (outbox) +------------------------------------+ | one line per MESSAGE, 5 fields | | identity = LINE NUMBER (0-based) | | FK: field 0 -> member name | | each PM stored TWICE (both parties)| +------------------------------------+ data/banlist.cgi -> name/email-prefix/ip-prefix (matched at registration) data/news.cgi -> announcements, identity = line number data/badwords.cgi -> single-line "bad=good&bad=good" substitution map data/onlinedata.dat, search/*, data/hacklog.cgi -> ephemeral ``` **Referential integrity: none.** Every "foreign key" is a member *name* stored as text. Renaming a member is impossible through the UI, and deleting one leaves every post, PM, moderator list and `lastposter` field pointing at a name with no file. The board handles this at read time via `set_up_guest` (section 5.2) rather than by cleaning up data -- which is excellent for archival recovery. **The only true primary keys are filesystem paths:** `forum/` for forums, `.pl`/`.thd` stems for topics, `members/.cgi` stems for members. Ordinal position within a file is the key for posts, PMs and announcements. --- ### 5.16 Locking and concurrency Ikonboard uses advisory `flock` with **numeric** constants (`Fcntl` is never imported): `flock(FH, 1)` = `LOCK_SH`, `flock(FH, 2)` = `LOCK_EX`. The centralized helper is `ikon.lib sub openFILE` (`ikon.lib:1071-1097`) with modes `r` (read + `LOCK_SH`), `wr` / `wrx` (truncate + `LOCK_EX`), `ap` (append + `LOCK_EX`) -- but **most call sites don't use it** and hand-roll `open` + `flock` instead. Per-file behavior: | File | Read lock | Write lock | Notes | |---|---|---|---| | `data/allforums.cgi` | `flock 1` at `post.cgi:378`; **`flock 2` at `ikon.lib:96`, `:275`, `:545`, `:1012`, `setmembers.cgi:366`** | `flock 2` | * several *read* paths take an **exclusive** lock on a read-only handle -- a scalability bug, and it means a crashed reader can block writers | | `forum/list.cgi` | `flock 1` (`topic.cgi:89`, `forums.cgi`, `newposts.cgi:149`) | `flock 2` | rewritten in full on **every topic view** -- the hottest write on the board | | `forum/.pl` | `flock 1` (`post.cgi:780`, `ikon.lib:1043`) | `flock 2` | | | `forum/.thd` | mixed: `flock 2` at `post.cgi:570`, `flock 1` at `post.cgi:616`, **no lock** at `post.cgi:753`, `postings.cgi:924` | `flock 2` | | | `members/.cgi` | **no lock** in `sub getmember` (`ikon.lib:379`); `flock 2` in `getmemberstime` (`ikon.lib:403`) | `flock 2` | | | `messages/*` | **no lock on read** (`messenger.cgi:184`, `:411`, `:637`) | `flock 2` | | | `data/boardstats.cgi` | via `require` (no lock) | `flock 2` | | | `data/banlist.cgi` | none | **none on append** (`setmembers.cgi:392`) | see section 5.11.2 | | `data/badwords.cgi` | none | **none** (`setbadwords.cgi:75`) | | | `data/onlinedata.dat` | none | `flock 2` | | | `data/hacklog.cgi` | -- | **none** (`ikon.lib:24`, `:48`) | append-only | **The fatal pattern.** Every update is *read-all -> modify-in-memory -> truncate -> write-all*: ```perl open(FILE, "$filetoopen"); @lines = ; close(FILE); # <- lock released here ... build $processed_data ... open(FILE, ">$filetomake"); flock(FILE, 2); print FILE $processed_data; close(FILE); ``` The exclusive lock is taken **after** `>` has already truncated the file, and the read handle was closed before the write handle opened. There is no atomicity between read and write: two concurrent posters in the same forum will lose one of their updates, and a crash between truncate and print leaves a **zero-byte file**. This is exactly the "disappearing threads" failure mode 2.1.9 tried to fix. Its mitigations are (a) the `$processed_data eq "" || !~ m!\|!` guard before every rewrite (section 6.2), and (b) the new per-topic `.pl` file, so a wiped `list.cgi` can be rebuilt from the `.pl` files rather than lost. **Recovery implication:** zero-byte or half-written `list.cgi` / `allforums.cgi` files are an expected artifact, not evidence of tampering. Rebuild `list.cgi` from `.pl`; rebuild `allforums.cgi` from the surviving `forum/` directories plus `data/allforums.bak` if present. --- ### 5.17 Notes for anyone writing a recovery parser **Read order.** Start from the filesystem, not from the index files: 1. Enumerate `forum*/` directories -> that is your authoritative forum list. Cross-reference `data/allforums.cgi` for names/categories, but a directory with no `allforums.cgi` row is still a real forum full of real posts. 2. Within each, enumerate `*.thd` -> that is your authoritative topic list. `*.pl` gives the header; if a `.pl` is missing, reconstruct: `topicid` = filename stem, `startedby`/ `startedpostdate` = fields 0/5 of `.thd` line 0, `lastposter`/`lastpostdate` = fields 0/5 of the final line, `topictitle` = field 1 of line 0, `threadposts` = lines-1, `threadstate` = unknown (assume `open`), `threadviews` = unknown. 3. Ignore `list.cgi` entirely except as a source for `threadstate` and `threadviews`, the only two fields not derivable from `.thd`. 4. Enumerate `members/*.cgi` -> members. Then sweep every `.thd` field 0 and every `.mal` field 0 for names with no member file: those are **deleted members** whose posts survive. **Splitting.** Use `split('|', line, -1)` semantics (keep trailing empty fields) -- Perl's default `split` drops them, so the *writers* produce records that look short. Always pad to the expected arity rather than rejecting. **Arity you must tolerate:** | File | Expected | Also seen | |---|---|---| | `members/*.cgi` | 22 | **16** (installer-created admin, section 8.2) | | `forum/.pl` | 10 | **11** with a trailing empty (upgrade tool, section 5.4) | | `forum/list.cgi` | 10 | 10 | | `data/allforums.cgi` | 15 | 15 | | `forum/.thd` | 7 | 7 | | `messages/*` | 5 | 5 | | `data/banlist.cgi` | 3 | **glued multi-records** (section 5.11.2) | **Trailing newlines.** `.pl` and `members/*.cgi` are single lines with **no** terminator. `.thd` normally has **no** terminator on its last line, but gains one after any edit or delete. Never use "file ends without newline" as a corruption signal. **Un-escaping -- order matters.** Reverse of `cleaninput`: `|`->`|`, `<`->`<`, `>`->`>`, `"`->`"`, ` `->space, then **`&`->`&` last**. Doing `&` first will corrupt any author-typed `<`. Also expand `
`->newline and `

`->blank line if you want plain text; in signatures the token is `[br]`, not `
`. **Encoding.** Latin-1 (ISO-8859-1). No BOM, no declaration in the data files. Shipped files are CRLF; runtime files are LF. Mixed line endings inside one archive are normal. **The string-sorted-timestamp trap (section 6.1).** If `list.cgi` ordering looks scrambled around September 2001, the board ran unpatched. Re-sort numerically on field 9 and check for a leading `0` on 10-character timestamps (an artifact of the local zero-pad fix). **Counters lie.** `data/boardstats.cgi`, plus `allforums.cgi` fields 12/13, are maintained by `++`/`--` and are wrong on any board that ever deleted anything. Field 12 (`threads`) is additionally corrupted by the `$threads = $newthreadnumber` bug (section 5.3). Recompute from the filesystem. **Detecting corruption:** * Zero-byte `list.cgi` / `allforums.cgi` -> the truncate-then-write race (section 5.16). Rebuild. * A `list.cgi` row whose `topicid` has no matching `.thd` -> topic deleted without a rebuild. * A line in any pipe file containing an odd field count -> check for a missing `\n` in the writer (banlist), or a `|` that escaped `cleaninput` (means the data predates 2.1.x or was edited by hand via FTP). * A `.thd` whose line 0 field 1 differs from the `.pl` field 1 -> the topic title was edited after creation (normal; `postings.cgi sub edit_topic_title` does not rewrite `.thd`). * A member file with a `_` in field 0 -> registered but never posted (section 8.1), not corruption. * `¦` anywhere -> data written by pre-2.1 code paths (`ikonadmin.lib:148`). **Privacy.** A recovered install contains, in the clear: **every member's plaintext password** (member field 1), their registration IP (field 7), the IP of **every individual post** (`.thd` field 2), all private messages in duplicate, and a full CGI-environment log in `data/hacklog.cgi`. Sanitize before publishing. Note that password reuse from 2001 is still a live risk. **Things that are simply not recoverable:** * Per-user read/unread state -- it only ever existed in browser cookies (section 5.14). * Original whitespace in posts (tabs and CRs are deleted, double spaces become ` `). * Post edit history -- edits overwrite in place; only the `(Edited by ...)` marker survives. * `threadviews` for topics whose `list.cgi` and `.pl` were both lost. * Anything in a forum whose directory was deleted -- `setforums.cgi` unlinks every file then `rmdir`s, with no backup. --- ### Appendix A -- file inventory quick reference | Path | Kind | Created by | Delimiter | Fields | Record granularity | |---|---|---|---|---|---| | `data/boardinfo.cgi` | Perl fragment | install / admin | n/a | ~28 named vars | whole file | | `data/boardstats.cgi` | Perl fragment | install / runtime | n/a | 4 named vars | whole file | | `data/styles.cgi` | Perl fragment | shipped / admin | n/a | 31 named vars | whole file | | `data/progs.cgi` | Perl fragment | shipped | n/a | 26 named vars | whole file | | `data/membertitles.cgi` | Perl fragment | shipped / admin | n/a | 17 named vars | whole file | | `data/template.dat` | raw HTML | shipped / admin | n/a | `$page_title`, `$ikonboard_main` | whole file | | `data/privacy.dat` | raw HTML | shipped | n/a | `$adminemail_in` token | whole file | | `data/register.dat` | raw HTML | shipped | n/a | -- | whole file | | `data/allforums.cgi` | delimited | admin | `\|` | 15 | line = forum | | `data/news.cgi` | delimited | admin | `\|` | 3 | line = announcement | | `data/banlist.cgi` | delimited | admin | `\|` | 3 | line = ban (often glued) | | `data/badwords.cgi` | delimited | admin | `&` then `=` | pairs | **whole file = 1 line** | | `data/onlinedata.dat` | delimited | runtime | `\|` | 3 | line = active session | | `data/hacklog.cgi` | free-form HTML | runtime | `------Log Entry------` | -- | block | | `members/.cgi` | delimited | register / install | `\|`, `&`+`=` in f17, `%%%` in f14 | 22 (or 16) | **whole file = 1 record** | | `messages/_msg.cgi` | delimited | runtime | `\|` | 5 | line = inbox message | | `messages/_out.cgi` | delimited | runtime | `\|` | 5 | line = outbox message | | `forum/.pl` | delimited | runtime | `\|` | 10 (or 11) | **whole file = 1 record** | | `forum/.thd` | delimited | runtime | `\|` | 7 | line = post | | `forum/.mal` | delimited | runtime | `\|` | 2 | line = subscriber | | `forum/list.cgi` | delimited | runtime (derived) | `\|` | 10 | line = topic | | `forum/.htaccess` | Apache conf | admin | n/a | -- | whole file | | `search/_sch.txt` | mixed | runtime (30-min TTL) | `\n` header + `\|` rows | 5 header lines + 7 | line | | `help/.dat` | raw HTML | shipped | n/a | -- | whole file | | `help/.cgi` | raw HTML | shipped | n/a | -- | whole file | | `*.bak` (`allforums.bak`, `list.bak`) | delimited | **never written by 2.1.9** | `\|` | as source | line | --- *Compiled from static analysis of the Ikonboard 2.1.9 Beta source tree. Every claim above is traceable to a cited `file:line`. Where the shipped `ikon.lib` (dated 2001-09-08) differs from the distributed `ikon.lib.bak` (2001-06-03), the difference is noted explicitly (section 6.1) -- the analyzed copy carries a local, board-operator-applied timestamp-sorting patch that is not part of stock 2.1.9.* --- ## 6. Security Teardown ### Executive summary Ikonboard 2.1.9 is a flat-file CGI forum whose entire trust model rests on **three plaintext values shuttled between the browser and disk**: a username cookie, a password cookie, and pipe-delimited member records on disk. There is no session layer, no password hashing, no CSRF token, and the "hack log" is the primary security control -- it *records* attacks rather than *blocking* them. The 2.1.8r2 / 2.1.8r2.1 / 2.1.9 change logs show a development team reacting to "a recent slew of kiddie-wannabe hackers" by bolting numeric-input guards onto a few scripts, but the architectural weaknesses were never addressed and could not be without a rewrite (which became iB3). The single most severe issues are the **plaintext password lifecycle** (stored plaintext, transmitted plaintext in a persistent cookie, re-emailed plaintext on request), the **unauthenticated `install.cgi` admin-creation backdoor**, and **admin authentication built on unsigned, client-supplied `adminname`/`adminpass` cookies**. Path traversal, Perl code injection into `require`d config files, stored/reflected XSS, and universal CSRF round out the picture. #### Findings by severity | ID | Title | Severity | CWE | |----|-------|----------|-----| | IB219-001 | Passwords stored in plaintext in member flat files | Critical | CWE-256 / CWE-257 | | IB219-002 | Password used as the session token in a persistent, non-HttpOnly cookie | Critical | CWE-522 / CWE-384 | | IB219-004 | `install.cgi` creates an administrator with no authentication | Critical | CWE-306 | | IB219-003 | Admin auth via unsigned client-supplied `adminname`/`adminpass` cookies | High | CWE-565 / CWE-807 | | IB219-005 | Private-forum access granted by a client-forgeable cookie | High | CWE-639 / CWE-602 | | IB219-006 | Path traversal -- `stripMETA` is a leaky blocklist (keeps `/`, single-pass `..`, no NUL) | High | CWE-22 / CWE-182 | | IB219-007 | Two-argument `open()` + poison-NUL / trailing-pipe primitives | High | CWE-59 / CWE-626 | | IB219-008 | Perl code injection into `require`d config files (`setvariables`/`setstyles`/`settemplate`) | High | CWE-94 | | IB219-010 | Stored XSS/HTML injection -- `` -- the space-only filter on `aimname` and the total lack of filtering on `UIN` allow breaking out of the `value="..."` attribute and injecting markup/handlers. These pop-ups are opened from post author toolbars (`topic.cgi:337,340`), so a crafted profile `aolname`/`icqnumber` can seed the malicious link, or an attacker simply distributes the URL. Reflected -> phish/credential-theft (again yielding the plaintext-password cookie). **2001 lens / modern contrast.** Reflected XSS was pervasive and largely un-modeled in 2001. Same modern fix as IB219-010: contextual output encoding. --- #### IB219-012 -- IkonCode tag injection builds tags/attributes from user input **Severity:** Medium * **CWE-79 / CWE-83 (Improper Neutralization of Attributes)** **Location.** `ikon.lib` `sub ikoncode` translates BBCode-style tags into HTML by regex, interpolating captured groups straight into `src`/`href`/`color`/`face` attributes: ```perl # ikon.lib:921 $post =~ s/\[url\](\S+?)\[\/url\]/$1<\/a>/isg; # ikon.lib:940 $post =~ s/\[img\](.+?)\[\/img\]//isg; # ikon.lib:941 $post =~ s/\[color=(\S+?)\]//isg; # ikon.lib:936 $post =~ s/\[font=\s*(.*?)\s*\]\s*(.*?)\s*\[\/font\]/$2<\/font>/isg; ``` **Mechanism.** Captures are placed inside double-quoted attributes. On HTML-off forums the surrounding `<`/`>` are entity-escaped, and stored `"` are `"`, so *breaking out* of the attribute with a raw quote/angle-bracket is blocked. The residual issues: - **`[img]` javascript/URL-scheme and off-site loading.** `[img][/img]` forces the victim's browser to fetch attacker content (CSRF-via-image, IP logging, and on legacy IE `javascript:`-scheme image handlers). `.+?` permits near-arbitrary content between the tags. - **`[url]` scheme injection.** `[url]javascript:alert(document.cookie)[/url]` yields `` -- a click-to-XSS on browsers honoring the scheme; the naked-URL auto-linkers (`ikon.lib:925-930`) similarly wrap user text into `href`. - **Attribute *value* injection without a quote.** Because `\S+?`/`.*?` allow any non-quote characters, values like `[color=red;xss:expression(...)]` inject CSS (legacy IE `expression()` -> script) into `` without needing to escape the attribute. The `[quote]`/`[code]` handling (`ikon.lib:919,948`) is also fragile (catastrophic-backtracking-prone nested `.*` -- see IB219-022). **2001 lens / modern contrast.** BBCode was invented specifically to *avoid* raw HTML, but naive regex expansion re-introduced injection through attribute values and URL schemes. Modern BBCode/Markdown renderers allowlist URL schemes and encode attribute values. --- ### CSRF #### IB219-013 -- CSRF everywhere; `checkVALIDITY` is Referer-only and log-only **Severity:** High * **CWE-352 (Cross-Site Request Forgery)** **Location.** The only anti-CSRF routine: ```perl # ikon.lib:16 sub checkVALIDITY { $rm = $query->request_method(); $rr = $query->referer(); if (($rm eq 'POST') and ($rr !~ /$boardurl/i)) { ... open(LOG, ">>$filetoopen"); print LOG ...; # ikon.lib:24 -- LOGS ONLY } } ``` It is invoked by `post.cgi:44`, `postings.cgi:42`, `profile.cgi:44`, `register.cgi:45`, `setmembers.cgi:44`, `setforums.cgi:48`, `setstyles.cgi:44`, `setvariables.cgi:45`, `settemplate.cgi:47`, `setmembertitles.cgi:45`, `setbadwords.cgi:44`, `forumoptions.cgi:45`. **Mechanism.** Three fatal weaknesses: (1) it checks only the `Referer` header -- spoofable/omittable by the attacker's page (and frequently blank due to privacy settings, so legitimate posts pass with an empty Referer, forcing the check to be lenient); (2) it only fires for `POST`, so any state change reachable via `GET` (many admin/mod actions are plain links, e.g. `setmembers.cgi?action=deletemember&checkaction=yes&member=X`, `postings.cgi?action=delete`, `forums.cgi?action=...`) is entirely unguarded; (3) **even on a detected cross-origin POST it does not block** -- it appends to `data/hacklog.cgi` and returns, letting the request proceed. There is **no CSRF token anywhere** in the product. **Exploitation walkthrough.** Host a page that auto-submits a form to `setvariables.cgi` (RCE via IB219-008) or `setmembers.cgi?action=edit&checkaction=yes&member=attacker&membercode=ad&...`. When a logged-in admin visits it, their `adminname`/`adminpass` cookies ride along; the action executes. Because there is no token and the Referer check only logs, a single admin click on a malicious link is game over. For non-admin CSRF, forge posts/profile edits/PMs on behalf of any logged-in member. GET-based mod actions (delete/lock/move topic) are forgeable with a bare ``. **2001 lens / modern contrast.** CSRF was not named ("session riding" appears ~2001) and had no standard defense; a Referer check was, briefly, considered *advanced*. The log-but-allow design shows the developer conceived of `checkVALIDITY` as forensic, not preventive. Per-form synchronizer tokens and `SameSite=Lax/Strict` cookies are the modern answer. --- ### Information disclosure #### IB219-014 -- `CGI::Carp "fatalsToBrowser"` in every script **Severity:** Low * **CWE-209 (Generation of Error Message Containing Sensitive Information)** Every script begins `use CGI::Carp "fatalsToBrowser";` (e.g. `post.cgi:16`, `topic.cgi:16`, `admincenter.cgi:17`). Any `die` -- and the code `die`s liberally on file errors (`ikon.lib:95`, `topic.cgi:53` `die "Hack attempt!"`, `postings.cgi:181` `die "Yo, sup: $!"`) -- renders the Perl error, often with `$!` and absolute filesystem paths, to the browser. This aids reconnaissance (paths for traversal, module/versions) and can echo fragments of interpolated data. **2001 lens:** `fatalsToBrowser` was routinely left on in production for convenience; modern deployments log server-side and show generic errors. #### IB219-015 -- Hack log records full `%ENV`, including the plaintext password cookie **Severity:** Medium * **CWE-532 (Insertion of Sensitive Information into Log File)** `checkVALIDITY` and `systemerror` dump the environment to `data/hacklog.cgi`: ```perl # ikon.lib:29 (checkVALIDITY) and :53 (systemerror) foreach (sort keys %ENV) { chomp $_; print LOG qq~$_ = $ENV{$_}
~ unless (/password/i); } ``` The `unless (/password/i)` filter matches on the **ENV variable *name***, not its value. `HTTP_COOKIE` does not match `/password/`, yet its **value** contains `apasswordcookie=` (IB219-002) plus `adminpass=...` if an admin tripped it. So the hack log -- a file named `.cgi` under a web-served directory (IB219-016) and readable via the admin "Check the hack logs" page (`checklog.cgi`) -- accumulates cleartext passwords of anyone who triggers a logged event. `HTTP_COOKIE`, `REMOTE_ADDR`, `HTTP_USER_AGENT` are all captured. **Modern contrast:** log redaction must operate on values, and credentials must never transit a logline. #### IB219-016 -- Member and data flat files are web-servable **Severity:** High * **CWE-538 (File and Directory Information Exposure) / CWE-540 (Information Exposure Through Source Code)** Member records (`members/.cgi`, plaintext passwords/emails/IPs), board data (`data/banlist.cgi`, `data/boardinfo.cgi`, `data/onlinedata.dat`, `data/hacklog.cgi`), and per-forum thread files live **inside the CGI/web tree**, addressable by URL. The `.cgi` extension is a double-edged mitigation: if the server maps `.cgi`->CGI-handler for that subdirectory, a request errors (the data file isn't a valid script); if it does **not** (very common for `members/`, `data/` subdirs, or via `.bak` files like `ikon.lib.bak`, `post.cgi.bak` present in the tree), the file is served as **plaintext**, disclosing every credential. The developer half-recognized this: `register.cgi:270` writes a decoy `members/index.html` (a fake Apache 401 page) "to foil snoopers, and to stop them gaining a list of the directory" -- which stops *indexing* but not *direct file fetches* of known names (`Admin.cgi`, etc.). **Exploitation:** `GET /cgi-bin/members/Admin.cgi` -> `Admin||Administrator|ad|...`. **Modern contrast:** data belongs outside the web root or behind a deny-all rule; secrets never share a namespace with servable content. #### IB219-017 -- Admin/system pages leak Perl version and absolute paths **Severity:** Low * **CWE-200** `admincenter.cgi:173-174` prints `$cgipath` (`$ENV{SCRIPT_FILENAME}` minus the prog name) and `Perl verison is $]` to the admin page; `systemerror`/`error` and the `require`-failure handlers (e.g. `post.cgi:31`) echo paths. Minor recon aid; feeds traversal. #### IB219-018 -- `viewip.cgi` exposes registration and post IPs **Severity:** Info * **CWE-359 (Privacy Violation)** `viewip.cgi` reveals both the post-time IP and the *registration* IP of any poster. It is gated to the forum moderator/admin with a password re-check (`viewip.cgi:141-145`) and is a deliberate moderation feature, so this is documented as *by-design privacy exposure* rather than a flaw -- but worth noting for a preservation project handling real users' PII (IPs are stored in field 8 of every member record and in every `.thd` post line). --- ### Cryptography & randomness #### IB219-019 -- Weak generated passwords **Severity:** Medium * **CWE-330 (Insufficiently Random Values) / CWE-338 (Weak PRNG)** When email verification is on, both registration and email-change generate a password: ```perl # register.cgi:127 (identical block at profile.cgi:503) $seed = int(rand 100000); $password = crypt($seed, aun); $password =~ s/%([a-fA-F0-9][a-fA-F0-9])/pack("C", hex($1))/eg; $password =~ s/\.//g; $password =~ s/\|//g; $password = substr($password, 0, 7); # truncate to 7 chars ``` **Defects.** (1) `rand` is **never seeded** (`srand` is absent; pre-5.004 Perl didn't auto-seed, and even auto-seeding here is time/PID-based, not cryptographic). (2) The keyspace is `int(rand 100000)` -> at most 100,000 possible seeds -> the generated password is one of <=100k values, brute-forceable offline/online in seconds. (3) `crypt($seed, "aun")` uses the **fixed two-char salt `aun`** (a bareword -- `aun` is interpreted as the string) and traditional DES `crypt`, then the output is mangled (strip `.`/`|`) and **truncated to 7 characters**, further shrinking entropy and biasing the distribution. The result is a low-entropy password mailed in cleartext (IB219-020). **Modern contrast:** generate secrets from a CSPRNG (`/dev/urandom`, `Crypt::URandom`) with >=128 bits of entropy; never derive them from a 100k-space unseeded `rand`. #### IB219-020 -- Lost-password feature emails the existing cleartext password **Severity:** Medium * **CWE-522 (Insufficiently Protected Credentials)** `profile.cgi` `sub sendpassword` mails the stored password back verbatim: ```perl # profile.cgi:169 &getmember("$inmembername"); if ($membercode eq "ad") { &blocked; } # admins are protected (see below) elsif ($userregistered ne "no") { $message .= "As per your request, here is your password:\n\n"; $message .= " $password\n\n\n"; # profile.cgi:178 -- cleartext ... &sendmail($from, $from, $to, $SMTP_SERVER, $subject, $message); } ``` **Mechanism.** Only a *username* is required (`profile.cgi:159` form has no ownership proof); the password is sent to the address on file. This is not a reset-token flow -- it discloses the actual reusable credential over cleartext email. It is only possible **because** passwords are stored recoverably (IB219-001). An attacker who knows a target's username triggers an email to the victim (nuisance/phish setup) and, if they can read the victim's mail or the mail transits plaintext, harvests the credential. **The `&blocked` admin carve-out (`profile.cgi:171,573`)** refuses to mail an *administrator's* password and instead emails the admin a "possible hack attempt" warning with the requester's IP/User-Agent -- a thoughtful, targeted mitigation acknowledging that admin credentials are the crown jewels. It does nothing for regular members. **Modern contrast:** password reset must use a single-use, expiring, unguessable token that lets the user *set a new* password; the old password is never known to the system (hashing) and never emailed. --- ### Race conditions / TOCTOU & flat-file locking #### IB219-021 -- `flock` misuse and read-modify-write races **Severity:** Medium * **CWE-367 (TOCTOU) / CWE-362 (Race Condition)** The flat-file store has no transactional discipline. Recurring defects: - **`flock` *after* a truncating `open`.** The universal write idiom is `open(FILE, ">$file"); flock(FILE, 2);` (e.g. `setvariables.cgi:83`, `setstyles.cgi`, `post.cgi:343/352/370/397/410`, `register.cgi:231`, `messenger.cgi:419/439`). `open(">...")` truncates the file **immediately**, before `flock` is acquired -- so a concurrent reader between truncate and write sees an empty/partial file. The lock therefore does not protect the critical operation it appears to guard. - **Read-then-reopen-for-write race.** `whosonline` reads the online list, then reopens the same file `">"` and rewrites it (`ikon.lib:477-497`); `topic.cgi:85-113` reads `list.cgi`, mutates the view counter in memory, then reopens `">$filetoopen"` and rewrites -- two clients viewing the same topic concurrently interleave and lose updates or corrupt the pipe-delimited file. The change logs' repeated laments about "disappearing threads" (README, Changes.txt 2.1.9 "huge rewrite of processing routines to try and stop disappearing threads") are the user-visible symptom of exactly these unsynchronized read-modify-write cycles across `post.cgi`/`postings.cgi`/`topic.cgi`/`rebuildLIST`. - **`flock` on read handles is only advisory `LOCK_SH`** (`flock(FILE,1)`) and is inconsistently applied; many reads omit locking entirely (`viewip.cgi:94`, `printpage.cgi:70`). - **Non-atomic multi-file updates.** A single post touches the `.thd`, `.pl`, `list.cgi`, `allforums.cgi`, member file, and `boardstats.cgi` (`post.cgi:341-417`) with no cross-file lock; a crash or race mid-sequence desynchronizes counts and last-poster metadata. **Exploitation / impact.** Mostly integrity/DoS (corrupted data files, lost posts, wrong counts) rather than direct compromise -- but a determined attacker can race the ban-list write (`setmembers.cgi:392`) or the stats/member rewrites to clobber data, and the truncate-before-lock window can be widened under load to reliably serve empty `boardinfo.cgi`/`styles.cgi` (breaking auth checks that compare against now-blank config). **Modern contrast:** write to a temp file and `rename()` (atomic), or use a database with real transactions; acquire the lock before truncation. --- ### Denial of service / resource exhaustion #### IB219-022 -- User-controlled regular expressions (ReDoS & logic) **Severity:** Medium * **CWE-1333 (Inefficient Regular Expression Complexity) / CWE-625 (Permissive Regex)** Two paths compile **attacker-supplied strings as regexes**: - **Search.** `search.cgi` splits the user's query into `@KEYWORDS` and matches each as a pattern: `if (($post =~ m|$_|gi) ...)` (`search.cgi:242`, and `:224`, `:256`, `:274`). A query like `(a+)+$` or a long alternation triggers catastrophic backtracking against every post in every forum -- a single request pins the CPU (ReDoS). It also means search is a **regex oracle** over private content: crafted patterns leak structure. There is no keyword length/complexity cap and `quotemeta` is never applied. - **Bad-word filter.** `post.cgi:332` / `profile.cgi:452` / `register.cgi:166` apply admin-defined `$bad` as a **regex** (`$inpost =~ s/$bad/$good/ig`). A malformed/`die`-inducing pattern in `badwords.cgi` breaks posting; a backtracking pattern DoSes it. Additionally the nested-`.*` IkonCode `[quote]`/`[code]` regexes (`ikon.lib:919`, `:948`) backtrack badly on adversarial input. **Modern contrast:** never interpolate user text into a pattern without `\Q...\E`/`quotemeta`; bound regex time or use a linear-time engine (RE2). #### IB219-023 -- Full-directory scans and unbounded flat-file growth **Severity:** Medium * **CWE-400 (Uncontrolled Resource Consumption)** `rebuildLIST` (`ikon.lib:1025`) `opendir`s an entire `forum` directory and opens/parses **every** `.pl` on each new post/reply/move/edit (`post.cgi:459`, `postings.cgi:199`). `setmembers.cgi`/`search.cgi`/`docount` scan the whole `members/` directory. As a board grows, every write becomes O(threads) / O(members) directory work -- an attacker can amplify by mass-posting. `$CGI::POST_MAX = 1024*150` (150 KB, set in every script, e.g. `post.cgi:18`) caps a single request, but there is **no per-user post-rate limit beyond the optional `floodcontrol`** (which admins/mods bypass, `post.cgi:142`), no thread/forum count cap, and thread files (`.thd`) grow without bound in one file. Registration writes are similarly uncapped (subject only to the ban list). The `search/` directory accumulates per-user result files pruned only opportunistically on the next search (`search.cgi:70-74`). **Modern contrast:** indexed storage, pagination at the data layer, and rate limiting. --- ### Authorization gaps (survey) The role model is a single `$membercode` field in the member file: `ad` (admin), `mo` (moderator), `me` (member), `banned`. Observations across the state-changing scripts: - **Admin scripts are consistently gated** on `($membercode eq "ad") && ($inpassword eq $password) && ($inmembername eq $membername)` reading `adminname`/`adminpass` cookies: `setmembers.cgi:63`, `setforums.cgi:84`, `setstyles.cgi:71`, `setvariables.cgi:73`, `settemplate.cgi:71`, `setmembertitles.cgi:76`, `setbadwords.cgi:67`, `checkboard.cgi:59`, `checklog.cgi:64`, `admincenter.cgi:87`. The gate is *logically* correct but rests on the weak cookie/credential substrate (IB219-003) and has **no CSRF token** (IB219-013), so cross-site forgery against a logged-in admin drives any of them. - **Mod/edit gates use the front-end password cookie:** `postings.cgi` and `topic.cgi` compute `$inmembmod` via `sub moderator` (parsing the forum's moderator name list, `ikon.lib:270`) and clear actions only when `($inmembmod eq "yes") && ($inpassword eq $password)` (`postings.cgi:162/272/594/...`, `viewip.cgi:141`, `forumoptions.cgi:90`). Sound in principle; inherits IB219-002/013. - **`undef $membercode` reuse.** `topic.cgi:404` `undef $membercode` at the end of each post-render iteration and re-`getmember`s per author -- role rendering is per-post, which is correct, but the same global `$membercode` is later reused for the viewer's mod-tools check (`topic.cgi:433-434`) after a fresh `getmember($inmembername)`; the global-variable style (no `my` on most of these) is fragile and a frequent source of the product's logic bugs. - **Privilege escalation via profile is *not* possible:** `profile.cgi` `savemodify` rewrites the member file but **preserves** `$membercode`/`$membertitle` from the loaded record (`profile.cgi:548`) rather than from the form -- a member cannot self-promote to `ad`. This is a genuinely correct decision (see "what 2001 got right"). - **Private-forum *listing* bypass** remains via the forgeable cookie (IB219-005), inconsistent with the server-side thread-view check. #### IB219-025 -- Weak registration ban and account-enumeration controls **Severity:** Medium * **CWE-203 (Observable Discrepancy) / CWE-799 (Improper Control of Interaction Frequency)** `register.cgi:111-119` compares the applicant against `banlist.cgi` with **prefix/loose** matches: `if ($emailaddress =~ /^$bannedemail/)`, `if ($ipaddress =~ /^$bannedip/)`. The banned values are interpolated **as regex** (unescaped) and anchored only at the start, so `$bannedip` of `10.0.0.1` also matches `10.0.0.10`, `10.0.0.100`, etc. (over-broad), while any registrant can evade an email ban by prepending characters or an IP ban by fronting a different address. Distinct error strings ("user already exists," "Invalid Email," "not allowed to register") plus the directory probe at `register.cgi:215` (`grep { /^$tempinusername\.cgi/i } readdir`) enable **username enumeration**, and there is no rate limit or CAPTCHA on registration or on the lost-password trigger (IB219-020). **Modern contrast:** exact-match structured ban rules, uniform responses to avoid enumeration, and rate limiting / proof-of-work on registration and password-reset. --- ### What 2001 got right A fair teardown notes the defenses that were present and sensible for the era. Several show a developer who was security-*aware* within the constraints of flat-file CGI: - **Post-size cap and upload lockdown, everywhere.** Every script sets `$CGI::POST_MAX = 1024 * 150` and `$CGI::DISABLE_UPLOADS = 1` (e.g. `post.cgi:18-19`). Disabling uploads at the `CGI.pm` layer preempts an entire class of file-upload-to-RCE bugs that sank contemporaries; capping POST size blunts memory-exhaustion. `register.cgi:20` even adds `$CGI::HEADERS_ONCE = 1`. - **A real (if leaky) input-sanitizer discipline.** `stripMETA` (IB219-006) is a genuine attempt at taint-style path hygiene, and the newer scripts pair it with **strict allowlist numeric guards** -- `topic.cgi:53` `die "Hack attempt!" unless $inforum =~ m!\A\d{1,3}\Z!;`, `forums.cgi:52`, and the `!~ /^[0-9]+$/` checks in `post.cgi:100`, `postings.cgi:89-92`, `viewip.cgi:55-57`, `printpage.cgi:50-52`, `messenger.cgi:96`. Anchored `\A\d{1,N}\Z` allowlists are exactly the right primitive; had they been applied to *every* interpolated identifier (member names, help/file names) instead of just forum/topic/post numbers, most traversal exposure would close. This is the 2.1.8r2/2.1.9 "security fixes" work visible in the diff. - **Role stored server-side, separate from the cookie.** `$membercode` lives only in the member file; the profile editor deliberately does not let a user rewrite it (`profile.cgi:548`), so there is no self-service privilege escalation -- a mistake many peers made. - **Admin lost-password lockdown.** `sub blocked` (`profile.cgi:171,573`) refuses to email an administrator's password and instead alerts the admin with the requester's IP/User-Agent -- a targeted, thoughtful control around the highest-value credential. - **Installer presence check.** `admincenter.cgi:58-68` hard-fails while `install.cgi` exists, nudging operators to remove the backdoor (IB219-004). Imperfect (manual, and the window is real) but better than silence. - **Directory-listing decoy.** `register.cgi:270` drops a fake `401 Authorization Required` `index.html` into `members/` to suppress indexing -- an honest attempt at defense-in-depth for the web-exposed data dir (IB219-016). - **Operational safety nets.** Optional **flood control** (`post.cgi:142`), a **ban list** (username/email/IP), a **bad-word filter** applicable to posts, names, and signatures, an `allforums.bak`/`checkboard.cgi` **backup-and-restore** path (`admincenter.cgi:100-107`), and a **hack log** for forensics. The log-only philosophy is the wrong choice for `checkVALIDITY` (IB219-013), but as *telemetry* it reflects a defender's mindset. - **Password excluded from logs -- the right idea, wrong granularity.** The `unless (/password/i)` log filter (IB219-015) shows intent to redact secrets; it fails only because it matches variable *names* rather than *values*. None of these offset the Critical findings, but they demonstrate the code was written by someone thinking about abuse -- within a language/hosting paradigm (two-arg `open`, `require`-as-config, plaintext cookies, no sessions) whose defaults made robust security nearly unattainable without the ground-up redesign that became Ikonboard 3. --- ### Appendix -- file/line index of primary sinks | Concern | File:line | |---|---| | Plaintext password read | `ikon.lib:383` (`getmember`) | | Plaintext password write | `register.cgi:233`, `profile.cgi:548`, `setmembers.cgi:405`, `post.cgi:372`, `post.cgi:818` | | Login cleartext compare + cookie set | `loginout.cgi:95`, `loginout.cgi:109`, `loginout.cgi:113` | | Admin cookie auth | `admincenter.cgi:70`, `admincenter.cgi:87`; `set*.cgi` cookie reads | | Unauth admin creation | `install.cgi:307`, `install.cgi:358` | | Private-forum cookie trust | `forums.cgi:84`, `forums.cgi:96`, `forums.cgi:160` | | Path sanitizer | `ikon.lib:652` (`stripMETA`) | | Two-arg opens (samples) | `ikon.lib:95`, `viewip.cgi:94`, `printpage.cgi:70`, `openFILE` `ikon.lib:1071` | | Config code-injection | `setvariables.cgi:50`+`:83`, `setstyles.cgi:49`, `settemplate.cgi:202` | | Mail transports | `ikonmail.lib:100`, `ikonmail.lib:131` | | Ikonfriend user addresses | `ikonfriend.cgi:120`, `ikonfriend.cgi:141` | | Output filters | `ikon.lib:602/620/633` | | HTML-on un-escape | `topic.cgi:258`, `printpage.cgi:129`, `topic.cgi:254` | | IkonCode tag expansion | `ikon.lib:919`-`951` | | Reflected XSS | `misc.cgi:46`, `misc.cgi:170`, `misc.cgi:174` | | CSRF routine | `ikon.lib:16` (`checkVALIDITY`) | | Hack-log env dump | `ikon.lib:29`, `ikon.lib:53` | | Password generation | `register.cgi:127`, `profile.cgi:503` | | Lost-password email | `profile.cgi:178` | | flock-after-truncate (samples) | `post.cgi:343`, `setvariables.cgi:83`, `messenger.cgi:419` | | User regex | `search.cgi:242`, `post.cgi:332` | | Full-dir scans | `ikon.lib:1025` (`rebuildLIST`), `setmembers.cgi:101` | *End of document 05.* --- ## 7. Installation, Hosting and Day-to-Day Operation *How you actually got one of these running in 2001, and what it was like to run.* --- ### 7.1 What it needed From `first_install.html`: > You'll need some Webspace (about 20megs) that has Perl 5.005 enabled. [...] You'll need a web browser (IE4.5+, or NN4.5+) and an FTP client to upload the files. You'll also need your path to perl, it's usually `#!/usr/bin/perl`, if you are unsure ask your web host. `help.html` gives a slightly different and lower floor -- Perl 5.004+, `CGI.pm` 2.4+ -- which is the kind of discrepancy you get when two documents are maintained separately. The full requirement list: | Requirement | Notes | |---|---| | ~20 MB of web space | Almost all of it headroom for posts; the distribution is 839 KB | | Perl 5.004/5.005 | Core modules only: `CGI`, `CGI::Carp`, `Socket` | | A `cgi-bin` configured to **execute** | Load-bearing for security -- see below | | An FTP client with ASCII/binary mode control | Not optional; the wrong mode breaks the install | | Ability to set CHMOD 0777 or 0755 on directories | The most common host-side blocker | | Cookies enabled in the browser | The entire session model | Notably absent: a database, a compiler, any CPAN module, any daemon, any configuration file the admin has to hand-edit. That was the whole pitch. In 2001 a MySQL database was frequently a paid upsell on shared hosting, and PHP was not yet universal; a package that needed nothing but a cgi-bin would run essentially anywhere. --- ### 7.2 The directory layout you had to build by hand Two trees, in two different parts of the account, because one must be executable and the other must be web-servable. ``` cgi-bin/ikonboard/ <- CHMOD 0777 (or 0755 if the host refuses) +-- data/ <- 0777 -- scripts write generated Perl here +-- help/ <- help text +-- members/ <- 0777 -- one file per member +-- messages/ <- 0777 -- private messages +-- (forum1/, forum2/ ... <- created at runtime by setforums.cgi) public_html/ikonboard/ <- normal web-served space +-- images/ +-- avatars/ +-- emoticons/ ``` The `first_install.html` instruction on permissions is worth quoting because it is where a great many boards went wrong: > Set the CHMOD (or Permissions) value to `0777` (drwxrwxrwx). If your webhost will not allow this, set the CHMOD value to `0755` (-rwxr-xr-x). On the shared hosting of the era, CGI typically ran as the webserver user (`nobody`, `www-data`) rather than as the account owner, so `0777` was frequently the only setting under which the scripts could write at all. World-writable directories on a multi-tenant server meant any other customer on the same box could write into your board's data directory. The documentation offers `0755` as the fallback without explaining that it will silently break registration and posting on most hosts -- which is precisely the failure `help.html` then devotes three FAQs to diagnosing. --- ### 7.3 Upload mode matters, and the docs know it > All of these files must be uploaded in BINARY (Graphic) mode in your FTP client. *(images)* > > All of these files must be uploaded in ASCII mode, with CHMOD permissions set to `0755` (-rwxr-xr-x). *(scripts)* Every shipped text file uses CRLF line endings. ASCII-mode FTP rewrites them to the server's native LF. Upload a `.cgi` in binary mode instead and the shebang becomes `#!/usr/bin/perl\r`; the kernel looks for an interpreter with a carriage return in its name and the server returns `Internal Server Error`, or on some configurations the memorably unhelpful *"No such file or directory"* -- naming a file that visibly exists. Conversely, uploading GIFs in ASCII mode corrupts them, because the same CRLF->LF rewrite is applied to binary image data. Getting this backwards in either direction was the single most common installation failure of the entire Perl-CGI era. --- ### 7.4 `install.cgi` -- a genuinely good installer 35 KB and the most competently engineered script in the distribution. It is a four-step wizard that does real environment validation rather than just writing a config file and hoping. **Step 1 -- collect paths and URLs.** Board name, board URL, path to the cgi directory, path to and URL of the images directory, admin email, timezone. **Step 2 -- verify the environment.** This is the part that distinguishes it. It checks: - that `ikonboard.cgi` exists at the path the user claimed (`install.cgi:59-66`); - that `data/`, `help/`, `members/` and `messages/` exist as directories; - that `data/` is genuinely **writable**, by creating a probe file, writing to it, and deleting it: ```perl $makefile = "$ikondir" . "data/test.txt"; open (TEST, ">$makefile") or $datawritable = "The data directory is not writable, please check the chmod"; print TEST "-"; close (TEST); $datawritable = "The data dir is writable" if (!$datawritable); unlink "$makefile"; ``` Each check reports individually, in blue for pass and red for fail, with a specific remedy. An `$errorflag` gates progress to the next step. This is more installation diagnostics than a great deal of modern software ships with, and it exists because the author had clearly answered the same support questions many hundreds of times. **Step 3 -- write the configuration.** Generates `data/boardinfo.cgi` as executable Perl, appending the four default font sizes (new in 2.1.9, per `Changes.txt`): ```perl open(FILE,">$filetomake"); flock(FILE,2); print FILE "$printme"; print FILE "\$dfontsize1 = \"1\"\;\n"; ... ``` Then re-checks that the file exists and is writable, and reports failure with the exact path the user entered so they can see what went wrong. **Step 4 -- create the administrator.** Writes the first member file with membercode `ad`. `first_install.html` warns: > Make sure you don't use any non-ascii characters such as `*`, `?`, `|`, `;`, `"`, etc. The `|` matters because it is the record separator and would corrupt the member file; the others are the shell metacharacters `stripMETA` removes. The user is being asked to enforce the software's data integrity by hand. **What the installer never does:** delete or disable itself. `install.cgi` remains present, executable and unauthenticated after setup completes, and nothing in the documentation tells the administrator to remove it. --- ### 7.5 First run After Step 4 the admin lands in `admincenter.cgi` and the board has no content. `first_install.html` walks through creating the first category and forum, with an example that has aged into pure charm: > For example, if your website was about 'Food', you may have a Category and Forum set up like: > - Fruit *(Category Name)* > - Choosing the right fruit *(Forum name)* > - Cooking with fruit *(Forum Name)* > - Meat *(Category Name)* > - How to prepare meat *(Forum Name)* And then, immediately, the caveat that reveals the architecture: > If you get a message saying 'Forum1 not created'. Then hit the back button of your browser. Then using your FTP client create a directory called 'forum1' in 'cgi-bin/ikonboard'. The scripts should create this for you, but in some cases it cannot due to the permissions set by your web host. Creating a forum means creating a **directory**. When `mkdir` failed -- which it did whenever the host disallowed `0777` -- the documented remedy was for the administrator to go make the directory themselves over FTP and resubmit the form. --- ### 7.6 Running the board The admin center is `admincenter.cgi`, gated by its own login in `ikonadmin.lib`. From it: | Area | Script | What it writes | |---|---|---| | Forum control | `setforums.cgi` (53 KB) | `data/allforums.cgi`, creates `forumN/` dirs | | Member control | `setmembers.cgi` | `members/*.cgi` | | Board variables | `setvariables.cgi` | `data/boardinfo.cgi` (regenerates Perl) | | Styles / colors | `setstyles.cgi` | `data/styles.cgi` (regenerates Perl) | | Member titles | `setmembertitles.cgi` | `data/membertitles.cgi` (regenerates Perl) | | Template | `settemplate.cgi` | `data/template.dat` | | Bad word filter | `setbadwords.cgi` | bad-words data file | | Announcements | `announcements.cgi` | announcement files | | IP lookup | `viewip.cgi` | reads only | | Board integrity | `checkboard.cgi` | recounts and repairs stats | | Log review | `checklog.cgi` | reads `data/hacklog.cgi` | Three of those admin screens are code generators -- every time an administrator adjusted a color, the board rewrote a Perl source file that every subsequent request would execute. `checkboard.cgi` deserves a note. It exists because the denormalized counters -- total posts, total topics, per-forum counts -- drift out of sync with reality whenever a write races or a delete goes wrong. It is a `fsck` for the forum, and its presence in the distribution is an admission that the storage layer could not keep itself consistent. `Changes.txt` for 2.1.8/2.1.9 is full of entries like *"Forum Topic Counts are now Accurate"*, *"Deleting Threads should not result in negative post counts"*, and *"Deleting Forums, the post count is now accurate when you delete. (Does not go up)"* -- each one a symptom of the same root cause. #### Private forums A per-forum flag plus a per-member allow-list. `help.html`: > After you've created a forum and switched it to 'private', you'll need to edit your own member profile and others of those you wish to allow access. [...] Bring up their profile and you'll see a check box with the name of the private forum next to it. Access is stored in field 18 of the member record as `&`-separated `key=value` pairs, unpacked into `%allowedentry`. Note that the administrator must explicitly grant access **to themselves** -- creating a private forum does not give its creator entry to it. #### Maintenance mode New in 2.1.9, credited to LarryC. A single variable checked in `sub output` swaps the page body for a "temporarily down" notice. It is checked at render time, after the script has already done its work, so it is a display-layer courtesy rather than a real lockout. --- ### 7.7 Backup and migration There is no export function, no backup command, and no mention of backups anywhere in the shipped documentation. The upgrade path, from `upgrading/upgrading.html`, is FTP: > Simply overwrite all of the *.cgi and *.lib files in the root IkonBoard directory. Because data lives in separate files from code, overwriting the scripts genuinely does preserve the content -- an accidental virtue of the one-file-per-entity layout. But upgrades that changed a record format needed a converter, and 2.1.9 shipped one: `upgrading/update_forums.cgi` (1,116 bytes), which rewrites every forum's `list.cgi` into the newer field layout. > To use this convertor, simply drop it into the root 'cgi-bin/ikonboard' directory on your webspace, set the correct CHMOD permission (usually '0755') and then run it by using your browser. Migration was: upload a script, load it in a browser, hope, delete it. There is no dry run, no backup step, no confirmation prompt, and no way to reverse it. The instruction to remove it afterwards is not given. The upgrade file list itself is worth flagging for anyone actually attempting a restoration: it is hand-maintained HTML and lists both `post.cgi` and `setforums.cgi` twice, with an orphaned `(*)` footnote marker attached to nothing. --- ### 7.8 Operating hazards Things that broke real boards, drawn from the FAQ and from the code: - **Posts that vanish.** `help.html` diagnoses this as a missing `forumN/` directory. The deeper cause -- the `list.cgi` rebuild racing with itself -- is discussed in the Architecture chapter, and the September 2001 timestamp rollover produced a spectacular instance of it. - **Login loops.** Three separate FAQ entries. Causes: the member file was never created (permissions), the membercode is not `ad`, or cookies are blocked. `help.html` walks the admin through opening `members/foobar.cgi` in a text editor and reading their own password out of it -- *"the file would look like this: `Foobar|bar|Administrator|ad|`"* -- which is as clear a statement as one could want that passwords were stored in plaintext and everybody knew it. - **Case sensitivity.** *"remember that Ikonboard is case sensitive, so Password, password, PASSWORD and pAssWorD are all different."* This was a **2.1.8r2.1 change** -- `Changes.txt` lists "Registering is now case sensitive" as a fix, meaning earlier versions had case-insensitive registration and members could collide. - **Non-ASCII characters in styles.** *"Make sure that you've not used any non ASCII characters when setting up the styles"* -- because the style values are written into a Perl file and then executed. - **Corrupted uploads.** The FAQ suggests downloading `install.cgi` back off the server and diffing it against the zip, because partial FTP transfers were common enough to warrant a documented procedure. --- ### 7.9 Support model No email support, by explicit policy, in every shipped document: > Please do not email us, we get a lot of email and do not have the time to reply. You'll get a far faster answer from our support board. Support was a forum -- `forums.ikonboard.com`, itself running Ikonboard -- and the community was the support tier. `README.txt` goes further, telling users that bug fixes for 2.1.9 did not all have written instructions yet and inviting them to *ask*: > Currently, we do not have bug fix instructions available for every bug fix contained within this distribution. However, if you post a thread in the "Bug Reports" forum for iB2 on the support forums [...] I or another member of the dev team would be happy to post the code. It would help if you entitled your post "2.1.9 - Fix for xxx". Patches were distributed as **forum posts containing Perl fragments**, which administrators pasted into their own files by hand. There was no patch file, no version control, no checksum. Two boards both calling themselves "2.1.9" could differ by a dozen hand-applied fixes -- which is exactly what this archive's own `ikon.lib` turns out to be. --- ### 7.10 Complete file manifest 145 files, 839,181 bytes. Generated by `ib_manifest.py`. | Directory | Files | Bytes | Lines | |---|---:|---:|---:| | `.` (docs) | 5 | 28,053 | 599 | | `cgi-bin` | 37 | 727,182 | 17,710 | | `cgi-bin/data` | 6 | 7,319 | 208 | | `cgi-bin/help` | 15 | 9,568 | 97 | | `cgi-bin/help/admin` | 1 | 1,644 | 59 | | `cgi-bin/messages` | 1 | 84 | 3 | | `non-cgi/avatars` | 18 | 9,418 | -- | | `non-cgi/emoticons` | 9 | 14,640 | -- | | `non-cgi/images` | 51 | 34,225 | -- | | `upgrading` | 2 | 7,048 | 175 | **34 Perl programs and libraries: 642,355 bytes, 15,526 lines.** #### Programs, by size | File | Bytes | Date | Role | |---|---:|---|---| | `postings.cgi` | 56,985 | 2001-06-08 | Thread display | | `setforums.cgi` | 53,092 | 2001-06-03 | Forum admin | | `post.cgi` | 45,142 | 2001-06-08 | Post composer | | `post.cgi.bak` | 45,138 | 2001-06-08 | *pre-edit backup* | | `ikon.lib` | 39,735 | **2001-09-09** | **Core library (patched)** | | `ikon.lib.bak` | 39,605 | 2001-06-04 | *pre-patch backup* | | `install.cgi` | 35,606 | 2001-05-23 | Installer | | `messenger.cgi` | 31,779 | 2001-05-23 | Private messages | | `profile.cgi` | 29,407 | 2001-05-23 | Member profiles | | `search.cgi` | 25,866 | 2001-05-23 | Search | | `announcements.cgi` | 24,066 | 2001-05-23 | Announcements | | `setmembers.cgi` | 23,285 | 2001-05-02 | Member admin | | `setvariables.cgi` | 22,324 | 2001-06-04 | Board settings | | `topic.cgi` | 21,956 | 2001-06-06 | Topic list | | `forums.cgi` | 21,312 | 2001-05-23 | Forum view | | `register.cgi` | 20,905 | 2001-05-28 | Registration | | `setstyles.cgi` | 20,785 | 2001-05-02 | Color admin | | `misc.cgi` | 18,082 | 2001-05-23 | Misc member actions | | `ikonboard.cgi` | 15,343 | 2001-05-23 | Board index | | `help.cgi` | 13,568 | 2001-05-23 | Help viewer | | `setmembertitles.cgi` | 12,474 | 2001-05-02 | Rank admin | | `forumoptions.cgi` | 11,238 | 2001-06-03 | Forum options | | `newposts.cgi` | 10,831 | 2001-05-23 | New post finder | | `ikonfriend.cgi` | 10,679 | 2001-05-23 | Email to a friend | | `viewip.cgi` | 10,158 | 2001-05-23 | IP lookup | | `checkboard.cgi` | 8,480 | 2001-05-02 | Integrity check | | `admincenter.cgi` | 8,848 | 2001-06-01 | Admin hub | | `settemplate.cgi` | 7,673 | 2001-05-18 | Template editor | | `loginout.cgi` | 7,355 | 2001-05-23 | Login/logout | | `setbadwords.cgi` | 6,928 | 2001-06-04 | Word filter | | `ikonadmin.lib` | 6,482 | 2001-05-28 | Admin auth library | | `printpage.cgi` | 5,490 | 2001-05-02 | Printable view | | `checklog.cgi` | 5,125 | 2001-05-02 | Log viewer | | `whosonline.cgi` | 4,632 | 2001-05-23 | Active users | | `privacy.cgi` | 3,379 | 2001-05-23 | Privacy statement | | `ikonmail.lib` | 3,345 | 2001-05-02 | Mail library | | `upgrading/update_forums.cgi` | 1,116 | 2000-11-24 | Format converter | #### Shipped data files | File | Bytes | Date | Contents | |---|---:|---|---| | `data/privacy.dat` | 2,477 | 2000-10-31 | Privacy statement text | | `data/register.dat` | 1,432 | 2000-09-25 | Registration terms | | `data/template.dat` | 1,240 | 2000-10-17 | Page shell | | `data/progs.cgi` | 883 | 2001-05-23 | Script name map | | `data/styles.cgi` | 864 | 2000-10-29 | Default colors | | `data/membertitles.cgi` | 423 | 2000-10-29 | Default ranks | #### Images 51 interface GIFs (2000-09-24 to 2000-11-01), 9 emoticons (2000-09-24), and 18 avatars -- all dated **2000-02-26**, the oldest files in the distribution: `abra`, `Abu`, `Aladdin`, `Alfred`, `arbok`, `Ares`, `ArmondoGuitierrez`, `Arthur`, `artistbirdie`, `Atta`, `avengers`, `Batman`, `Batman2`, `BlackWidow`, `BruceWayne`, `Catwoman2`, `Flik`, plus `noavatar.gif` (2000-10-10). The avatar set is alphabetical and stops at F. It is the first pageful of somebody's clip-art folder, shipped as a default set and never revisited across at least four releases. --- ## 8. Archaeology ### 8.1 The distribution is dated by its contents, not its packaging The 145 files carry preserved modification timestamps from inside the original zip. They span: ``` earliest: 2000-09-25 cgi-bin/data/register.dat latest: 2001-09-09 cgi-bin/ikon.lib ``` The oldest assets are older still -- `non-cgi/avatars/*.gif` are all dated **2000-02-26**, a set of eighteen cartoon-character GIFs (Batman, Aladdin, Abu, Flik, Atta, an Abra and an Arbok) that had already been sitting in the tree for over a year when 2.1.9 shipped. The shipped documentation clusters tightly in late May / early June 2001: | File | Date | |---|---| | `license.html` | 2001-05-04 | | `first_install.html` | 2001-05-28 | | `help.html` | 2001-05-28 | | `README.txt` | 2001-06-04 | | `Changes.txt` | 2001-06-07 | | `upgrading/upgrading.html` | 2001-06-07 | That makes **7 June 2001** the effective release date of Ikonboard 2.1.9 Beta. Most of the CGI scripts are dated 2001-05-23, a bulk timestamp consistent with a single packaging pass. Four files postdate the documentation. Those four are the story. --- ### 8.2 The billion-second patch `ib_provenance.py` flags everything newer than the newest shipped document: ``` FILES NEWER THAN THE RELEASE -- edited after the release was packaged: 2001-06-08 cgi-bin/post.cgi 2001-06-08 cgi-bin/post.cgi.bak 2001-06-08 cgi-bin/postings.cgi 2001-09-09 cgi-bin/ikon.lib ``` `ikon.lib` stands three months apart from everything else. Its exact modification time is the single most interesting artifact in this archive: ``` ikon.lib epoch=1000009398 UTC=2001-09-09 04:23:18 rollover 1e9 UTC=2001-09-09 01:46:40 ``` **The file's own timestamp is 1000009398 -- nine thousand three hundred and ninety-eight seconds after Unix time reached 1,000,000,000.** Two hours, thirty-six minutes and thirty-eight seconds after the rollover. And the diff against `ikon.lib.bak` shows exactly what was changed in those two and a half hours: ```diff --- cgi-bin/ikon.lib.bak (2001-06-04, 1124 lines) +++ cgi-bin/ikon.lib (2001-09-09, 1127 lines) @@ -1045,2 +1045,3 @@ close (TMP); + if ($lastpostdate < 1000000000) { $lastpostdate = "0" . $lastpostdate; } $rr = "$lastpostdate|$topicid|$topictitle|..."; @@ -1051,2 +1052,3 @@ @sortdat = reverse(@sortdat); + $checkdat = @sortdat; foreach (@sortdat) { @@ -1056,3 +1058,3 @@ } - if (($processed_data eq "") || ($processed_data !~ m!\|!)) { &error("Missing Data&..."); } + if ((($processed_data eq "") || ($processed_data !~ m!\|!)) && ($checkdat != 0)) { &error("Missing Data&..."); } ``` #### Why the board broke `sub rebuildLIST` builds each forum's topic index by prefixing every topic's last-post timestamp to a pipe-delimited string and calling Perl's `sort`. Perl's default `sort` is a **string** comparison. Placing a fixed-width timestamp first makes lexical order equal chronological order -- as long as the width never changes. At 01:46:40 UTC on 9 September 2001, Unix timestamps went from nine digits to ten. From that moment: ``` "1000000123" lt "999999999" -> TRUE ``` Every topic posted after the rollover sorted *below* every topic posted before it. Active threads vanished to the bottom of page one, or off the listing entirely. To an administrator that morning it looked exactly like the notorious "disappearing threads" bug the release notes had promised was fixed. The patch zero-pads any nine-digit timestamp to ten characters, restoring the invariant that made the string sort valid. #### The second half of the fix The other two hunks are subtler and reveal a real debugging session, not a blind patch. The original code treated an empty `$processed_data` as proof of corruption and aborted with *"Data as corrupted on the server."* But a forum that legitimately contains **zero topics** also produces empty `$processed_data`. The new `$checkdat = @sortdat` captures the topic count in scalar context, and the abort now requires *both* empty output *and* a non-empty input list. So whoever sat down at 23:23 local time on 8 September 2001 fixed two things: the sort that had scrambled every forum, and a false-positive corruption error that fired on empty forums -- very likely the error they were staring at while diagnosing the first problem. #### Who applied it The tree settles this, and it settles it against the intuitive answer. **This distribution was never installed.** Ikonboard generates a specific set of files the first time it is set up and used, and not one of them exists here: | Generated file | Created by | Present? | |---|---|---| | `data/boardinfo.cgi` | `install.cgi` step 3 | no | | `data/onlinedata.dat` | `sub whosonline` | no | | `data/boardstats.cgi` | install / runtime | no | | `data/allforums.cgi` | `setforums.cgi` | no | | `data/hacklog.cgi` | `checkVALIDITY` | no | `data/` holds exactly the six files the zip ships. `members/` is empty. `messages/` contains only its anti-directory-listing stub. There are no `forumN/` directories. Two of those absences are individually conclusive. `data/boardinfo.cgi` is `require`d by all 32 scripts inside the startup `eval` (`ikonboard.cgi:22-34`), so without it **no page of this board could ever have been served**. And `onlinedata.dat` is rewritten by *any* visitor loading *any* page, guests included, so its absence independently proves zero page views. This narrows the field considerably, but it is worth being precise about how far it goes. **What the tree does prove** is that it descends from the vendor's own working copy. `ikon.lib.bak` is dated **2001-06-04**, three days *before* the 2001-06-07 release -- a date no downloader could hold, because the release did not yet exist. The `.bak` files are the maintainer's working detritus, left in the tree and packaged with it. Whatever else this zip is, it was assembled at ikonboard.com. **What it does not prove** is who typed the September edit. "Never installed" rules out an administrator of *this* tree hitting the bug in production -- nobody was running it -- but it does not rule out a third party patching an unpacked copy without ever installing it. Someone running Ikonboard elsewhere could plausibly have kept a clean master copy, read the fix off the support forum (which is exactly how `README.txt` says fixes were distributed), applied it to that copy in September, and re-zipped. A mirror operator could have done the same. So the honest statement is: **almost certainly a vendor-issued patch, but not proven to have been applied by the vendor to this exact zip.** What favors the vendor reading is that ikonboard.com documented doing precisely this (`help.html`): > We do however, try to update the downloadable zip file every time a new bug is found. -- and that the edit is stylistically of a piece with the SmileyMan `rebuildLIST` rewrite the release notes credit. The most plausible reconstruction, offered as reconstruction rather than as established fact: ikonboard.com kept its master tree on disk, hand-edited it in place (leaving `.bak` files behind), zipped it for release on 7 June, corrected `post.cgi` and `postings.cgi` the next day, and then -- when Unix time gained a digit three months later and every board running the software scrambled its forum listings overnight -- patched the master and re-issued the download. No version bump was needed or made: `$versionnumber` still reads `2.1.9 Beta`, and a zip fetched in September would legitimately carry the fix while calling itself the same release. The chain from that master tree to this particular archive object is not something the filesystem can establish. But there is external evidence that closes most of the gap. #### The November 2001 download This copy has a known owner and a known board. FieRcE YeD, who preserved the zip, ran FinalFantasyFanatic.com on Ikonboard 2.1.9 -- and the board's own profile pages, captured by the Wayback Machine while it was still live, state each member's join date in the `Member Since:` field that `profile.cgi` renders. The two that matter, quoted from the archived pages themselves: | Member | `Member Since:` | Archived profile | |---|---|---| | **FieRcE_YeD** | **Nov. 9, 2001** | [capture, 2002-02-20](https://web.archive.org/web/20020220075917/http://www.finalfantasyfanatic.com/cgi-local/ikonboard/profile.cgi?action=show&member=FieRcE_YeD) | | jstyla | Nov. 25, 2001 | [capture, 2002-04-20](https://web.archive.org/web/20020420085800/http://www.finalfantasyfanatic.com/cgi-local/ikonboard/profile.cgi?action=show&member=jstyla) | Sixteen further member profiles are captured on the same host, all registering between January and April 2002 -- Millennia and teddybearperson on 12 January, then Razz, Ramza_Beoulve, ValiantTRISH, Sephiroth, StratusRT, Highlander, DJ_Moguta, Kefka, Vivi, The_Iron_Yuppie, doppelganger, Jubei_Skyrender, Romku_Tetsulian and Garalin. FieRcE_YeD and jstyla are the only two from 2001, and they are two weeks apart. FieRcE_YeD is the administrator account, which `install.cgi` step 4 creates as the final act of setup, so **9 November 2001 dates the installation**. jstyla, sixteen days later, is the first member to find the board. That is two months *after* the billion-second patch -- and the zip used to perform it is this one, carrying a 9 September `ikon.lib`. So the copy obtained in early November already contained the fix. That is what a vendor-maintained download looks like. For the third-party explanation to survive, somebody other than ikonboard.com would have had to patch a copy, re-zip it, and get it into the hands of a downloader within eight weeks, in place of the official file -- while the official site was itself documenting that it patched the download whenever a bug was found. The simple explanation is the one `help.html` states. #### Why a complete tree was never installed The same fact explains the artifact's strangest property. A board went up from this zip, yet the zip shows no trace of ever having run -- and that is exactly what the era's deployment model produces: 1. Download the zip and unpack it **locally**. 2. FTP the contents to the web host, in ASCII mode, per `first_install.html`. 3. Run `install.cgi` **on the server**, which is where `boardinfo.cgi`, `members/` and the `forumN/` directories are then created. The generated files only ever exist on the host. The local unpacked copy is a staging area, and it stays pristine permanently. This tree is that staging copy -- which is why it is simultaneously a complete distribution, demonstrably never installed, and the direct ancestor of a board that ran for a year. It also explains why it survived. The installed board died with its host; the unpacked zip sat in a personal backup and was migrated forward across machines for twenty-five years. **Sourcing note.** The registration dates are cited to Wayback Machine captures of the live board, not to any later reconstruction of it -- each linked page can be opened and read directly, and the `Member Since:` line is the software's own output. That is external to the zip and independently checkable, which is the point: nothing in the tree itself could establish when it was used. Two things here are *not* of that standard and are flagged as such. The identification of this particular zip as the one used for that installation rests on the owner's account of having kept it since, and the inference that it was downloaded shortly before 9 November 2001 rather than months earlier is reasoning from the install date, not a record of the download. Both are plausible and consistent with every dated artifact in the tree; neither is proven. One fact is fixed either way: **this archive contains a fix for the Unix billion-second rollover, written within three hours of the rollover itself, and the file's own mtime is the timestamp that caused the bug.** --- ### 8.3 The two-minute edit on `post.cgi` The other post-release cluster is much tighter: ``` postings.cgi UTC=2001-06-08 00:59:40 post.cgi.bak UTC=2001-06-08 01:25:42 post.cgi UTC=2001-06-08 01:27:38 ``` `post.cgi.bak` and `post.cgi` are **116 seconds apart**, and the entire difference is one character: ```diff --- cgi-bin/post.cgi.bak (1055 lines) +++ cgi-bin/post.cgi (1056 lines) @@ -1051,2 +1051,3 @@ $reviewcount++; + } } # end foreach ``` A single closing brace, closing an `unless($reviewcount > 20)` block inside `&threadreview`. This was verified rather than assumed. Compiling both files with `perl -c` (after neutralizing an unrelated regex that modern Perl rejects -- see section 8.11): ``` post.cgi syntax OK post.cgi.bak Missing right curly or square bracket at line 1055 ``` **`post.cgi.bak` is not an older working version -- it does not compile at all.** A board shipped with that file would have returned a 500 error on every attempt to post. The backup captures a broken intermediate state, which is what makes the 116-second gap legible: the brace was not a refinement, it was the fix for a build that was already broken. The sequence is legible: edit `postings.cgi`, move to `post.cgi`, save a backup, make the change, hit a syntax error, fix the brace, save again two minutes later. This is someone patching a live tree by hand, at 20:25 local time on a Thursday evening, hours after the release documentation was finalized. `Changes.txt` is dated the same day and its final entry reads *"a huge rewrite of processing routines to try and stop disappearing threads. (Credit to SmileyMan)"*. The `post.cgi`/`postings.cgi` edits are almost certainly the tail end of that rewrite landing. --- ### 8.4 Version-string discrepancies The software does not agree with itself about what it is. - `ikon.lib:12` -- the only version declaration in the codebase -- reads: ```perl $versionnumber = "2.1.9 Beta"; ``` - Every user-facing page therefore renders "Powered by Ikonboard 2.1.9 Beta". - `README.txt`, `Changes.txt`, `first_install.html`, `help.html` and `upgrading.html` all call it plain "2.1.9". - `help.html` is candid about the beta status: *"Please remember that Ikonboard is currently in 'beta'."* There is also a stale copyright year in the maintenance-mode footer. `ikon.lib` prints `© 2001 Ikonboard.com` in the normal page footer (line 862) but `© 2000 Ikonboard.com` in the maintenance-mode footer (line 874) -- the maintenance block was copy-pasted from an older revision and its year never updated. Since maintenance mode was **new in 2.1.9**, the freshest feature in the release carries the oldest copyright date in the codebase. `install.cgi` likewise still carries a `# Copyright 2000` header while all 31 other scripts say 2001. --- ### 8.5 The support forum grew 285% in a document that never noticed Two shipped documents, both dated **2001-05-28**, quote the size of the support community: - `first_install.html`: *"We currently have over **1,300** members who can try to help."* - `help.html`: *"We currently have over **5,000** members who can try to help."* Identical sentence, identical date, figures nearly four times apart. `first_install.html` was clearly written much earlier and re-saved without its body text being revisited; `help.html` carries the current figure. It is a small thing, but it dates the two documents relative to each other far better than their filesystem timestamps do, and it puts a number on how fast forums.ikonboard.com was growing in 2000-2001. --- ### 8.6 The `messanger` -> `messenger` affair `Changes.txt` records, among genuine bug fixes: > Messenger is now spelt correctly. (Credit to Peter) The private-messaging script had shipped as `messanger.cgi` for several releases. Correcting the spelling turned out to be a breaking change, and `upgrading/upgrading.html` devotes an entire section to it: > This file no longer exists in the iB zip download. Instead, messenger.cgi is used. This works in exactly the same way as messanger except that it is spelled correctly. To install this corrected version you must do the following: > > Delete messanger.cgi from the main iB directory, and messanger,dat from the help directory. Upload the messenger.cgi and messenger.dat from the new zip file to the appropriate directories and chmod 755. **Note -** You cannot simply change the filenames, the contents of the scripts must also be altered to reflect the name change. [...] You must also edit progs.cgi in the data directory to reflect the new files. So `$messangerprog` becomes `$messengerprog` etc. Because script names were string-interpolated into links throughout the codebase *and* recorded in `data/progs.cgi`, a typo fix required a coordinated multi-file rename with manual admin intervention on every existing installation. It is a perfect miniature of the architecture's central weakness: with no module boundaries and no indirection that the software itself understood, an identifier was effectively public API. (The upgrade document also contains the typo `messanger,dat` -- a comma for a period -- while explaining the spelling fix.) --- ### 8.7 Hand-edited HTML `license.html` carries a revealing `` tag: ```html ``` `upgrading.html` shows the opposite: deeply nested and unbalanced `` tags, `
` elements scattered mid-list, and a file list where **`post.cgi` and `setforums.cgi` each appear twice**: ``` - setforums.cgi - setvariables.cgi ... - forumoptions.cgi (*) - setforums.cgi (*) - post.cgi ``` There is also an orphaned footnote marker -- a `(*)` on its own line with nothing attached to it. The documentation was maintained by hand in a WYSIWYG editor by more than one person, and the duplicate entries mean an administrator following the upgrade list literally would upload two files twice. Harmless, but it tells you the release checklist was not itself checked. --- ### 8.8 Authorship evidence in the headers `ib_provenance.py` groups every script by its exact copyright header. The result is unusually uniform, with three deviations: ``` 31 files: # Copyright 2001 Ikonboard.com - All Rights Reserved # All files written by Matthew Mecham 1 file: # Copyright 2001 ... # All files written by Matthew Mecham (Unless otherwise stated) -> cgi-bin/ikonmail.lib 1 file: # Copyright 2000 Ikonboard.com - All Rights Reserved -> cgi-bin/install.cgi 1 file: # ... + "### Check for authorisation." -> cgi-bin/viewip.cgi ``` The `(Unless otherwise stated)` qualifier on `ikonmail.lib` exists because that file is the only one carrying a third-party attribution, immediately below the standard header: ```perl # # Mail function library. Some functions based on sendmail.pm # # By Milivoj Ivkovic # ``` That is the one place in 15,526 lines of Perl where the blanket "All files written by Matthew Mecham" claim is explicitly qualified -- and the header was edited to accommodate it. Meanwhile `Changes.txt` credits **LarryC**, **Peter** and **SmileyMan** by name for specific 2.1.9 fixes, none of whom appear in any file header. Ten files carry no copyright header at all: the three generated `data/*.cgi` config fragments, six help-text files that use a `.cgi` extension despite containing only HTML, and `upgrading/update_forums.cgi`. --- ### 8.9 Files whose extensions lie The distribution uses file extensions as access control rather than as type declarations, and the result is a tree where almost nothing is what it claims: | Pattern | Actually contains | Why | |---|---|---| | `data/styles.cgi`, `data/progs.cgi`, `data/membertitles.cgi` | Perl variable assignments | `.cgi` so the webserver executes rather than serves them | | `data/allforums.cgi`, `members/*.cgi`, `forumN/list.cgi` | Pipe-delimited plain text | same -- the extension hides them from direct HTTP fetch | | `data/hacklog.cgi` | HTML fragments | same | | `forumN/*.pl` | Pipe-delimited plain text, no Perl | Perl extension on non-Perl data | | `help/Postings.cgi`, `help/Moving_Topics.cgi`, and four others | Plain HTML help text | inconsistent with the `.dat` used by the other nine help files | | `ikon.lib` | Perl source | `file(1)` reports it as *"HTML document"* because it is mostly HTML in string literals | The help directory is the clearest sign of drift: nine files use `.dat` and six use `.cgi`, for identical content, with no functional difference -- `help.cgi` reads whichever is asked for. Two conventions coexisting in one directory is the fingerprint of a codebase maintained by successive hands without a style rule. Note also that this entire scheme depends on `cgi-bin` being configured to execute rather than serve. Where an administrator installed the board outside a properly configured `cgi-bin` -- a documented-as-common misconfiguration -- `members/*.cgi` would be served as plain text, publishing every member's password to anyone who guessed a filename. --- ### 8.10 Line endings All shipped text files use **CRLF**. The installation instructions insist that every `.cgi` file be uploaded in **ASCII mode** (`first_install.html`, Step 3), which is exactly what makes this work: the FTP client rewrites CRLF to the server's native LF in transit. Upload in binary mode and the shebang line becomes `#!/usr/bin/perl\r`, which Unix reports as *"No such file or directory"* -- one of the era's most reliably confusing error messages, and the reason the instruction is capitalized in the original. Not one file in the distribution contains a byte above 0x7E. The entire codebase is 7-bit ASCII. --- ### 8.11 It no longer runs A note for anyone attempting to stand this board up, because the obstacle is not the one you would expect. Four lines across three files render the `:o` "shocked" emoticon with the substitution `s/\:\o/.../g`: ``` announcements.cgi:419 messenger.cgi:349 messenger.cgi:692 post.cgi:1034 ``` A bare `\o` escape in a regular expression has been a **fatal compile error since Perl 5.14** (2011); Perl now requires `\o{...}` with braces. In 2001 the sequence was accepted and meant a literal `o`, so the code was correct when it was written and is unrunnable today. The escape is a copy-paste artifact, and the evidence is in the library: the canonical version of the same emoticon block in `ikon.lib:982` (`sub preview`) correctly reads `s/\:o/.../g`, with no second backslash. The four broken copies are downstream duplicates of that block -- the same eight-line emoticon substitution pasted into every script that renders a post, acquiring a typo that the original never had. It is the clearest single illustration of what having no shared render path costs: one routine, five copies, four of them subtly wrong, and nothing to notice for twenty-five years. The failure mode is unhelpful -- the script dies at compile time with a message about the escape rather than anything suggesting an emoticon parser -- so this is the first wall anyone restoring an Ikonboard 2.1.9 will hit, before any question of Perl 5.005 semantics, `CGI.pm` version drift, or `flock` behavior. Patch those four lines first. This also means the compile check in section 8.3 required neutralizing that regex in both files before `perl -c` would report on the brace at all. --- ### 8.12 Summary of the archive's provenance | Question | Answer | Confidence | |---|---|---| | Version | 2.1.9 Beta (`ikon.lib:12`) | Certain -- stated in code | | Release boundary | 7 June 2001 -- the newest shipped document | Certain as a *boundary*; the exact public download date is uncorroborated | | Complete distribution? | Yes -- installer, docs, images, help all present | Certain | | Assembled at ikonboard.com? | Yes -- `ikon.lib.bak` predates the release by 3 days | Certain -- no downloader could hold it | | Ever installed? | **No** -- not one runtime-generated file exists | Certain -- see section 8.2 | | Contains user data? | No -- `members/` empty, no `forumN/` dirs | Certain | | Modified after packaging? | Yes -- 4 files, in **two clusters**: 3 on 8 June, `ikon.lib` on 9 September | Certain -- mtimes + diffs | | Nature of the last edit | Unix billion-second rollover fix | Certain -- diff is unambiguous | | Time of the last edit | 2001-09-09 04:23:18 UTC (epoch 1000009398) | Certain -- filesystem mtime | | Was the September edit made by the vendor? | **Very likely** -- the copy downloaded in early Nov 2001 already carried it | Strong: tree evidence + the Nov 2001 install date | | Why is a complete tree never installed? | It is the **local staging copy**; `install.cgi` ran on the web host, where the generated files stayed | High -- matches the documented FTP install model | | Date of the installation it fed | 9 November 2001 -- admin account `Member Since` | External, citable: Wayback capture of the live profile page | | Chain from vendor master to this archive object | ikonboard.com -> download (~Nov 2001) -> personal backup -> archive.org | Probable; the download source is not independently attested | --- ## 9. Field Evidence > **Revision history** > **1.0 -- 08/16/2026** -- chapter added. > > Sections 1 through 8 were written on 08/15/2026 against the distribution > alone. This chapter came a day later, when the surviving records of a board > that actually ran the software became available. The open item at the end of > section 9.6 is unresolved at the time of writing; any later revision that > closes it will be dated in this block. Everything up to this point examines the distribution: the code as shipped, the records it would write, the defects it carried. This chapter is the one place the teardown meets a board that actually ran. FinalFantasyFanatic.com ran Ikonboard 2.1.9 until it stopped working in June 2002. Its operator, FieRcE YeD, kept a dated news page through the failure, its successor board's member table survives, and one directory of its private messages was pulled off the server three weeks before the end. Between them those three sources show what the defects catalogd in sections 3 and 5 look like when they fire on a live site. The value here is not the site. It is that several failure modes documented analytically elsewhere in this teardown can, for once, be checked against a board that suffered them. --- ### 9.1 The deployment A Final Fantasy fan community, hosted on Verio, running on SGI IRIX 6.5.x on MIPS hardware -- a detail established independently from stock IRIX system binaries FieRcE YeD FTP'd off the server and kept. **[SRC]** The board was active through at least the first half of 2002 and was busy enough that he was fighting it regularly. Nothing about the deployment is unusual. That is the point: it is an ordinary 2.1.9 installation of the kind the software shipped tens of thousands of. --- ### 9.2 The failure sequence FieRcE YeD's own news page, recovered from a local copy of the site, carries three dated entries. They are reproduced verbatim; the spelling is his. **[SRC]** > **June 7, 2002** > Ok, So I go to bed last night thinking, oh the forums are ok I just fixed > them finally for the 1000th time. SO just now I checked it out and it can't > read a certain file. So guess what? The forums are going to stay down while > I find something different than Ikonboard. I'll keep ya updated. > **June 13, 2002** > More news about the Forums. I have a few message boards I am looking at. I > will start working on it tomorrow. Also Interesting to Note, take a look over > at www.xgam.org Their Ikonboard crashed the EXACT SAME DAY AS MINE. > Coincidence? > **June 14, 2002** > OK, I have the forums up once again. After talking to Kyle Shidar a couple of > days ago, he made me decided to give Ikonboard one more shot. So the forums > are up once again and everyone will have to re-register. Also the moderators > of the old forums, when you re-register, email me or just post a thread in > general discussion so I can give you your mod powers back. Three things in these notes matter. **"It can't read a certain file"** is the failure signature of a corrupted flat index. 2.1.9 has no database; `data/allforums.cgi` and each forum's `list.cgi` are single points of failure that every page load touches. **"The 1000th time"** establishes that June 7 was not a first occurrence. The board had been failing and being repaired repeatedly, which is the signature of a structural defect under rising load rather than a one-off accident. **"Everyone will have to re-register"** establishes the scope of the loss. Recovering a corrupted topic index does not cost you your members. --- ### 9.3 What the member data proves The replacement board was Ikonboard 3.0.2a, and its member table survives in a November 2002 backup. 3.x member IDs carry the registration epoch as their second component (`70-1024085379`), so the table dates its own population. **[SRC]** Every one of the 48 recovered members registered on or after 14 June 2002: ``` 06/14/2002 20:09 FieRcE YeD <- the board operator, first account on the new board 06/14/2002 20:40 Setzer 06/14/2002 20:42 Edgar 06/15/2002 00:19 Romi Seneca 06/15/2002 03:02 RTV King 06/15/2002 04:07 Razz 06/15/2002 08:00 Lord Arkmam 06/15/2002 12:03 JCD 06/15/2002 15:04 Tiff 06/15/2002 15:46 Sage Winard 06/16/2002 10:52 Highlander 06/17/2002 01:47 Hobbes Lionheart ``` Three registrations on 14 June, seven on 15 June, then a decaying tail. FieRcE YeD's account is the first, timed 20:09 on the day his news entry says the board came back. **The member directory was destroyed, not merely the topic index.** That distinction narrows the possible causes considerably, and section 6 returns to it. A secondary observation: several members re-registered under different names. The pre-crash correspondence in section 4 is between "Kyle Shidair", "teddybearperson" and "the Golden Warrior"; the new board's table has "Sage Winard", "Romi Seneca" and no Golden Warrior at all. A forced re-registration is a rename opportunity, and people took it. For anyone reconstructing a board across this kind of discontinuity, identity does not survive the gap by itself. --- ### 9.4 What survived, and why One directory of private messages was pulled off the server by FTP on 13 May 2002 and kept. It contains 38 files in the exact layout specified in section 5: `_msg.cgi` for each inbox, `_out.cgi` for each outbox, one message per line, five pipe-delimited fields. **[SRC]** ``` teddybearperson|yes|1021171994|hah!|cheater.. sheesh, why so many posts YeD?*grin* ``` 572 raw records deduplicate to **377 unique messages spanning 17 January to 13 May 2002**. The successor board's own message store begins 15 June 2002. The two corpora do not overlap by a single message: the pre-crash record survives in its entirety and the post-crash record begins clean, with the gap falling exactly across the failure. Two details are worth drawing out. **The dual-copy structure paid off.** Section 5 notes that a single send writes two independent copies, one to each party's file, and that a message therefore survives if either file survives. Measured against this corpus: 195 of the 377 exist in both copies, and **182 survive on one side only**. Nearly half the archive would have been lost to a design that stored each message once. **Nothing else survived.** No `list.cgi`, no `allforums.cgi`, no `banlist.cgi`, no forum directories, no member files. The same download contains the stock 2.1.9 distribution zip, unpacked, which is a different artifact entirely. The private messages survive because somebody manually copied them off a working server three weeks before it stopped working. That is the practical consequence of the finding in section 5 that `list.bak` is read by `checkboard.cgi` but **never written by any script in the distribution**. A 2.1.9 board has no automatic backup of anything. What you keep is what you thought to copy. --- ### 9.5 Which documented defects this is evidence for | Defect | Where documented | What this deployment shows | |---|---|---| | Flat indexes are single points of failure; every page load reads `allforums.cgi` | Section 3 | "It can't read a certain file" took the whole board down, not one forum | | Several *read* paths take an exclusive `flock 2` on `allforums.cgi`; a crashed reader blocks writers | Section 3 | Repeated failures ("the 1000th time") on a board whose traffic was growing | | The integrity guard added to stop empty or pipe-less buffers overwriting good indexes -- the "disappearing threads" mitigation | Section 5 | Index corruption was endemic enough in the wild to need a guard, and this board still lost its indexes | | `list.bak` is read but never written by any script | Section 5 | Recovery depended entirely on a manual FTP copy made for unrelated reasons | | CVE-2001-0841, cookie directory traversal, naming "ib219 and all older versions", public from 30 October 2001 | Sections 2 and 6 | Eight months of public exposure by June 2002 on an unpatched board | None of this is surprising given the code. What the deployment adds is confirmation that the failure modes were not theoretical, and a sense of the timescale: an ordinary community board running the software through a period of growth got roughly a year before the flat-file design became unmanageable. --- ### 9.6 What this does not establish The mechanism above is well supported. The **trigger** is not, and the distinction should not be blurred. **The simultaneous failure is the crux and rests on a single source.** The only evidence that xgam.org's Ikonboard died the same day is FieRcE YeD's own news entry of 13 June 2002, quoted in section 9.2. He had no reason to invent it, but it is one contemporaneous claim, unverified, about a third party's software. If it is accurate, coincidence is a poor explanation. Two unrelated boards on unrelated hosts failing the same day points to a common external cause, and the most plausible candidate is automated exploitation: CVE-2001-0841 was eight months public and named this exact version. A scripted sweep would produce precisely this pattern. That theory also fits the one fact plain corruption explains poorly. **Index corruption does not destroy a member directory.** A traversal flaw that leaks `members/*.cgi` -- and with them the password hashes documented in section 5 -- followed by an intrusion, does. So does a panicked wipe-and-reinstall by an administrator who had run out of patience, which is the innocent alternative and cannot be distinguished from the outside. **On the present evidence, both remain open.** The honest summary is that the board was structurally fragile in ways this teardown documents, it had been failing repeatedly, and something finished it on 7 June 2002 that also appears to have finished at least one other board the same day. #### Open item: corroboration from the Wayback Machine The claim quoted in section 9.2 -- that xgam.org's Ikonboard failed on the same day as this one -- is the only assertion in this chapter that can be tested against a source outside FinalFantasyFanatic.com's own records. Everything else here rests on FieRcE YeD's news page, his successor board's member table, and his file copies. Corroborating it means reading xgam.org's site from that week. The Wayback Machine holds snapshots of `www.xgam.org` on **2 June 2002** and **19 July 2002** and nothing in between, so the crash-notice window is precisely the gap in coverage. **[WEB]** Both snapshots were requested while this chapter was written -- specifically to corroborate the 13 June 2002 post quoted above -- and the Wayback Machine returned **HTTP 503** on each attempt. That is a service condition, not an absence of data, and the fetch should simply be retried. **To do, at a future date:** 1. Retry `http://web.archive.org/web/20020719001302/http://www.xgam.org/` and the 2 June 2002 snapshot. The July page may still carry a recovery notice or a changed forum platform. 2. Widen the CDX query beyond the index page. Snapshots of `xgam.org/forum*`, `xgam.org/cgi-bin/*` or a subdomain may fall inside the gap even though the index does not. 3. If the July page shows a board running something other than Ikonboard, or describes an intrusion, that materially strengthens the common-cause reading of section 6. If it shows Ikonboard running normally with no reference to an outage, FieRcE YeD's claim weakens and coincidence becomes the better explanation. Until one of those is done, section 6 stands as written: mechanism confirmed, trigger inferred. --- ## 10. How to reproduce this Everything in this document was derived from one 839 KB directory tree using seven Python scripts, all of which are reproduced in full in the next section. Nothing here required running the software, and nothing required a Perl interpreter. ### Getting the source The distribution is preserved at -- 145 files, 839,181 bytes: ``` ib219/ +-- cgi-bin/ 37 Perl programs, libraries and data files +-- non-cgi/ 78 GIF and JPEG interface images +-- upgrading/ the 2.1.5 -> 2.1.9 format converter +-- README.txt Changes.txt license.html +-- first_install.html help.html ``` Two things that archive is **not**. It is not the pristine 7 June package. Four of those files carry later timestamps -- three from 8 June and `ikon.lib` from 9 September 2001, the last of them being the billion-second fix. What survives is the 2.1.9 tree **as ikonboard.com maintained and re-issued it**, not as it first shipped, which is exactly why its own file dates repay close reading. Section 8 works through what they show. And it does not contain this document or the scripts in section 11. Those were written afterwards, against the unpacked tree, and are reproduced here in full so the analysis can be re-run and checked rather than taken on trust. To repeat the work, unpack the archive and put the scripts anywhere -- each takes the tree root as its only argument, defaulting to the parent of its own directory, so dropping them in a `teardown/` subdirectory needs no arguments at all: ``` ib219/ +-- cgi-bin/ non-cgi/ upgrading/ *.txt *.html <- from archive.org +-- teardown/ <- added; the scripts below ``` ### The method The tree is small enough to read end to end, and for a document like this that is the right approach -- every claim below was ultimately confirmed by reading the Perl. The scripts exist because five specific questions could not be answered reliably by reading alone: **1. What are the record formats?** There is no schema anywhere in Ikonboard. Field order exists only in the `split(/\|/, ...)` list assignments scattered through 44 files. `ib_records.py` collects all 162 of them, groups identical field lists, and ranks by corroboration. When 21 independent sites unpack the same 15 names in the same order, that is the forum record -- and the sites that disagree are exactly the truncated reads and stale copies worth investigating. **2. What are the endpoints?** There is no route table. Every script dispatches through an if/elsif chain of string comparisons against a CGI parameter. `ib_actions.py` reads the comparisons back out of the code to recover the complete list of 48 `script.cgi?action=...` endpoints, plus the parameters and cookies each script reads. **3. Where does untrusted input reach a filename?** None of the scripts run under `perl -T`, so nothing is tainted in the interpreter's sense. `ib_taint.py` does the bookkeeping by hand -- finds the variables assigned from `param()`, `cookie()` and `%ENV`, then reports which of them reach an `open()` without passing a sanitizer first. It is a lexical scan and cannot see through ikon.lib's global-variable aliasing, so its output is a worklist, not a verdict. Every finding quoted in this document was confirmed by reading the source. **4. Which file operations are dangerous?** `ib_fileio.py` catalogs all 206 `open()` calls, classifies each by mode, notes whether a `flock` follows and on which handle, and flags two-argument opens carrying interpolated variables. The ordering it reports -- lock acquired *after* a truncating open -- is what establishes that the locking protects nothing. **5. What happened to this particular copy?** `ib_provenance.py` first decides whether the tree was ever installed, by looking for the files Ikonboard generates on first setup and first page view -- that single question governs how every date in the tree is read, since an uninstalled copy's mtimes record the vendor's edits rather than an administrator's. It then diffs the `.bak` files against their live counterparts, date-brackets the tree against the newest shipped document, and harvests attribution evidence. This is what surfaced the September 2001 patch and the two-minute `post.cgi` edit. `ib_subs.py` and `ib_manifest.py` are supporting: the first builds the call graph that the source never declares (84 subs defined, 131 called), the second produces the inventory and the SHA-256 hashes. ### Running them ```sh cd ib219/teardown python3 ib_manifest.py # inventory, hashes, date bracket python3 ib_provenance.py # .bak diffs, attribution evidence python3 ib_actions.py # the routing table python3 ib_records.py # the record formats python3 ib_subs.py # the call graph python3 ib_fileio.py # every file operation, classified python3 ib_taint.py # request data reaching filenames ``` Each takes an optional tree root as its first argument and writes a plain-text report to stdout. No dependencies beyond the Python standard library. ### Rebuilding this document ```sh python3 build_readme.py IKONBOARD-2.1.9-teardown.md --txt ``` `readme_src.md` is the skeleton: a title, a contents table, and a marker per chapter. Chapters are pulled in by `<!--CHAPTER:n:file.md-->`, which demotes their headings so the assembled document has one coherent outline, and each `<!--CODE:name.py-->` marker is replaced at build time with that script's current contents -- so the code printed below can never drift from the code that produced the findings. The builder also normalizes spelling to American English -- with a keep-list so that material quoted from the 2001 source and documentation stays verbatim, British spellings and all -- folds the output to pure ASCII, and fails loudly if a local filesystem path or an unexpanded marker survives into the output. --- ## 11. The toolchain Seven scripts, reproduced in full. Together they are about 900 lines of Python with no dependencies outside the standard library. ### `ib_manifest.py` Inventory: size, mtime, SHA-256, line endings and line count for every file, plus per-directory totals and the date bracket. The mtimes are the ones preserved inside the original zip, so they date the individual files rather than the archive -- which is how the out-of-band `ikon.lib` was spotted against a distribution that is otherwise no later than 7 June 2001. ```python """Inventory every file in the Ikonboard 2.1.9 distribution. Emits size, mtime, SHA-256, line endings and line count for each file, plus a per-directory summary. The mtimes are the ones preserved inside the original zip, so they date the individual files rather than the archive -- which is how the out-of-band `ikon.lib` (2001-09-08) was spotted against a distribution that is otherwise no later than 2001-06-07. Usage: python3 ib_manifest.py [root] [--json out.json] """ import hashlib import json import os import sys from datetime import datetime, timezone TEXT_EXT = {'.cgi', '.lib', '.pl', '.dat', '.txt', '.html', '.bak'} # This tooling lives inside the tree it inspects; keep it out of the counts. SKIP_DIRS = {'teardown'} def line_endings(data): crlf = data.count(b'\r\n') lf = data.count(b'\n') - crlf cr = data.count(b'\r') - crlf tags = [] if crlf: tags.append('CRLF=%d' % crlf) if lf: tags.append('LF=%d' % lf) if cr: tags.append('CR=%d' % cr) return ','.join(tags) or 'none' def scan(root): rows = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) for name in sorted(filenames): full = os.path.join(dirpath, name) rel = os.path.relpath(full, root).replace('\\', '/') data = open(full, 'rb').read() ext = os.path.splitext(name)[1].lower() istext = ext in TEXT_EXT st = os.stat(full) rows.append({ 'path': rel, 'dir': os.path.dirname(rel) or '.', 'ext': ext, 'size': len(data), 'mtime': datetime.fromtimestamp( st.st_mtime, timezone.utc).strftime('%Y-%m-%d'), 'sha256': hashlib.sha256(data).hexdigest(), 'text': istext, 'lines': data.count(b'\n') + 1 if istext and data else 0, 'eol': line_endings(data) if istext else '-', 'nonascii': sum(1 for b in data if b > 126) if istext else 0, }) return rows def main(): args = [a for a in sys.argv[1:] if not a.startswith('--')] root = args[0] if args else os.path.dirname(os.path.dirname( os.path.abspath(__file__))) rows = scan(root) print('%-46s %8s %-10s %-7s %s' % ('PATH', 'BYTES', 'DATE', 'LINES', 'SHA-256 (first 16)')) print('-' * 100) for r in rows: print('%-46s %8d %-10s %-7s %s' % ( r['path'], r['size'], r['mtime'], r['lines'] or '-', r['sha256'][:16])) print() print('Totals by directory') print('-' * 100) bydir = {} for r in rows: d = bydir.setdefault(r['dir'], {'n': 0, 'bytes': 0, 'lines': 0}) d['n'] += 1 d['bytes'] += r['size'] d['lines'] += r['lines'] for d in sorted(bydir): v = bydir[d] print('%-30s %4d files %10d bytes %8d lines' % ( d, v['n'], v['bytes'], v['lines'])) total = sum(r['size'] for r in rows) code = [r for r in rows if r['ext'] in ('.cgi', '.lib') and r['dir'] == 'cgi-bin'] print() print('%d files, %d bytes total' % (len(rows), total)) print('%d top-level Perl programs/libraries, %d bytes, %d lines' % ( len(code), sum(r['size'] for r in code), sum(r['lines'] for r in code))) # File dates bracket the release; anything outside is a post-release edit. dates = sorted({r['mtime'] for r in rows}) print('file dates span %s .. %s' % (dates[0], dates[-1])) newest = max(rows, key=lambda r: r['mtime']) print('newest file: %s (%s)' % (newest['path'], newest['mtime'])) if '--json' in sys.argv: out = sys.argv[sys.argv.index('--json') + 1] json.dump(rows, open(out, 'w'), indent=1) print('wrote %s' % out) if __name__ == '__main__': main() ``` ### `ib_provenance.py` Establishes whether the tree was ever installed, diffs each `*.bak` against its live counterpart, names the files that postdate the release, and harvests names, handles, email addresses, URLs and version strings. Groups every script by its exact copyright header so the odd ones stand out. ```python """Provenance and archaeology checks on the distribution. Four jobs: 0. Decide whether the tree was ever installed. Ikonboard generates a set of files the first time it is set up and used, none of which ship in the zip. If any exist, the tree is a deployed board and its mtimes record an operator's actions; if none do, it is an unpacked download and they record the vendor's. That distinction decides how every other date here is read. 1. Diff each `*.bak` against its live counterpart. The distribution shipped with two backup files still in place, and the differences are the last edits anyone made to this tree. 2. Date-bracket the tree and name the files that fall outside the release window -- edits made after the release was packaged, which for an uninstalled tree means the vendor revised the download in place. 3. Harvest attribution evidence: names, handles, email addresses and URLs, plus the version strings, so the copyright headers can be checked against what the code actually says. Usage: python3 ib_provenance.py [root] """ import difflib import os import re import sys from datetime import datetime, timezone EMAIL = re.compile(r'[\w.\-]+@[\w.\-]+\.\w{2,}') URL = re.compile(r'https?://[^\s"\'<>)~]+') VERSION = re.compile(r'\$versionnumber\s*=\s*[\'"]([^\'"]+)') CREDIT = re.compile(r'\(?(?:Credit|Thanks|Written|Fix(?:ed)?)\s+(?:To|to|by|By)\s+([A-Z][\w .\-]{1,30})') AUTHORLINE = re.compile(r'^#.*(?:written by|Copyright|Author)[^\n]*', re.M | re.I) SKIP_DIRS = {'teardown'} TEXT_EXT = {'.cgi', '.lib', '.pl', '.dat', '.txt', '.html', '.bak'} def text_files(root): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for name in sorted(filenames): if os.path.splitext(name)[1].lower() in TEXT_EXT: full = os.path.join(dirpath, name) yield os.path.relpath(full, root).replace('\\', '/'), full def main(): root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( os.path.dirname(os.path.abspath(__file__))) files = list(text_files(root)) print('=' * 78) print('0. WAS THIS TREE EVER INSTALLED?') print('=' * 78) cgi = os.path.join(root, 'cgi-bin') generated = [ ('data/boardinfo.cgi', 'install.cgi step 3', 'require()d by all 32 scripts at startup'), ('data/onlinedata.dat', 'ikon.lib sub whosonline', 'rewritten on EVERY page view, guests included'), ('data/boardstats.cgi', 'install / runtime', 'require()d by ikonboard.cgi for the totals line'), ('data/allforums.cgi', 'setforums.cgi', 'the forum table, read on every board page'), ('data/hacklog.cgi', 'checkVALIDITY / systemerror', 'appended on referer mismatch, never rotated'), ('data/badwords.cgi', 'setbadwords.cgi', 'word filter'), ('data/banlist.cgi', 'setmembers.cgi', 'ban list'), ('data/news.cgi', 'announcements.cgi', 'announcements'), ] found = [] for rel, maker, why in generated: hit = os.path.exists(os.path.join(cgi, rel.replace('/', os.sep))) if hit: found.append(rel) print(' %-8s %-22s by %-28s %s' % ('PRESENT' if hit else 'absent', rel, maker, why)) def listing(sub): p = os.path.join(cgi, sub) return sorted(os.listdir(p)) if os.path.isdir(p) else None members = listing('members') or [] messages = listing('messages') or [] forums = sorted(d for d in (os.listdir(cgi) if os.path.isdir(cgi) else []) if d.startswith('forum') and os.path.isdir(os.path.join(cgi, d))) print('\n members/ %d file(s) %s' % (len(members), members or '(empty)')) print(' messages/ %d file(s) %s' % (len(messages), messages or '(empty)')) print(' forumN/ %d dir(s) %s' % (len(forums), forums or '(none)')) live = bool(found or members or forums or [m for m in messages if m != 'index.html']) print('\n VERDICT: %s' % ( 'deployed board -- mtimes record an operator' if live else 'unpacked distribution, NEVER INSTALLED -- mtimes record the vendor')) print() print('=' * 78) print('1. BACKUP FILE DIFFS -- the last edits made to this tree') print('=' * 78) for rel, full in files: if not rel.endswith('.bak'): continue live = full[:-4] if not os.path.exists(live): print('\n%s -- no live counterpart' % rel) continue a = open(full, encoding='latin-1').read().splitlines() b = open(live, encoding='latin-1').read().splitlines() da = datetime.fromtimestamp(os.stat(full).st_mtime, timezone.utc).strftime('%Y-%m-%d') db = datetime.fromtimestamp(os.stat(live).st_mtime, timezone.utc).strftime('%Y-%m-%d') print('\n--- %s (%s, %d lines)' % (rel, da, len(a))) print('+++ %s (%s, %d lines)' % ( os.path.relpath(live, root).replace('\\', '/'), db, len(b))) diff = list(difflib.unified_diff(a, b, lineterm='', n=1)) if not diff: print(' (identical)') for line in diff[2:]: print(' %s' % line.rstrip()) print() print('=' * 78) print('2. DATE BRACKET -- files outside the release window') print('=' * 78) dated = [(datetime.fromtimestamp(os.stat(f).st_mtime, timezone.utc).strftime('%Y-%m-%d'), r) for r, f in files] dated.sort() print('earliest: %s %s' % dated[0]) print('latest: %s %s' % dated[-1]) # The documented release date is the newest doc file. Anything newer was # edited after packaging -- by the vendor, if section 0 shows the tree was # never installed, since no operator could have been running it. docs = [d for d, r in dated if r.endswith(('.txt', '.html'))] release = max(docs) if docs else dated[-1][0] print('release window closes: %s (newest shipped document)' % release) after = [(d, r) for d, r in dated if d > release] if after: print('\nFILES NEWER THAN THE RELEASE -- edited after the release was packaged:') for d, r in after: print(' %s %s' % (d, r)) else: print('\nno files postdate the release') print() print('=' * 78) print('3. ATTRIBUTION EVIDENCE') print('=' * 78) emails, urls, credits, versions = set(), set(), set(), set() headers = {} for rel, full in files: text = open(full, encoding='latin-1').read() emails |= set(EMAIL.findall(text)) urls |= set(URL.findall(text)) credits |= {c.strip(' .)') for c in CREDIT.findall(text)} versions |= set(VERSION.findall(text)) if rel.endswith(('.cgi', '.lib')): hs = AUTHORLINE.findall(text) if hs: headers[rel] = tuple(h.strip() for h in hs) print('\nversion strings in code: %s' % ', '.join(sorted(versions))) print('\nemail addresses (%d):' % len(emails)) for e in sorted(emails): print(' %s' % e) print('\nURLs (%d):' % len(urls)) for u in sorted(urls): print(' %s' % u) print('\nnamed credits in prose (%d):' % len(credits)) for c in sorted(credits): print(' %s' % c) # Group scripts by their exact copyright header so odd ones stand out. print('\ncopyright/author header variants:') byheader = {} for rel, hs in headers.items(): byheader.setdefault(hs, []).append(rel) for hs, rels in sorted(byheader.items(), key=lambda kv: -len(kv[1])): print('\n %d file(s):' % len(rels)) for h in hs: print(' %s' % h) if len(rels) <= 6: print(' -> %s' % ', '.join(sorted(rels))) noheader = [r for r, f in files if r.endswith(('.cgi', '.lib')) and r not in headers] if noheader: print('\n NO author/copyright header (%d): %s' % ( len(noheader), ', '.join(sorted(noheader)))) if __name__ == '__main__': main() ``` ### `ib_actions.py` Recovers the routing table by reading the dispatch chains. For each script: the `action` values it tests for, the parameters and cookies it reads, and the state variables it compares against string literals -- which is where the permission model lives. ```python """Recover the routing table Ikonboard never wrote down. Every script dispatches on a CGI parameter -- usually `action` -- through an if/elsif chain of string comparisons. There is no route table anywhere in the source, so the only way to enumerate the board's endpoints is to read the comparisons back out of the code. For each script this reports the parameters it reads and the literal values its dispatch chain tests for. The result is the complete endpoint list: `script.cgi?action=`. Usage: python3 ib_actions.py [root] """ import os import re import sys # $action eq "foo" / $action eq 'foo' / ($action eq "foo") EQ = re.compile(r'\$(\w+)\s+eq\s+[\'"]([^\'"]{1,40})[\'"]') # param('x') and cookie('x') PARAM = re.compile(r'\bparam\s*\(\s*[\'"]([^\'"]+)[\'"]') COOKIE = re.compile(r'\bcookie\s*\(\s*[\'"]([^\'"]+)[\'"]') # $IN{-Foo} style hash access used by the newer routines INHASH = re.compile(r'\$IN\{\s*-?(\w+)\s*\}') DISPATCH_VARS = ('action', 'act', 'code', 'CODE') def main(): root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( os.path.dirname(os.path.abspath(__file__))) cgidir = os.path.join(root, 'cgi-bin') names = sorted(n for n in os.listdir(cgidir) if n.endswith('.cgi') and os.path.isfile(os.path.join(cgidir, n))) extra = os.path.join(root, 'upgrading', 'update_forums.cgi') paths = [os.path.join(cgidir, n) for n in names] if os.path.exists(extra): paths.append(extra) grand = 0 for path in paths: rel = os.path.relpath(path, root).replace('\\', '/') text = open(path, encoding='latin-1').read() body = '\n'.join(l for l in text.splitlines() if not l.lstrip().startswith('#')) actions, others = [], {} for var, val in EQ.findall(body): if var in DISPATCH_VARS: if val not in actions: actions.append(val) else: others.setdefault(var, set()).add(val) params = sorted(set(PARAM.findall(body))) cookies = sorted(set(COOKIE.findall(body))) inkeys = sorted(set(INHASH.findall(body))) print('=' * 74) print('%s (%d lines)' % (rel, body.count('\n') + 1)) print('=' * 74) if actions: grand += len(actions) print(' actions (%d):' % len(actions)) for a in actions: print(' ?action=%s' % a) else: print(' actions: none -- single entry point') if params: print(' param(): %s' % ', '.join(params)) if cookies: print(' cookie(): %s' % ', '.join(cookies)) if inkeys: print(' $IN{-*}: %s' % ', '.join(inkeys)) # Other string-compared state vars are usually the permission model. interesting = {k: v for k, v in others.items() if k in ('membercode', 'inmembmod', 'userregistered', 'privateforum', 'threadstate', 'htmlstate', 'maintenancemode', 'showemail', 'method')} if interesting: print(' state comparisons:') for k in sorted(interesting): vals = sorted(x for x in interesting[k] if x) print(' $%-16s in {%s}' % (k, ', '.join(vals))) print() print('%d scripts, %d distinct action endpoints total' % (len(paths), grand)) if __name__ == '__main__': main() ``` ### `ib_records.py` Recovers the on-disk record formats from the code that parses them. Collects every `split` list assignment, groups identical field lists, ranks by how many independent sites agree. ```python """Recover the on-disk record formats from the code that parses them. Ikonboard stores everything as pipe-delimited lines and there is no schema anywhere -- the field order exists only in the `split(/\\|/, ...)` list assignments scattered through the scripts. This collects every one of them, groups identical field lists, and ranks by how many places agree. That agreement is the evidence: when eleven scripts independently unpack the same 22 names in the same order, that is the member record definition. Where scripts disagree, the mismatch is reported -- those are the truncated reads (deliberate) and the stale ones (bugs). Usage: python3 ib_records.py [root] """ import os import re import sys from collections import defaultdict # ($a, $b, $c) = split(/\|/, $line); SPLIT = re.compile( r'\(\s*((?:\$\w+\s*,\s*)*\$\w+)\s*\)\s*=\s*split\s*\(\s*([/!|].{1,12}?[/!|])\s*,', re.S) SKIP_DIRS = {'teardown', 'non-cgi'} def perl_files(root): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for name in sorted(filenames): if os.path.splitext(name)[1].lower() in ('.cgi', '.lib', '.pl'): full = os.path.join(dirpath, name) yield os.path.relpath(full, root).replace('\\', '/'), full def main(): root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( os.path.dirname(os.path.abspath(__file__))) shapes = defaultdict(list) # (delim, fields) -> [(file, line)] delims = defaultdict(int) for rel, full in perl_files(root): text = open(full, encoding='latin-1').read() offsets = [0] for ch in text: offsets.append(offsets[-1] + 1) for m in SPLIT.finditer(text): fields = tuple(f.strip().lstrip('$') for f in m.group(1).split(',')) delim = m.group(2) line = text.count('\n', 0, m.start()) + 1 shapes[(delim, fields)].append((rel, line)) delims[delim] += 1 print('=' * 78) print('DELIMITERS IN USE') print('=' * 78) for d, n in sorted(delims.items(), key=lambda kv: -kv[1]): print(' %-12s %d split sites' % (d, n)) print() print('=' * 78) print('RECORD SHAPES, most-corroborated first') print('=' * 78) ranked = sorted(shapes.items(), key=lambda kv: (-len(kv[1]), -len(kv[0][1]))) for (delim, fields), sites in ranked: real = [f for f in fields if f != 'trash'] if len(sites) < 2 and len(real) < 4: continue # skip one-off two-field splits, they are noise print() print('-- %d fields, delimiter %s, seen in %d place(s)' % ( len(fields), delim, len(sites))) for i, f in enumerate(fields): note = ' (discard)' if f == 'trash' else '' print(' [%2d] %s%s' % (i, f, note)) show = sites[:8] print(' sites: %s%s' % ( ', '.join('%s:%d' % s for s in show), ' ... +%d more' % (len(sites) - 8) if len(sites) > 8 else '')) print() print('%d distinct record shapes across %d split sites' % ( len(shapes), sum(len(v) for v in shapes.values()))) if __name__ == '__main__': main() ``` ### `ib_subs.py` Builds the call graph. `ikon.lib` is `require`d into the same package as the calling script, so every sub is a global in `main::` and any script can call any of them; the graph exists but is never declared. Also reports subs defined and never called, and subs called but never defined. ```python """Map every Perl sub in the distribution and who calls it. Ikonboard has no module system -- `ikon.lib` is `require`d into the same package as the calling script, so every sub is a global in `main::` and any script can call any of them. This builds the call graph that the source itself never declares. Reports: subs defined per file, call counts, the shared-library API surface ranked by use, and subs that are defined but never called (dead code). Usage: python3 ib_subs.py [root] """ import os import re import sys from collections import defaultdict SUB_DEF = re.compile(r'^\s*sub\s+([A-Za-z_]\w*)', re.M) # Perl calls: &name(...), &name;, or bareword name(...) for known subs. SUB_CALL = re.compile(r'&\s*([A-Za-z_]\w*)') SKIP_DIRS = {'teardown', 'non-cgi'} def perl_files(root): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for name in sorted(filenames): if os.path.splitext(name)[1].lower() in ('.cgi', '.lib', '.pl'): full = os.path.join(dirpath, name) rel = os.path.relpath(full, root).replace('\\', '/') yield rel, open(full, encoding='latin-1').read() def main(): root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( os.path.dirname(os.path.abspath(__file__))) defs = defaultdict(list) # sub -> [file] defs_by_file = defaultdict(list) calls = defaultdict(lambda: defaultdict(int)) # sub -> file -> count files = list(perl_files(root)) for rel, text in files: # Strip comment lines so commented-out calls do not count. body = '\n'.join(l for l in text.splitlines() if not l.lstrip().startswith('#')) for m in SUB_DEF.finditer(body): defs[m.group(1)].append(rel) defs_by_file[rel].append(m.group(1)) for m in SUB_CALL.finditer(body): calls[m.group(1)][rel] += 1 print('=' * 78) print('SUBS DEFINED PER FILE') print('=' * 78) for rel in sorted(defs_by_file): names = defs_by_file[rel] print('\n%s (%d)' % (rel, len(names))) for n in names: total = sum(calls[n].values()) callers = len(calls[n]) print(' %-24s calls=%-4d from %d file(s)' % (n, total, callers)) print() print('=' * 78) print('SHARED LIBRARY API SURFACE (subs defined in *.lib), by use') print('=' * 78) libsubs = [(n, f) for n, fs in defs.items() for f in fs if f.endswith('.lib')] ranked = sorted(libsubs, key=lambda nf: -sum(calls[nf[0]].values())) print('%-24s %-22s %6s %6s' % ('SUB', 'DEFINED IN', 'CALLS', 'FILES')) print('-' * 78) for n, f in ranked: print('%-24s %-22s %6d %6d' % ( n, os.path.basename(f), sum(calls[n].values()), len(calls[n]))) print() print('=' * 78) print('DEFINED BUT NEVER CALLED (candidate dead code)') print('=' * 78) for n in sorted(defs): if not calls[n]: print(' %-24s defined in %s' % (n, ', '.join(defs[n]))) print() print('=' * 78) print('CALLED BUT NEVER DEFINED (external / CGI.pm / typo)') print('=' * 78) for n in sorted(calls): if n not in defs: total = sum(calls[n].values()) print(' %-24s %d call(s) in %s' % ( n, total, ', '.join(sorted(calls[n])[:4]))) print() print('%d Perl files, %d distinct subs defined, %d distinct subs called' % ( len(files), len(defs), len(calls))) if __name__ == '__main__': main() ``` ### `ib_fileio.py` Catalogs every file operation and classifies its risk: mode, whether a `flock` follows and on which handle, whether the interpolated path passed through `stripMETA` first, and whether the open is a pipe. ```python """Catalog every file operation and classify its risk. Ikonboard is a filesystem database, so `open()` is its query language. This finds every open/opendir/unlink/mkdir in the tree, classifies the mode, and flags the two things that matter for a Perl CGI of this era: * two-argument `open` with an interpolated variable -- the filename is parsed for mode characters, so a leading `>`, `>>` or a trailing `|` in the DATA can change the operation; * whether the interpolated path was passed through `stripMETA` first. Also reports `flock` usage relative to `open`, because the ordering is what determines whether the lock protects anything (a `>` open truncates before the lock is ever taken). Usage: python3 ib_fileio.py [root] """ import os import re import sys from collections import Counter OPEN = re.compile(r'\bopen\s*\(?\s*([A-Za-z_]\w*)\s*,\s*("([^"]*)"|\'([^\']*)\'|[^)\n;]+)') OPENDIR = re.compile(r'\bopendir\s*\(\s*(\w+)\s*,\s*("([^"]*)"|[^)\n]+)') UNLINK = re.compile(r'\bunlink\b[^;\n]*') MKDIR = re.compile(r'\bmkdir\b[^;\n]*') FLOCK = re.compile(r'\bflock\s*\(?\s*(\$?\w+)\s*,\s*(\d)') VAR = re.compile(r'\$\w+') SKIP_DIRS = {'teardown', 'non-cgi'} def mode_of(target): t = target.strip().strip('"\'') if t.startswith('>>'): return 'append' if t.startswith('>'): return 'WRITE/TRUNCATE' if t.startswith('+<'): return 'read-write' if t.startswith('<'): return 'read' if t.endswith('|'): return 'PIPE-FROM' if t.startswith('|'): return 'PIPE-TO' return 'read (implicit)' def perl_files(root): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for name in sorted(filenames): if os.path.splitext(name)[1].lower() in ('.cgi', '.lib', '.pl'): full = os.path.join(dirpath, name) yield os.path.relpath(full, root).replace('\\', '/'), full def main(): root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( os.path.dirname(os.path.abspath(__file__))) modes = Counter() total = 0 interpolated = 0 unstripped = [] pipes = [] for rel, full in perl_files(root): lines = open(full, encoding='latin-1').read().splitlines() # Which variables were stripMETA'd anywhere earlier in this file. stripped_at = {} for i, line in enumerate(lines): m = re.search(r'(\$\w+)\s*=\s*&?stripMETA\s*\(', line) if m: stripped_at[m.group(1)] = i hits = [] for i, line in enumerate(lines): if line.lstrip().startswith('#'): continue for m in OPEN.finditer(line): fh, target = m.group(1), m.group(2) mode = mode_of(target) modes[mode] += 1 total += 1 vars_used = VAR.findall(target) interp = bool(vars_used) if interp: interpolated += 1 # Was any interpolated var stripMETA'd before this line? safe = any(v in stripped_at and stripped_at[v] < i for v in vars_used) # Look for a flock on this handle within the next 3 lines. lock = '' for j in range(i, min(i + 4, len(lines))): fm = FLOCK.search(lines[j]) if fm and fm.group(1).lstrip('$') == fh.lstrip('$'): lock = 'LOCK_SH' if fm.group(2) == '1' else 'LOCK_EX' break hits.append((i + 1, fh, mode, target.strip()[:52], interp, safe, lock)) if interp and not safe: unstripped.append((rel, i + 1, mode, target.strip()[:60])) if 'PIPE' in mode: pipes.append((rel, i + 1, target.strip()[:60])) if hits: print('=' * 78) print(rel) print('=' * 78) print('%-6s %-10s %-16s %-8s %s' % ( 'LINE', 'HANDLE', 'MODE', 'LOCK', 'TARGET')) for ln, fh, mode, tgt, interp, safe, lock in hits: mark = '' if not interp else (' [stripMETA]' if safe else ' [RAW VAR]') print('%-6d %-10s %-16s %-8s %s%s' % ( ln, fh, mode, lock or '-', tgt, mark)) print() print('=' * 78) print('SUMMARY') print('=' * 78) print('%d open() calls total, %d with an interpolated variable' % ( total, interpolated)) for mode, n in modes.most_common(): print(' %-18s %d' % (mode, n)) print() print('PIPE opens (command execution): %d' % len(pipes)) for rel, ln, tgt in pipes: print(' %s:%d %s' % (rel, ln, tgt)) print() print('Interpolated opens with NO prior stripMETA in the same file: %d' % ( len(unstripped))) for rel, ln, mode, tgt in unstripped: print(' %-34s:%-5d %-16s %s' % (rel, ln, mode, tgt)) if __name__ == '__main__': main() ``` ### `ib_taint.py` Follows request data to dangerous sinks by hand, since nothing here runs under `perl -T`. Output is a worklist for manual review, not a verdict. ```python """Follow untrusted input to dangerous sinks. None of the scripts run under `perl -T`, so nothing in Ikonboard is tainted in the interpreter's sense. This does the bookkeeping by hand: it finds the variables that receive request data (`param`, `cookie`, `$ENV{...}`) and reports the ones that reach a sink -- a filename, printed output, or a sanitizer -- without passing through one of the escaping routines first. It is a lexical scan, not dataflow analysis: it cannot see through the global-variable aliasing that ikon.lib relies on, so treat the output as a worklist of places to read, not a verdict. Every finding quoted in the security section was confirmed by reading the source. Usage: python3 ib_taint.py [root] """ import os import re import sys from collections import defaultdict SOURCE = re.compile( r'(\$\w+)\s*=\s*(?:\$query->)?(param|cookie)\s*\(\s*[\'"]?([^\'")]*)') ENVSRC = re.compile(r'(\$\w+)\s*=\s*\$ENV\{\s*[\'"]?(\w+)') SANITIZE = re.compile(r'&?(cleaninput|unHTML|cleanarea|stripMETA)\s*\(\s*"?(\$\w+)') OPENSINK = re.compile(r'\bopen\s*\(?\s*\w+\s*,\s*[^;\n]*?(\$\w+)') PRINTSINK = re.compile(r'(?:print|qq~|qq\()[^;\n]*?(\$\w+)') SKIP_DIRS = {'teardown', 'non-cgi'} # Variables the libraries populate from request data even though the # assignment is not lexically visible in the consuming script. KNOWN_TAINTED = { 'inmembername', 'inpassword', 'inforum', 'intopic', 'action', 'membername', 'ipaddress', } def perl_files(root): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] for name in sorted(filenames): if os.path.splitext(name)[1].lower() in ('.cgi', '.lib', '.pl'): full = os.path.join(dirpath, name) yield os.path.relpath(full, root).replace('\\', '/'), full def main(): root = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname( os.path.dirname(os.path.abspath(__file__))) allsources = defaultdict(set) file_to_sink = [] for rel, full in perl_files(root): lines = open(full, encoding='latin-1').read().splitlines() tainted = {} # var -> line first assigned from request sanitized = {} # var -> line sanitized for i, line in enumerate(lines): if line.lstrip().startswith('#'): continue for var, fn, key in SOURCE.finditer(line): tainted.setdefault(var.lstrip('$'), (i + 1, '%s(%s)' % (fn, key))) allsources[rel].add('%s(%s)' % (fn, key)) for var, key in ENVSRC.finditer(line): tainted.setdefault(var.lstrip('$'), (i + 1, 'ENV{%s}' % key)) allsources[rel].add('ENV{%s}' % key) for fn, var in SANITIZE.finditer(line): sanitized.setdefault(var.lstrip('$'), (i + 1, fn)) for name in KNOWN_TAINTED: tainted.setdefault(name, (0, 'library global')) for i, line in enumerate(lines): if line.lstrip().startswith('#'): continue for m in OPENSINK.finditer(line): v = m.group(1).lstrip('$') if v in tainted: s = sanitized.get(v) file_to_sink.append( (rel, i + 1, v, 'open()', tainted[v][1], '%s@%d' % (s[1], s[0]) if s else 'NONE', line.strip()[:56])) if tainted: print('=' * 78) print('%s' % rel) print('=' * 78) for v in sorted(tainted): ln, src = tainted[v] s = sanitized.get(v) print(' $%-22s <- %-18s %s' % ( v, src, 'sanitized by %s at line %d' % (s[1], s[0]) if s else 'no sanitizer seen')) print() print('=' * 78) print('REQUEST DATA REACHING open() AS A FILENAME') print('=' * 78) print('%-30s %-6s %-16s %-14s %s' % ( 'FILE', 'LINE', 'VARIABLE', 'SANITIZER', 'SOURCE')) print('-' * 78) for rel, ln, v, sink, src, san, _code in sorted(file_to_sink): print('%-30s %-6d $%-15s %-14s %s' % (rel, ln, v, san, src)) print() print('%d request-data-to-filename sites, %d unsanitized' % ( len(file_to_sink), sum(1 for r in file_to_sink if r[5] == 'NONE'))) print() print('=' * 78) print('DISTINCT REQUEST INPUTS PER SCRIPT') print('=' * 78) for rel in sorted(allsources): print('%-34s %s' % (rel, ', '.join(sorted(allsources[rel])))) if __name__ == '__main__': main() ``` ### `build_readme.py` Assembles this document: expands the code markers, normalizes spelling, folds to ASCII, and checks for leaked paths. ```python """Assemble the standalone Ikonboard 2.1.9 teardown document. `readme_src.md` is the prose. Every `` line is replaced by that file's current contents in a fenced block, so the published document can never drift from the scripts that produced its findings. Usage: python3 build_readme.py [output.md] [--txt] """ import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(HERE, 'readme_src.md') DEFAULT_OUT = os.path.join(HERE, 'IKONBOARD-2.1.9-teardown.md') # Spellings to normalize on the way out. Material quoted from the 2001 source # and documentation is British English and must stay verbatim -- it is # protected by `KEEP` below. US = [ ('color', 'color'), ('Color', 'Color'), ('behavior', 'behavior'), ('Behavior', 'Behavior'), ('artifact', 'artifact'), ('Artifact', 'Artifact'), ('organized', 'organized'), ('recognized', 'recognized'), ('center', 'center'), ('Center', 'Center'), ('labeled', 'labeled'), ('Labeled', 'Labeled'), ('modeled', 'modeled'), ('canceled', 'canceled'), ('analyze', 'analyze'), ('catalog', 'catalog'), ('honor', 'honor'), ('favor', 'favor'), ('defense', 'defense'), ('while', 'while'), ('among', 'among'), ('gray', 'gray'), ('Gray', 'Gray'), ('practice', 'practice'), ('traveled', 'traveled'), ('signaled', 'signaled'), ('summarize', 'summarize'), ('license', 'license'), ] # Strings quoted from the 2001 distribution. These are primary-source # material -- the original spelling is the evidence, so never respell it. KEEP = [ 'Messenger is now spelt correctly', 'Check for authorisation', 'summarise what the forum is about', 'Ikonboard License and cost information', 'Ikonboard License costing', 'License Ikonboard without the copyright information', 'To keep this license valid', 'this license agreement', 'the terms of this license', 'Removing this invalidates your license', ] # The document is served as a raw file. Web servers have no mime mapping for # .md, so it goes out with no charset and browsers fall back to windows-1252, # which turns every UTF-8 em dash into "a-". Rather than depend on every # surface being configured correctly, keep the output pure ASCII -- it then # renders identically no matter what charset the reader guesses. ASCII_FOLD = [ ('--', '--'), # em dash ('-', '-'), # en dash ('-', '-'), # box drawings light horizontal ('|', '|'), # box drawings light vertical ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('>', '>'), # black right-pointing triangle ('->', '->'), # rightwards arrow ('<-', '<-'), # leftwards arrow ('<', '<'), ('...', '...'), # horizontal ellipsis ('*', '*'), # middle dot ('section ', 'section '), ('(c)', '(c)'), ('GBP ', 'GBP '), # pound sign -- the license is priced in sterling (''', "'"), (''', "'"), ('"', '"'), ('"', '"'), ('x', 'x'), (' ', ' '), # non-breaking space ('yes', 'yes'), ('no', 'no'), ('*', '*'), ('v', 'v'), ('>', '>'), ('>=', '>='), ('<=', '<='), ('!=', '!='), ('~', '~'), ('-', '-'), # minus sign, distinct from hyphen-minus ('>>', '>>'), ('<<', '<<'), ] # The fence character, never written here as a literal run of three -- not even # in a comment. This file embeds its own source in the document, so a literal # run would land inside a fenced block and unbalance the document's backtick # count. Line-oriented highlighters (Notepad++ among them) use that count to # decide where code stops, so an odd total renders everything after it as one # long code block. TICK = chr(96) def expand(text): """Replace with the current contents of that file. The fence is widened past the longest backtick run in the embedded file. CommonMark only lets a fence be closed by one at least as long as the opener, so a script containing triple backticks stays safely enclosed. """ def sub(m): name = m.group(1).strip() path = os.path.join(HERE, name) body = open(path, encoding='utf-8').read().rstrip('\n') ext = os.path.splitext(name)[1] lang = {'.py': 'python', '.pl': 'perl', '.cgi': 'perl'}.get(ext, '') runs = re.findall(TICK + '+', body) width = max([3] + [len(r) + 1 for r in runs]) fence = TICK * width return '%s%s\n%s\n%s' % (fence, lang, body, fence) return re.sub(r'', sub, text) # Populated by include_chapters(): normalized pre-renumber anchor -> final # anchor. Consulted by fix_anchors() for links that chapters wrote against # their own original heading numbers. RENUMBERED = {} def slug(text): """GitHub-style heading anchor.""" text = re.sub(TICK + '([^' + TICK + ']*)' + TICK, r'\1', text) # code ticks text = re.sub(r'[*_]', '', text) # drop emphasis text = re.sub(r'[^\w\s-]', '', text.lower()) return re.sub(r'\s+', '-', text.strip()) def fence_spans(lines): """Line indices that sit inside a fenced code block.""" inside, out, fence = False, set(), None for i, line in enumerate(lines): m = re.match(r'\s*(' + TICK + '{3,}|~{3,})', line) if m: if not inside: inside, fence = True, m.group(1)[0] elif m.group(1)[0] == fence: inside = False if inside: out.add(i) return out # Chapter drafts were written as standalone documents, so each opens by # restating what the subject is, where the source tree lives, what the version # string is and why the document exists. Assembled into one teardown that # becomes the same paragraph four times over -- section 1 already says it and # section 11 already explains the purpose. These are dropped from a chapter's # preamble (the region before its first sub-heading) only. FRONTMATTER = re.compile( r'^(?:' r'\*\*(?:Subject|Source tree|Source tree analyzed|Version string|Purpose|' r'Build dates|Nature of this document|Document\s+\d+)\b' r'|Source tree\s*:' # A caption line -- "**Ikonboard v2.1.9 Beta** -- cgi-bin/*.cgi ..." -- not # prose that merely opens with the product in bold. The "v" and the # trailing dash together are what distinguish the two. r'|\*\*Ikonboard\s+v2\.1\.9[^*]*\*\*\s*[----]' r'|All line citations refer to' r'|This chapter documents all' # Bookkeeping subtitles ("*Source: ...*", "*Chapter 1 of ...*"). Editorial # subtitles are left alone -- they say something the title does not. r'|\*(?:Chapter\s+\d|Source\s*:|Everything in this chapter)' r')', re.I) def strip_frontmatter(body): """Remove standalone-document boilerplate from a chapter preamble.""" lines = body.split('\n') if not lines: return body head, rest = [lines[0]], lines[1:] # keep the "## N. Title" line # The preamble runs until the chapter's first sub-heading. end = next((i for i, l in enumerate(rest) if l.startswith('#')), len(rest)) pre, tail = rest[:end], rest[end:] # A dropped bold label often wraps onto following lines; drop those too, # up to the next blank line, or the label's tail is left stranded. kept, skipping = [], False for l in pre: if FRONTMATTER.match(l.strip()): skipping = True continue if skipping: if not l.strip(): skipping = False continue kept.append(l) pre = kept # Drop a leading horizontal rule and collapse blank runs left behind by # the removals, so the title is not followed by an orphan divider. while pre and (not pre[0].strip() or pre[0].strip() == '---'): pre.pop(0) while pre and not pre[-1].strip(): pre.pop() out, blank = [], 0 for l in pre: blank = blank + 1 if not l.strip() else 0 if blank < 2: out.append(l) return '\n'.join(head + [''] + out + ([''] if out else []) + tail) def include_chapters(text): """Replace with that file, renumbered. Each chapter file is written standalone with a level-1 title. Here the title becomes `## . ` and every other heading is demoted one level so the assembled document has a single coherent outline. Headings inside fenced code blocks are left alone. """ def sub(m): num, name = m.group(1).strip(), m.group(2).strip() body = open(os.path.join(HERE, name), encoding='utf-8').read() lines = body.split('\n') fenced = fence_spans(lines) out, seen_title = [], False # Chapters carry their own contents lists pointing at their own # headings. Renumbering the headings invalidates those links, so # record old-anchor -> new-anchor and rewrite them afterwards. remap = {} for i, line in enumerate(lines): if i in fenced or not line.startswith('#'): out.append(line) continue hm = re.match(r'(#+)\s+(.*)$', line) if not hm: out.append(line) continue level, title = len(hm.group(1)), hm.group(2).strip() if level == 1 and not seen_title: seen_title = True # Chapter files title themselves variously: "04 - Script # Reference", "Ikonboard 2.1.9 Beta - History", "Architecture # -- How It Worked". Strip the bookkeeping prefix and any # trailing gloss so the assembled outline reads cleanly. title = re.sub(r'^\d{2}\s*[----:]\s*', '', title) title = re.sub(r'^Ikonboard 2\.1\.9( Beta)?\s*[----:]\s*', '', title) title = re.split(r'\s+[----]{1,2}\s+', title)[0].strip() out.append('## %s. %s' % (num, title)) continue # A chapter that uses level-1 headings for its internal groups # (rather than just for its title) would otherwise land those # groups at the same level as chapter titles. Push them to 3 so # they read as subsections; everything else drops one level. new_level = 3 if level == 1 else min(level + 1, 6) # Renumber a chapter-local "3. Foo" to "8.3 Foo" so section # numbers are unique across the assembled document. Applies to # any heading landing at level 2 or 3, whichever level the # chapter happened to write it at. nm = re.match(r'(\d+)\.\s+(.*)$', title) if nm and new_level <= 3: new_title = '%s.%s %s' % (num, nm.group(1), nm.group(2)) remap[slug(title)] = slug(new_title) # Also record it globally under a hyphen-run-collapsed key, # so fix_anchors() can resolve links written against the # original numbering regardless of how the anchor was spelled. RENUMBERED[re.sub(r'-+', '-', slug(title)).strip('-')] = \ slug(new_title) title = new_title out.append('%s %s' % ('#' * new_level, title)) body = strip_frontmatter('\n'.join(out).strip('\n')) if remap: body = re.sub( r'\]\(#([\w-]+)\)', lambda m: '](#%s)' % remap.get(m.group(1), m.group(1)), body) return body text = re.sub(r'<!--\s*CHAPTER:(\d+):([^>]+?)-->', sub, text) # <!--INCLUDE:file.md--> drops a file in verbatim, for prose that already # carries correctly-levelled headings of its own. def raw(m): name = m.group(1).strip() return open(os.path.join(HERE, name), encoding='utf-8').read().strip('\n') return re.sub(r'<!--\s*INCLUDE:([^>]+?)-->', raw, text) def scrub_paths(text): """Rewrite the local tree root to a neutral relative path. Chapter drafts cite the tree wherever it happened to be unpacked. The published document should read the same for everyone, so the absolute root is normalized to `ib219/` on the way out and separators are flipped to forward slashes. """ root = os.path.basename(os.path.dirname(HERE)) # e.g. "ib219" # <drive>:\...\ib219\ -> ib219/ # # Only backslash-separated paths are rewritten. Matching forward slashes # too would eat URLs -- in "https://archive.org/details/ib219" the "s:" # reads as a drive letter -- so the leading lookbehind rejects a letter # preceded by another word character, and the separator must be "\". text = re.sub(r'(?<![A-Za-z0-9])[A-Za-z]:\\(?:[^\s`"\'<>|]+?\\)*?' + re.escape(root) + r'\\?', root + '/', text) # The substitution above rewrites only as far as the root, so a deeper # path keeps its native separators ("ib219/cgi-bin/"). Flip those too. text = re.sub(r'\b' + re.escape(root) + r'/([\w.\-]+(?:\\[\w.\-]*)+)', lambda m: root + '/' + m.group(1).replace('\\', '/'), text) return re.sub(r'\b' + re.escape(root) + r'/((?:[\w.\-]+/)*)\\', lambda m: root + '/' + m.group(1), text) def americanize(text): holes = {} for i, k in enumerate(KEEP): token = '\x00KEEP%d\x00' % i if k in text: holes[token] = k text = text.replace(k, token) for a, b in US: text = text.replace(a, b) for token, k in holes.items(): text = text.replace(token, k) return text def to_ascii(text): for a, b in ASCII_FOLD: text = text.replace(a, b) bad = sorted({ch for ch in text if ord(ch) > 126}) if bad: # Report by codepoint -- printing the characters themselves would # itself fail on a cp1252 console, which is the whole reason the # output is folded to ASCII in the first place. print('WARNING: non-ASCII left in output: %s' % ', '.join( 'U+%04X' % ord(ch) for ch in bad)) return text def fix_anchors(text): """Reconcile internal links against the final heading set. Anchors are computed from heading text, and heading text is rewritten several times on the way through this builder (chapter renumbering, then the ASCII fold, which turns an em dash into two hyphens). Rather than try to keep every intermediate form in step, resolve links once at the end: match on a form with hyphen runs collapsed, which is stable across all of those rewrites. """ def norm(a): return re.sub(r'-+', '-', a).strip('-') lines = text.split('\n') fenced = fence_spans(lines) index = {} for i, line in enumerate(lines): if i in fenced: continue m = re.match(r'#{2,6}\s+(.*)$', line) if m: a = slug(m.group(1).strip()) index.setdefault(norm(a), a) unresolved = [] def sub(m): want = m.group(1) if norm(want) in index: return '](#%s)' % index[norm(want)] # Fall back to the chapter-renumbering map for links that still # point at a heading's original, pre-assembly number. target = RENUMBERED.get(norm(want)) if target and norm(target) in index: return '](#%s)' % index[norm(target)] unresolved.append(want) return m.group(0) text = re.sub(r'\]\(#([\w-]+)\)', sub, text) for a in sorted(set(unresolved)): print('CHECK: unresolved internal link -> #%s' % a) return text def check(text): """Guard against leaking local paths or unexpanded markers. Fenced code is exempt: this builder's own source is embedded in the document, and it necessarily contains both the marker syntax and the patterns used to detect it. """ lines = text.split('\n') fenced = fence_spans(lines) problems = [] for pat, why in [ (r'[A-Za-z]:\\\\?(?:Mike|Users|_git)', 'local Windows path'), (r'CLAUDE_JOB_DIR', 'job temp path'), (r'<!--\s*CODE:', 'unexpanded CODE marker'), (r'<!--\s*CHAPTER:', 'unexpanded CHAPTER marker'), (r'<!--\s*INCLUDE:', 'unexpanded INCLUDE marker'), ]: for m in re.finditer(pat, text): line = text.count('\n', 0, m.start()) + 1 if (line - 1) in fenced: continue problems.append('line %d: %s (%s)' % (line, why, m.group(0))) # Every fence must close. A stray run inside an embedded script would # otherwise silently turn the rest of the document into a code block in # any line-oriented highlighter. depth, opened = 0, None for i, l in enumerate(lines, 1): if re.match(r'\s*' + TICK + '{3,}', l): if depth == 0: depth, opened = 1, i else: depth = 0 if depth: problems.append('line %d: code fence opened and never closed' % opened) # Belt and braces: an odd total of triple-backtick runs means some # highlighters will lose track even though the fences themselves pair up. if text.count(TICK * 3) % 2: problems.append('odd number of %s runs (%d) -- naive highlighters ' 'will mis-render the tail' % (TICK * 3, text.count(TICK * 3))) # Prose cross-references ("see section 8") are written by hand against a # numbering this builder assigns, so they go stale whenever chapters are # reordered. Check every one against the headings that actually exist. sections = {m.group(1) for m in re.finditer(r'(?m)^##\s+(\d+)\.\s', text)} for i, l in enumerate(lines, 1): if i - 1 in fenced: continue for m in re.finditer(r'\bsections?\s+(\d+)', l, re.I): if m.group(1) not in sections: problems.append('line %d: cross-reference to section %s, ' 'which does not exist (have %s)' % (i, m.group(1), ','.join(sorted(sections, key=int)))) return problems def main(): args = [a for a in sys.argv[1:] if not a.startswith('--')] out = args[0] if args else DEFAULT_OUT text = open(SRC, encoding='utf-8').read() # Chapters first: they may themselves contain CODE markers. text = to_ascii(americanize(scrub_paths(expand(include_chapters(text))))) text = fix_anchors(text) for p in check(text): print('CHECK: %s' % p) lines = text.count('\n') + 1 targets = [out] # `--txt` also writes a byte-identical .txt twin. Web servers have no mime # mapping for .md, so it is served as application/octet-stream and browsers # download it instead of displaying it; .txt is text/plain everywhere, # which is what makes it viewable inline with no server configuration. if '--txt' in sys.argv: targets.append(os.path.splitext(out)[0] + '.txt') for path in targets: open(path, 'w', encoding='utf-8').write(text) print('%s: %d lines, %.0f KB' % ( path, lines, len(text.encode()) / 1024)) if __name__ == '__main__': main() ``` --- ## 12. About this teardown This is archival work. Ikonboard 2.1.9 Beta has not been hosted anywhere in roughly twenty-five years, ikonboard.com is gone, and the company that made it no longer exists. Nothing in this document describes an attack on a live system, because there is no live system. The security chapter is written the way it is because the interesting question about software this old is not *"can it be exploited"* -- everything can, and 2001 knew far less than we do -- but *"what did the state of the art actually look like, and which of these mistakes did the industry have to make before it learned"*. The answer turns out to be: most of them. Plaintext passwords in a cookie, Referer headers as CSRF protection, a blocklist sanitizer, configuration files that are executable code, and `flock` calls that lock a file after truncating it. Every one of those has a modern countermeasure that exists *because* of software like this. That lineage is the point. It is also worth saying plainly that Ikonboard was good at its job. It ran on hosting that offered nothing but a cgi-bin, it installed in ten minutes, its installer diagnosed its own failures better than most software does now, and tens of thousands of communities ran on it. The parts of it that look indefensible today were, in 2001, either normal practice or a considered trade-off against constraints that no longer exist. ### What is uncertain Anything inferred rather than observed is marked as such in the text. The two significant open questions: - **Who made the September 2001 edit.** Very likely ikonboard.com. The `.bak` files prove the tree was assembled there, and the copy was in a downloader's hands by early November 2001 already carrying the fix -- which is what a maintained download looks like, and what `help.html` says the project did. What is *not* proven is that the vendor typed this specific patch into this specific zip; a third party could in principle have re-issued a patched copy. Section 8 lays out both sides. - **Where this particular copy was fetched from.** ikonboard.com directly, or a mirror? Nothing in the tree distinguishes them, and a mirror would in any case have served whatever the project published. - **The relationship between the credited dev team and the file headers.** Every script says "All files written by Matthew Mecham" while the changelog credits LarryC, Peter and SmileyMan for specific 2.1.9 fixes. The headers were evidently never updated; how the work was actually divided is not recoverable from the source. Where a claim rests on a web source rather than on the archive, the URL is cited inline. ### Acknowledgements **FieRcE YeD** -- for keeping the zip. This document exists because a copy of Ikonboard 2.1.9 survived twenty-five years in a personal backup and was uploaded rather than deleted, which is the entire mechanism by which anything from this era still exists. Preservation is mostly just somebody not throwing something away. The connection is not incidental. **Final Fantasy Fanatic**, FieRcE YeD's forum, ran on this exact software. A reconstruction of that board is readable at <https://doors98.com/forum-ib219/> -- and to be exact about what that is, it is **static HTML rebuilt from Wayback Machine captures**, not a running Ikonboard. None of the board's flat-file data survives. What survives is the output: pages the software produced while it was still up, scraped back out of a web archive years later. Which is part of why the specification in section 5 is worth having. Those captures are the only remaining evidence of that board, and everything behind them -- the pipe-delimited member records, the `pips*.gif` rank ladder, the emoticon substitution, the iB-code parser -- is documented here from the code that generated them. This is the manual for a specific dead forum as much as it is a general one. **doors98.com** is a Windows 98 desktop rebuilt in a browser -- a parody, an archive, and a place to put things that no longer have anywhere to live. It already hosts recoveries from this same era and scene, including the Final Fantasy Fanatic and ffoncrack.com forum archives and several Games Factory and Multimedia Fusion applications rescued from the same backup. ### Where things live | | | |---|---| | The 2.1.9 distribution -- June 2001 release plus the vendor's September patch | <https://archive.org/details/ib219> | | This document, raw | <https://doors98.com/misc/ikonboard-2.1.9-teardown.txt> | | This document, as Markdown | <https://doors98.com/misc/ikonboard-2.1.9-teardown.md> | | This document, on the desktop | <https://doors98.com/misc/ikonboard-2.1.9-teardown> | | Final Fantasy Fanatic -- a board that ran it, rebuilt as static HTML from Wayback captures | <https://doors98.com/forum-ib219/> | | Other recovered software from this era | <https://archive.org/details/@fierceyed> | (The two copies are byte-identical. Web servers have no mime mapping for `.md`, so that one is sent as `application/octet-stream` and browsers download it instead of showing it; the `.txt` is `text/plain` everywhere and renders inline. The document is written in Markdown and kept pure ASCII so it reads correctly either way. The `.md` is the canonical copy to link and mirror; the `.txt` is the one to read in a browser.) ### Corrections Ikonboard's own community is the only remaining source for a great deal of this, and much of it was never written down anywhere but a support forum that no longer exists. If you were on the Ikonboard 2 dev team, ran a board on 2.1.x, know what the 2.1.8r2 security fixes actually were, or have a copy of a version this archive lacks: corrections and additions are genuinely welcome. Rights holders named in this document who want something removed should say so.