PHP2Shell: Migrating a Self-Hosted HumHub from humhub-dev to the Official Docker Image

On 21 October 2026 the PHP project shipped emergency releases for two critical remote code execution bugs in the PHP engine, CVE-2026-109618 and CVE-2026-109619, better known as PHP2Shell. Details had leaked a day earlier and exploitation started almost immediately. The fixed versions are PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12.

One of the things I host is a HumHub instance, running in Docker behind NPMplus. I took it off the web straight away (NPMplus host disabled, a wildcard redirect pointing to a maintenance notice) and kept the containers running in the background so I could work on the update. This post is the write-up of that update: it turned into a full migration, and a few of the pitfalls are worth sharing.

Starting point

I installed HumHub in June 2025 using humhub/humhub-dev:master, because at the time it was the only Compose variant I got working. It ran HumHub 1.17.3 with MariaDB and Redis. The important thing to understand about PHP2Shell: it’s a bug in PHP itself, not in HumHub. Updating HumHub only helps if the image you end up with ships a patched PHP binary. So the first check is always:

docker compose exec humhub php -v
# PHP 8.2.28 (cli) (built: Mar 13 2025 18:21:38)

8.2.28: vulnerable. A docker compose pull brought me to 8.2.29, built July 2025, and that was it. The humhub-dev:master tag simply hadn’t been rebuilt for over a year and was never going to get the fix. Lesson one: a dev image is not a production dependency, and moving tags only help if someone actually rebuilds them.

The official image

HumHub now maintains an official image at github.com/humhub/docker. The documentation is still thin (the upgrading page literally says “TBD”), so I inspected the image before touching any data:

docker pull humhub/humhub:stable
docker run --rm --entrypoint sh humhub/humhub:stable -c 'php -v; ls -la /data; ls /'
# PHP 8.4.27 (cli) (built: Oct 21 2026 15:53:39) (ZTS)

PHP 8.4.27, built on release day: patched. The ZTS build and the caddy folder in /data give it away: this image is based on FrankenPHP (Caddy with embedded PHP) rather than Apache/PHP-FPM like the old one.

The key question was whether my existing data folder would fit. Reading /docker-entrypoint.sh answered it:

mkdir -p /data/{uploads,assets,logs,config,modules,modules-custom,themes,caddy}
cp -rn /opt/humhub/protected/config/ /data/
cp -rn /opt/humhub/uploads/ /data/
rm -rf /data/themes/HumHub   # since 1.19 the default theme lives elsewhere

That’s exactly the layout the old image used under /var/lib/humhub, so the old folder can be mounted 1:1 to /data. The cp -n means existing config isn’t overwritten. The theme deletion is harmless as long as themes/ only contains the stock HumHub theme. If you have a custom theme in there, check it first.

Backup first

docker compose exec db sh -c 'mariadb-dump -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases' > humhub-db-$(date +%F).sql
docker compose down
tar czf humhub-data-$(date +%F).tgz humhub-data
cp -a humhub-data humhub-data-v2          # migrate a copy, keep the original for rollback
cp compose.yaml compose.yaml.bak-$(date +%F)

One small trap here: after docker compose down, I started only the HumHub service again with docker compose up -d humhub. That starts HumHub and its depends_on services, which was only the database. Redis stayed down, and HumHub, which uses Redis for cache and sessions, promptly reported “Invalid database configuration – Failed to open DB connection”. The database was perfectly fine. A plain docker compose up -d fixed it, and the new compose file below lists Redis in depends_on.

The new compose file

