Docker Container obfuscaten
Mit Docker lässt sich Software super auf eine Art und Weise deployen, dass sie auf jedem System laufen. Der große Nachteil dabei: wie ein Container gebaut wurde lässt sich im Nachgang anscheuen.
Hier wollen wir nun an einen Container gehen und den soweit misshandeln, dass sich erschwert nachvollziehen lässt, wie er gebaut wurde und wie genau er funktioniert.
Es geht hierbei NICHT darum die Anwendung in dem Container zu obfuscaten; sondern nur darum, den Container so zu verwurschteln, dass sich nicht mehr einfach nachvollziehen lässt, wie er gebaut wurde.
Vorab: hierdurch brechen wir auch Layer auf. Es ist also nicht mehr möglich, Layer-Caches auf dem Ziel-System zu benutzen; auf unserem Bau-System nutzen wir weiterhin den Build-Cache.
Ist-Situation
Wir arbeiten mit folgendem (gekürztem) Dockerfile:
FROM alpine:3.19.1
ENV WINEDEBUG=-all
RUN apk add --no-cache wine=[...]
RUN wget -P /tmp http://[...].msi \
&& wine msiexec /i /tmp/[...].msi \
&& rm -rf /tmp/[...].msi
RUN addgroup -S app && adduser -S app -G app
RUN apk add --no-cache nodejs-current npm
RUN mkdir /app && chown app:app /app
USER app
WORKDIR /app
COPY --chown=app:app src/package.json /app/package.json
RUN npm install --production
COPY --chown=app:app src/index.js /app/index.js
CMD ["node", "/app/app/index.js"]
Der Container installiert also wine, dann irgendeine MSI, macht ein bisschen Ordnerstruktur, kopiert eine package.json rein, npm install -production
, eine Javascript kommt auch rein und die wird dann ausgeführt. Soweit, so gut, ein 0815 Dockerfile. Bauen wir das mit docker build
, und schauen uns die Layer von dem gebauten Image an, bekommen wir folgendes:
Layer | Inhalt | Größe |
0 | ADD file:37a76ec18[…]5473 in / | 7.38 MB |
1 | CMD [„/bin/sh“] | 0 B |
2 | ENV WINEDEBUG=-all | 0 B |
3 | RUN /bin/sh -c apk add –no-cache wine=[…] | 411.04 MB |
4 | RUN /bin/sh -c wget -P /tmp http://[…].msi && wine msiexec /i /tmp/[…].msi && rm -rf /tmp/[…].msi | 729.67 MB |
5 | RUN /bin/sh -c addgroup -S app && adduser -S app -G app | 4.74 KB |
6 | RUN /bin/sh -c apk add –no-cache nodejs-current npm | 68.06 MB |
7 | RUN /bin/sh -c mkdir /app && […] | 0 B |
8 | USER app | 0 B |
9 | WORKDIR /app | 0 B |
10 | COPY –chown=app:app src/package.json /app | 14 B |
11 | RUN /bin/sh -c npm install –production | 1.78 MB |
12 | COPY –chown=app:app src/index.js /app/index.js | 713 KB |
13 | CMD [„node“ „/app/app/index.js“] | 0 B |
Und da ist auch dann schon das, was wir verhindern wollen – man sieht in dem gebautem Volume ALLES. Googlen wir den Hash in der ersten Zeile (da gekürzt, hier lang: 37a76ec18f9887751cd8473744917d08b7431fc4085097bb6a09d81b41775473
) handeln die ersten 15 Google-Suchergebnisse davon, dass das Alpine ist. Layer 1 gehört noch zum Alpine Image, dann folgt nahezu 1:1 unser Dockerfile.
Ein Blick auf die Layer
Schauen wir uns die Layer an, können wir diese in pauschal zwei Kategorien unterscheiden; die, die tatsächlich was im Dateisystem ändern:
Layer | Inhalt | Größe |
0 | ADD file:37a76ec18[…]5473 in / | 7.38 MB |
3 | RUN /bin/sh -c apk add –no-cache wine=[…] | 411.04 MB |
4 | RUN /bin/sh -c wget -P /tmp http://[…].msi && wine msiexec /i /tmp/[…].msi && rm -rf /tmp/[…].msi | 729.67 MB |
5 | RUN /bin/sh -c addgroup -S app && adduser -S app -G app | 4.74 KB |
6 | RUN /bin/sh -c apk add –no-cache nodejs-current npm | 68.06 MB |
7 | RUN /bin/sh -c mkdir /app && […] | 0 B |
10 | COPY –chown=app:app src/package.json /app | 14 B |
11 | RUN /bin/sh -c npm install –production | 1.78 MB |
12 | COPY –chown=app:app src/index.js /app/index.js | 713 KB |
Und die, die nichts im Dateisystem ändern:
Layer | Inhalt | Größe |
2 | ENV WINEDEBUG=-all | 0 B |
8 | USER app | 0 B |
9 | WORKDIR /app | 0 B |
13 | CMD [„node“ „/app/app/index.js“] | 0 B |
Die, die was im Dateisystem ändern, können wir zusammenlegen.
Die unteren, die nichts im Dateisystem ändern, können wie leider nicht zusammenlegen und müssen die später als einzelne Layer mitgeben.
Anpassen des Dockerfiles
Wir behalten unser Dockerfile von oben bei, und passen es nur marginal an. Unsere Änderungen im Detail sind:
- wir nehmen das ursprüngliche Dockerfile als Basefile
- Kopieren das komplette Root-Verzeichnis davon in unser eigentliches Image
- Anschließend führen wir nur die nicht dateisystem ändernden Anweisungen erneut aus
Durch das Umwandeln zu einem Basefile und das Kopieren des Root’s verschwinden dann die Layer 3, 4, 5, 6, 7, 10, 11, und 12 – und werden auf ein Layer zusammengemischt. Anschließend folgen nur noch Layer 2, 8, 9 und 13. Keiner wird wissen, welche Befehle wir beim Build in welcher Reihenfolge ausgeführt haben.
# Schritt 1: hier hängen wir das "as build" an
FROM alpine:3.19.1 AS build
# Das hier ist unverändert unser Dockerfile von oben
ENV WINEDEBUG=-all
RUN apk add --no-cache [...]
RUN wget -P /tmp http://[...].msi \
&& wine msiexec /i /tmp/[...].msi \
&& rm -rf /tmp/[...].msi
RUN addgroup -S app && adduser -S app -G app
RUN apk add --no-cache nodejs-current npm
RUN mkdir /app && chown app:app /app
USER app
WORKDIR /app
COPY --chown=app:app src/package.json /app/package.json
RUN npm install --production
COPY --chown=app:app src/index.js /app/index.js
CMD ["node", "/app/app/index.js"]
# Ab hier ist neu
# Wir erstellen ein neues Image von scratch (also leer), kopieren das Dateisystem
# aus Build und führen auf diesem die nicht Dateisystem verändernden Schritte aus
FROM scratch
COPY --from=build / /
ENV WINEDEBUG=-all
USER app
CMD ["node", "/app/app/index.js"]
Mit dem Copy aus dem zweiten „FROM“-Block kopieren wir das Dateisystem unseres fertig gebauten Images 1:1 in ein neues Image. Dabei übernimmt er auch die Dateiberechtigungen des Quellsystemes.
Nach einem docker build
baut er erst das build-Image, und dann unten unser tatsächliches Image
Die resultierenden Layer
Das Dockerfile hat zwar um ein vielfaches mehr Schritte, ist aber anschließend in den Layern nicht so einsichtig.
Layer | Inhalt | Größe |
0 | COPY / / #buildkit | 1.22 GB |
1 | ENV WINEDEBUG=-all | 0 B |
2 | USER app | 0 B |
3 | CMD [„node“, „/app/app/index.js“] | 0 B |
Wir sehen hier keine MSI, die erst heruntergeladen und installiert wird, wir sehen nicht Mal dass alpine genutzt wird; es wird nur das Root-Dateisystem kopiert.
Obligatorische Hinweise und anschließende Überlegungen
Durch das Verstecken der Schritte, mit denen ein Container gebaut wird, ist der Inhalt des Containers natürlich weiterhin sichtbar. Es ist also dadurch nicht sicher, dort Secrets wie API-Tokens o.ä. zu hinterlegen und das dann auf unbekannte Systeme zu deployen; der Inhalt des Containers ist nämlich weiterhin ungeschützt.
Ebenfalls ist es im Falle von JS-Anwendungen weiterhin easy möglich sich den Quellcode der in dem Container befindlichen Anwendung anzuschauen.
Der Artikel hier bezieht sich nur auf den Bauvorgang des Containers, nicht auf die Verschleierung des Inhaltes.
Und durch diese Schritte eliminieren wir auch den Layer-Cache von Docker. Container, die wir auf die hier genannte Art und Weise misshandeln, können nicht mehr effizient durch Docker gecacht werden und verbrauchen ein vielfaches an Speicherplatz.
Für Bastler: die Anzahl der Layer lässt sich bestimmt noch weiter reduzieren, indem man Layer 1 bis 3 aus dem finalen Image in ein Shell-Script packt, dieses im Build-Process in den Container haut und anschließend dann via RUN ausführt.
Dann besteht der Ziel-Container nur aus zwei Layern; COPY und RUN.