Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Basic SMTP/IMAP probe: look for bad TLS and StartTLS downgrade attacks #989

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
325 changes: 325 additions & 0 deletions internal/engine/experiment/imap/imap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
package imap

import (
"bufio"
"context"
"crypto/tls"
"fmt"
"github.com/pkg/errors"
"net/url"
"strings"
"time"

"github.com/ooni/probe-cli/v3/internal/engine/experiment/urlgetter"
"github.com/ooni/probe-cli/v3/internal/measurexlite"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/tcprunner"
"github.com/ooni/probe-cli/v3/internal/tracex"
)

var (
// errNoInputProvided indicates you didn't provide any input
errNoInputProvided = errors.New("not input provided")

// errInputIsNotAnURL indicates that input is not an URL
errInputIsNotAnURL = errors.New("input is not an URL")

// errInvalidScheme indicates that the scheme is invalid
errInvalidScheme = errors.New("scheme must be imap(s)")
)

const (
testName = "imap"
testVersion = "0.0.1"
)

// Config contains the experiment config.
type Config struct{}

type runtimeConfig struct {
host string
port string
forcedTLS bool
noopCount uint8
}

func config(input model.MeasurementTarget) (*runtimeConfig, error) {
if input == "" {
// TODO: static input data (eg. gmail/riseup..)
return nil, errNoInputProvided
}

parsed, err := url.Parse(string(input))
if err != nil {
return nil, fmt.Errorf("%w: %s", errInputIsNotAnURL, err.Error())
}
if parsed.Scheme != "imap" && parsed.Scheme != "imaps" {
return nil, errInvalidScheme
}

port := ""

if parsed.Port() == "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps instead of setting a default port, we should throw an error due to a malformed input.

Since we generally use the input column for searching and filtering through measurements, a measurement run with as input imap://gmail.com will not be grouped together with a measurement with input imap://gmail.com:143.

// Default ports for StartTLS and forced TLS respectively
if parsed.Scheme == "imap" {
port = "143"
} else {
port = "993"
}
} else {
// Valid port is checked by URL parsing
port = parsed.Port()
}

validConfig := runtimeConfig{
host: parsed.Hostname(),
forcedTLS: parsed.Scheme == "imaps",
port: port,
noopCount: 10,
}

return &validConfig, nil
}

// TestKeys contains the experiment results for an entire domain host
type TestKeys struct {
Host string `json:"hostname"`
Queries []*model.ArchivalDNSLookupResult `json:"queries"`
// Individual IP/port results
Runs []*IndividualTestKeys `json:"runs"`
// Used for global failure (DNS resolution)
Failure string `json:"failure"`
}

func newTestKeys(host string) *TestKeys {
tk := new(TestKeys)
tk.Host = host
return tk
}

// Hostname TCPRunnerModel
func (tk *TestKeys) Hostname(host string) {
tk.Host = host
}

// DNSResults TCPRunnerModel
func (tk *TestKeys) DNSResults(res []*model.ArchivalDNSLookupResult) {
// TODO: not sure if we are passed the overall trace results and should overwrite key, or just append
tk.Queries = append(tk.Queries, res...)
}

// Failed TCPRunnerModel
func (tk *TestKeys) Failed(msg string) {
tk.Failure = msg
}

// NewRun TCPRunnerModel
func (tk *TestKeys) NewRun(addr string, port string) tcprunner.TCPSessionModel {
itk := newIndividualTestKeys(addr, port)
tk.Runs = append(tk.Runs, itk)
return itk
}