services:
  humhub:
    image: humhub/humhub:${HUMHUB_DOCKER_VERSION}
    restart: unless-stopped
    ports:
      - 8404:80
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - ./humhub-data-v2:/data
    env_file:
      - ./humhub.env
    environment:
      - SERVER_NAME=:80
      - HUMHUB_DOCKER__ACCESS_LOG=true
      - |-
        CADDY_GLOBAL_OPTIONS=servers {
          trusted_proxies static 10.0.0.2
        }
      - HUMHUB_CONFIG__COMPONENTS__DB__DSN=${HUMHUB_DOCKER_DB_DSN}
      - HUMHUB_CONFIG__COMPONENTS__DB__USERNAME=${HUMHUB_DOCKER_DB_USER}
      - HUMHUB_CONFIG__COMPONENTS__DB__PASSWORD=${HUMHUB_DOCKER_DB_PASSWORD}
      - HUMHUB_FIXED_SETTINGS__BASE__BASE_URL=https://${HUMHUB_DOCKER_DOMAIN}
      - HUMHUB_CONFIG__COMPONENTS__CACHE__CLASS=yii\redis\Cache
      - HUMHUB_CONFIG__COMPONENTS__REDIS__CLASS=yii\redis\Connection
      - HUMHUB_CONFIG__COMPONENTS__REDIS__HOSTNAME=redis
      - HUMHUB_CONFIG__COMPONENTS__REDIS__PORT=6379
      - HUMHUB_CONFIG__COMPONENTS__REDIS__DATABASE=0
      - HUMHUB_CONFIG__COMPONENTS__SESSION__CLASS=yii\redis\Session
  db:
    image: mariadb:11.7
    restart: unless-stopped
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    user: root
    volumes:
      - ./mysql-data:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=${HUMHUB_DOCKER_DB_PASSWORD}
    healthcheck:
      test: ["CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
  redis:
    image: redis:6.2-alpine
    restart: unless-stopped
    command: redis-server --save 20 1 --loglevel warning
    volumes:
      - ./cache:/data

Notes on the important bits:

  • SERVER_NAME=:80: the image does automatic HTTPS by default (Caddy). Behind a reverse proxy that terminates TLS you want plain HTTP, and Caddy confirms it in the log with “no automatic HTTPS will be applied”. The NPMplus host keeps pointing at port 8404; only the container side changed from 8404 to 80.
  • The ${HUMHUB_DOCKER_VERSION} variable comes from the existing .env file. I reused it for the image tag (more on pinning below).
  • Pin MariaDB to the major version you’re already running (check the DB log). image: mariadb without a tag is a surprise waiting to happen.
  • The old compose had expose: 127.0.0.1:3306:3306 on the DB. expose doesn’t take host bindings, so that line never did anything. Removed.

First start: 1.17.3 → 1.19.3

The new image runs migrations and module updates automatically in its startup script. On first start it applied 27 core migrations (comments, likes and activities moved to a new content-ID structure, LDAP columns reworked, the old logging table dropped, and more) and then updated all 17 marketplace modules, including the Bootstrap 5 migration of the Clean Theme. The whole thing took about a minute. After that, FrankenPHP came up with PHP 8.4.27, and the scheduler and both queue workers reported “Database installed and fully migrated”.

The admin area confirmed HumHub 1.19.3, up to date, and conveniently shows the exact Docker image tag and build time.

Was I compromised?

Because exploitation started before the patches were out, it’s worth checking for dropped webshells before going back online. New or modified PHP files since the leak date are the classic indicator:

# the migrated copy (modules excluded – they were just rewritten by the updates)
find humhub-data-v2 -name '*.php' -newermt '2026-10-19' -not -path '*/assets/*' -not -path '*/modules/*' -ls
# the untouched original, including modules
find humhub-data -name '*.php' -newermt '2026-10-19' -ls

The only hits were config/dynamic.php, rewritten by a migration at the exact minute it ran and containing only the DB config, and an empty, zero-byte theme file touched by the old container on startup. Nothing in uploads/, which is where anything suspicious would show up. Clean.

Gotcha: the Redis cache setting moved in 1.19

On the first start the log showed cache (yii\redis\Cache). After a restart it suddenly said cache (yii\caching\FileCache). The reason is the migration m250807_194741_remove_cache_settings: since 1.19, the old fixed setting HUMHUB_FIXED_SETTINGS__BASE__CACHE_CLASS is ignored. The cache has to be configured as a component instead:

# old (no effect since 1.19)
- HUMHUB_FIXED_SETTINGS__BASE__CACHE_CLASS=yii\redis\Cache
# new
- HUMHUB_CONFIG__COMPONENTS__CACHE__CLASS=yii\redis\Cache

Nothing breaks if you miss this; HumHub just quietly falls back to the slower file cache.

Pinning the version

Pinning the exact build tag would freeze PHP too, which is exactly how I ended up on an unpatched image in the first place. The official image publishes minor-version tags, so I pinned to 1.19. That way I get patch releases and PHP rebuilds with every pull, but no surprise jump to 1.20 with new migrations:

docker manifest inspect humhub/humhub:1.19 >/dev/null && echo "1.19 exists"
sed -i 's/^HUMHUB_DOCKER_VERSION=.*/HUMHUB_DOCKER_VERSION="1.19"/' .env
docker compose pull humhub && docker compose up -d

Pinning only helps if you actually pull regularly. A notify-only update watcher such as Diun is a good idea, so the next PHP rebuild doesn’t go unnoticed.

Real client IPs behind the reverse proxy

The access log is off by default. After enabling it with HUMHUB_DOCKER__ACCESS_LOG=true, every request showed the NPMplus address as the client. To fix that, Caddy has to trust the proxy’s X-Forwarded-For header. The entrypoint appends its own log options to CADDY_GLOBAL_OPTIONS, so you can pass a servers block in. Caddyfile blocks need line breaks, hence the YAML literal block (|-) in the compose file above. Check what Compose actually passes in with:

docker compose config | grep -A3 CADDY_GLOBAL_OPTIONS
docker compose logs --since 2m humhub | grep -o '"client_ip":"[^"]*"' | sort | uniq -c

After that, the access log, which goes to stdout as JSON and therefore straight into Loki, showed real public addresses.

A trap I walked into: PHP’s own REMOTE_ADDR still shows the proxy, so I also tried telling Yii to trust it via HUMHUB_CONFIG__COMPONENTS__REQUEST__TRUSTED_HOSTS=["10.0.0.2"]. HumHub does parse the JSON, but HUMHUB_CONFIG__* variables apply to the console application too, and the console Request class has no trustedHosts property. Every console command failed with “Setting unknown property: yii\console\Request::trustedHosts”, including the scheduler, and an hour later the admin area showed “Setup incomplete – the cron job doesn’t seem to work”. Removing the line fixed it immediately. If you want that setting, put it in a web-only config file (/data/config/web.php) instead of the environment.

Housekeeping

  • Rotate secrets you pasted anywhere. At one point I pasted my .env into a chat, DB password included. The DB isn’t reachable outside the Docker network, but rotating took two minutes. Remember that HumHub also keeps a copy of the DB password in config/dynamic.php, so update it there too.
  • Rollback plan: the migrations change the database schema, so rolling back means restoring the SQL dump and using the old compose file with the untouched humhub-data folder.
  • After a few quiet days, delete the old data folder, the tarball and the dump.

Still on the list

  • MariaDB 11.7 → 11.8 (LTS); 11.7 was a short-term release.
  • Redis 6.2 → 7, since 6.2 is end of life.
  • A dedicated DB user instead of root.

Takeaways

  • For engine-level bugs like PHP2Shell, check the runtime version inside the container, not the app version.
  • Dev images and stale moving tags don’t get security rebuilds. Use the official image and a minor-version tag, and pull regularly.
  • Read the entrypoint before migrating. It told me more than the docs did.
  • Back up, migrate a copy, and check for webshells before going back online.
  • Watch for silently ignored settings after big version jumps (here: the cache class).
  • Environment-based config applies everywhere, console included. Web-only settings belong in a web-only config file.

Total downtime for users was about a week, most of it spent waiting with the instance safely offline. The actual migration took about an hour.

Leave a comment