// IndividualTestKeys contains the experiment results for a single IP/port combo
type IndividualTestKeys struct {
TCPConnect []*model.ArchivalTCPConnectResult `json:"tcp_connect"`
TLSHandshake *model.ArchivalTLSOrQUICHandshakeResult `json:"tls_handshakes"`
Failure string `json:"failure"`
FailureStep string `json:"failed_step"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
FailureStep string `json:"failed_step"`
FailedStep string `json:"failed_step"`

IP string `json:"ip"`
Port string `json:"port"`
noopCounter uint8
}

func newIndividualTestKeys(addr string, port string) *IndividualTestKeys {
itk := new(IndividualTestKeys)
itk.IP = addr
itk.Port = port
return itk
}

// IPPort TCPSessionModel
func (itk *IndividualTestKeys) IPPort(ip string, port string) {
itk.IP = ip
itk.Port = port
}

// ConnectResults TCPSessionModel
func (itk *IndividualTestKeys) ConnectResults(res []*model.ArchivalTCPConnectResult) {
itk.TCPConnect = append(itk.TCPConnect, res...)
}

// HandshakeResult TCPSessionModel
func (itk *IndividualTestKeys) HandshakeResult(res *model.ArchivalTLSOrQUICHandshakeResult) {
itk.TLSHandshake = res
}

// FailedStep TCPSessionModel
func (itk *IndividualTestKeys) FailedStep(failure string, step string) {
itk.Failure = failure
itk.FailureStep = step
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
itk.FailureStep = step
itk.FailedStep = step

}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you really need setters for the individualTestKeys attributes. You can just set them directly in the body of the experiment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I introduced this helper method to be sure i wouldn't miss one of the two keys when reporting an error. Is this unreasonable? Also, the field is named FailureStep precisely because this method FailedStep creates a naming conflict. I could rename it of course if you think it's wise to keep this helper method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding to what @hellais said:

The use case where you need setters I can think of is the one in which you're running operations in parallel and therefore want to protect the test keys with a mutex. In such a case, I suggest this pattern:

type TestKeys struct {
  Foo string `json:"foo"`

  mu *sync.Mutex // not exported because lowercase
}

func (tk *TestKeys) setFoo(value string) {
  defer tk.mu.Unlock()
  tk.mu.Lock()
  tk.Foo = value
}

For cases in which you're running operations in parallel, I would recommend setting keys directly.


// Measurer performs the measurement.
type Measurer struct {
// Config contains the experiment settings. If empty we
// will be using default settings.
Config Config

// Getter is an optional getter to be used for testing.
Getter urlgetter.MultiGetter
}

// ExperimentName implements ExperimentMeasurer.ExperimentName
func (m Measurer) ExperimentName() string {
return testName
}

// ExperimentVersion implements ExperimentMeasurer.ExperimentVersion
func (m Measurer) ExperimentVersion() string {
return testVersion
}

// Run implements ExperimentMeasurer.Run
func (m Measurer) Run(ctx context.Context, args *model.ExperimentArgs) error {
sess := args.Session
measurement := args.Measurement
log := sess.Logger()
trace := measurexlite.NewTrace(0, measurement.MeasurementStartTimeSaved)

config, err := config(measurement.Input)
if err != nil {
// Invalid input data, we don't even generate report
return err
}

tk := newTestKeys(config.host)
measurement.TestKeys = tk

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

tlsconfig := tls.Config{
InsecureSkipVerify: false,
ServerName: config.host,
}

runner := &tcprunner.TCPRunner{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should eventually put something like this into the standard measurement library. I think putting it as an experiment package is sub-optimal.

@bassosimone where do you think something like this could go?

Copy link
Contributor

@bassosimone bassosimone Nov 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should eventually put something like this into the standard measurement library. I think putting it as an experiment package is sub-optimal.

@bassosimone where do you think something like this could go?

Based on a quick skim through the package implementation itself, this code seems to me an abstraction built on top of measurexlite. It seems to support well the use case of this experiment and may support alike experiments, so I think it's fine to give it a toplevel-internal directory name such as ./internal/tcprunner.

BTW, the fact that @ooninoob felt the need to write a support package is very precious information. Our previous assumption was that people were going to write experiments using measurexlite primitives. However, it seems this level of abstraction is too low to really be satisfactory. Because we also determined the declarative API of urlgetter is also not flexible enough, it is clear to me there is need to invest some extra time to provide a more comprehensive abstraction on top of measurexlite to support several use cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful if i wrote about my personal feelings as a newcomer regarding the internal APIs? I could open a new ticket about this. Please note that i'm still not confident about what makes a good data structure for the test keys when it comes to testing multiple things (several hostnames and/or IP/port combos, as in these probes and the DHT/Bittorrent probes) and that consideration very much affects how an internal API should feel like.

I don't know what is a good answer and i for sure can't come up with specific guidelines, but it feels to me like there's different ways to approach the testkeys organization from a high-level perspective:

  • global testkeys for everything (linear queries/tcp results/etc)
  • global testkeys to store one invididual testkeys for each hostname (including DNS results)
  • global testkeys (for DNS results) storing one individual testkeys for each IP resolved
  • global testkeys storing one individual testkeys for each hostname, each storing one sub-individual testkeys for each resolved IP

It would sure be possible to have explicit guidelines about this, but just for starting to develop things we could have high-level APIs for several of those modes of operation. I think exposing a failed_step/failed_run as part of those high-level APIs would also bring value to help produce better test results (and better debugging abilities) without having each probe reinvent the wheel.

That is my personal opinion that probes should focus on their probing logic (networking/protocol stuff) and not have to know/care about ooni internals and how data is magically collected and presented. This would for sure lower the barrier for contribution for new probes, although i would understand if the ooni project willingly prefers to keep the barrier high and the implementation details explicit. I just felt from talking to @hellais and from reading your comment that it may not be a conscious design decision to keep me (and other future contributors) buried under ooni internal abstractions and that all this error-prone boilerplate may have been accidental ;-)

Copy link
Contributor

@bassosimone bassosimone Dec 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ooninoob thank you for your very detailed reply!

Some comments inline:

different ways to approach the testkeys organization from a high-level perspective:

  • global testkeys for everything (linear queries/tcp results/etc)
  • global testkeys to store one invididual testkeys for each hostname (including DNS results)
  • global testkeys (for DNS results) storing one individual testkeys for each IP resolved
  • global testkeys storing one individual testkeys for each hostname, each storing one sub-individual testkeys for each resolved IP

I am not sure sure about what you mean here. Are you complaining about the data format being inconsistent across experiments or are you referring to the APIs available for writing experiments?

probes should focus on their probing logic (networking/protocol stuff) and not have to know/care about ooni internals and how data is magically collected and presented

I agree with the need to allow a programmer to focus on the network experiment logic. I find it to insightful to see you would like our documentation to spend less time about how data is collected and presented. I see why the current documentation focuses on that and why you rightfully say you should be (too much) concerned with that.

lower the barrier for contribution for new probes, although i would understand if the ooni project willingly prefers to keep the barrier high and the implementation details explicit

We previously had a high level API, which we now have deprecated. The reason why we deprecated it is that it was and is a burden to maintain in light of having a number of experiments to maintain. Each experiment has some set of semi-conflicting requirements, which shapes and puts pressure on such API.

You ended up using directly the internal primitives (aka "step-by-step") that we determined are "better", where better is defined in terms of the kind of experiments (and follow-up experiments) we can write. We were not sure whether there was a need to add another API on top of that or not. Your experience tells us that we definitely need to offer some kind of API for people to write experiments that is more abstract.

it may not be a conscious design decision to keep me (and other future contributors) buried under ooni internal abstractions and that all this error-prone boilerplate may have been accidental

Yeah, it's not a conscious decision. I suppose it's more of a learning process where, on the one end, we need to adapt our tools for measuring censorship and detecting which low-level step failed and possibly try to run some follow-up test. On the other end, we also need to provide contributors with easy to use abstractions. There's definitely a time lag in there and it's also true that it's difficult to know the direction without feedback, so thank you for your feedback!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Oh, @ooninoob, if you have time to chat about this, perhaps we can have a chat on Slack and then distill the result of the chat in some issue or design document to improve the contributing experience.)

Tk: tk,
Trace: trace,
Logger: log,
Ctx: ctx,
Tlsconfig: &tlsconfig,
}

// First resolve DNS
addrs, success := runner.Resolve(config.host)
if !success {
return nil
}

for _, addr := range addrs {
tcpSession, success := runner.Conn(addr, config.port)
if !success {
continue
}
defer tcpSession.Close()

if config.forcedTLS {
log.Infof("Running direct TLS mode to %s:%s", addr, config.port)

if !tcpSession.Handshake() {
continue
}

// Try NoOps
if !testIMAP(tcpSession, config.noopCount) {
continue
}
} else {
log.Infof("Running StartTLS mode to %s:%s", addr, config.port)

// Upgrade via StartTLS and try NoOps
if !tcpSession.StartTLS("A1 STARTTLS\n", "TLS") {
continue
}

if !testIMAP(tcpSession, config.noopCount) {
continue
}
}
}

return nil
}

func testIMAP(s *tcprunner.TCPSession, noop uint8) bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very bike-sheddy comment, but maybe these functions should not be named with the test prefix to avoid confusion with the Test functions inside of a unit/integration test.

// Auto-choose plaintext/TCP session
// TODO: move to Debugf
s.Runner.Logger.Infof("Retrieving existing connection")
conn := s.CurrentConn()
s.Runner.Logger.Infof("Starting IMAP query")

command, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
s.FailedStep(*tracex.NewFailure(err), "imap_wait_capability")
return false
}

if !strings.Contains(command, "CAPABILITY") {
s.FailedStep(fmt.Sprintf("Received unexpected IMAP response: %s", command), "imap_wrong_capability")
return false
}

s.Runner.Logger.Infof("Finished starting IMAP")

if noop > 0 {
// Downcast TCPSession's itk into typed IndividualTestKeys to access noopCounter field
concreteITK := s.Itk.(*IndividualTestKeys)
s.Runner.Logger.Infof("Trying to generate more no-op traffic")
concreteITK.noopCounter = 0
for concreteITK.noopCounter < noop {
concreteITK.noopCounter++
s.Runner.Logger.Infof("NoOp Iteration %d", concreteITK.noopCounter)
_, err = conn.Write([]byte("A1 NOOP\n"))
if err != nil {
s.FailedStep(*tracex.NewFailure(err), fmt.Sprintf("imap_noop_%d", concreteITK.noopCounter))
break
}
}

if concreteITK.noopCounter == noop {
s.Runner.Logger.Infof("Successfully generated no-op traffic")
return true
}
s.Runner.Logger.Warnf("Failed no-op traffic at iteration %d", concreteITK.noopCounter)
return false
}

return true
}

// NewExperimentMeasurer creates a new ExperimentMeasurer.
func NewExperimentMeasurer(config Config) model.ExperimentMeasurer {
return Measurer{Config: config}
}

// SummaryKeys contains summary keys for this experiment.
//
// Note that this structure is part of the ABI contract with ooniprobe
// therefore we should be careful when changing it.
type SummaryKeys struct {
//DNSBlocking bool `json:"facebook_dns_blocking"`
//TCPBlocking bool `json:"facebook_tcp_blocking"`
IsAnomaly bool `json:"-"`
}

// GetSummaryKeys implements model.ExperimentMeasurer.GetSummaryKeys.
func (m Measurer) GetSummaryKeys(measurement *model.Measurement) (interface{}, error) {
sk := SummaryKeys{IsAnomaly: false}
_, ok := measurement.TestKeys.(*TestKeys)
if !ok {
return sk, errors.New("invalid test keys type")
}
return sk, nil
}
Loading